// Turns the raw extractor tree into a Figma-shaped tree: pass-through wrappers // collapsed, auto-layout inferred (direction / gap / padding / alignment), and // non-uniform gaps expressed as explicit spacer frames so every container can // still be re-flowed by hand in Figma. // // node scripts/figma-normalize.mjs // // Reads /tree.raw.json, writes /tree.fig.json. import fs from "node:fs"; import path from "node:path"; const OUT = process.argv[2] || "figma-extract/desktop"; // Optional: crop the captured viewport down to the design container width, so a // 2200px capture (where --maxw resolves to its 1648 cap) becomes a 1648 frame // with the real 16px gutters instead of a narrow column in dead margin. const CROP = Number(process.argv[3] || 0); const raw = JSON.parse(fs.readFileSync(path.join(OUT, "tree.raw.json"), "utf8")); if (CROP && raw.width > CROP) { const off = Math.round((raw.width - CROP) / 2); (function shift(n) { if (!n) return; n.x -= off; // Full-bleed bands span the whole viewport; clamp them to the frame. if (n.x <= 1 && n.x + n.w >= CROP - 1) { n.x = 0; n.w = CROP; } (n.children || []).forEach(shift); })(raw.root); raw.width = CROP; } const haveMedia = new Set( fs.existsSync(path.join(OUT, "media")) ? fs.readdirSync(path.join(OUT, "media")).map((f) => f.replace(/\.png$/, "")) : [], ); const R = (v) => Math.round(v * 100) / 100; const near = (a, b, t = 1.5) => Math.abs(a - b) <= t; const isAbs = (n) => n.pos === "absolute" || n.pos === "fixed"; const paints = (n) => (n.fills && n.fills.length) || (n.strokes && n.strokes.length) || (n.shadows && n.shadows.length) || n.opacity < 1 || n.clip; // ---------------------------------------------------------------- collapse --- function collapse(n) { if (!n.children || !n.children.length) return n; n.children = n.children.map(collapse).filter(Boolean); // Drop empty non-painting frames. n.children = n.children.filter( (c) => c.type !== "FRAME" || c.children.length || paints(c), ); // A lone child that fills its non-painting parent replaces the parent. while ( n.type === "FRAME" && n.children.length === 1 && !paints(n) && !isAbs(n.children[0]) && near(n.children[0].x, n.x, 1) && near(n.children[0].y, n.y, 1) && near(n.children[0].w, n.w, 1) && near(n.children[0].h, n.h, 1) ) { const c = n.children[0]; const keep = n.name.startsWith("#") ? n.name : c.name; n = { ...c, name: keep, pos: n.pos === "static" ? c.pos : n.pos }; if (!n.children) break; } return n; } // ------------------------------------------------------------ layout infer --- const overlapY = (a, b) => Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > Math.min(a.h, b.h) * 0.35; function isColumn(kids) { for (let i = 1; i < kids.length; i++) { if (kids[i].y < kids[i - 1].y + kids[i - 1].h - 1.5) return false; } return true; } function isRow(kids) { for (let i = 1; i < kids.length; i++) { if (kids[i].x < kids[i - 1].x + kids[i - 1].w - 1.5) return false; if (!overlapY(kids[i], kids[i - 1])) return false; } return true; } // Grid / wrapped flow -> synthesise one row frame per visual row. function rowGroups(kids) { const sorted = [...kids].sort((a, b) => a.y - b.y || a.x - b.x); const rows = []; for (const k of sorted) { const row = rows.find((r) => r.some((m) => overlapY(m, k))); if (row) row.push(k); else rows.push([k]); } for (const r of rows) r.sort((a, b) => a.x - b.x); if (rows.length < 2) return null; if (!rows.every((r) => isRow(r))) return null; return rows; } function bounds(kids) { return { x: Math.min(...kids.map((k) => k.x)), y: Math.min(...kids.map((k) => k.y)), r: Math.max(...kids.map((k) => k.x + k.w)), b: Math.max(...kids.map((k) => k.y + k.h)), }; } function wrapRow(kids, name) { const bb = bounds(kids); return { id: "row-" + kids[0].id, name, type: "FRAME", tag: "row", x: bb.x, y: bb.y, w: bb.r - bb.x, h: bb.b - bb.y, fills: [], strokes: [], radius: [0, 0, 0, 0], opacity: 1, clip: false, shadows: [], pos: "static", children: kids, }; } function spacer(px, dir, at) { return { id: "sp-" + at, name: "space/" + Math.round(px), type: "SPACER", w: dir === "H" ? px : 0, h: dir === "V" ? px : 0, }; } function layoutFor(n) { const all = n.children || []; const abs = all.filter(isAbs); let flow = all.filter((c) => !isAbs(c)); if (!flow.length) return { dir: null, abs, flow: [] }; let dir = null; if (flow.length === 1) { dir = "V"; } else if (isColumn(flow)) { dir = "V"; } else if (isRow(flow)) { dir = "H"; } else { const rows = rowGroups(flow); if (rows) { flow = rows.map((r, i) => (r.length === 1 ? r[0] : wrapRow(r, "row " + (i + 1)))); dir = "V"; } } if (!dir) return { dir: null, abs: all, flow: [] }; // Padding from the container edge to the flow content box. const bb = bounds(flow); const pad = [ Math.max(0, R(bb.y - n.y)), Math.max(0, R(n.x + n.w - bb.r)), Math.max(0, R(n.y + n.h - bb.b)), Math.max(0, R(bb.x - n.x)), ]; // Gaps along the primary axis. const gaps = []; for (let i = 1; i < flow.length; i++) { const g = dir === "V" ? flow[i].y - (flow[i - 1].y + flow[i - 1].h) : flow[i].x - (flow[i - 1].x + flow[i - 1].w); gaps.push(Math.max(0, R(g))); } const uniform = !gaps.length || Math.max(...gaps) - Math.min(...gaps) <= 1.5; let itemSpacing = uniform ? R(gaps.length ? gaps.reduce((a, b) => a + b) / gaps.length : 0) : 0; let out = flow; if (!uniform) { out = []; flow.forEach((c, i) => { if (i && gaps[i - 1] > 0.5) out.push(spacer(gaps[i - 1], dir, c.id)); out.push(c); }); } // Cross-axis alignment, read off the children's own edges. const cross = dir === "V" ? { s: flow.map((c) => c.x - bb.x), e: flow.map((c) => bb.r - (c.x + c.w)) } : { s: flow.map((c) => c.y - bb.y), e: flow.map((c) => bb.b - (c.y + c.h)) }; const allStart = cross.s.every((v) => v <= 1.5); const allEnd = cross.e.every((v) => v <= 1.5); const allCenter = flow.every((_, i) => near(cross.s[i], cross.e[i], 2)); const counter = allStart ? "MIN" : allEnd ? "MAX" : allCenter ? "CENTER" : "MIN"; return { dir, gap: itemSpacing, pad, counter, abs, flow: out }; } // ------------------------------------------------------------------ emit ----- function emit(n) { const o = { n: n.name, t: n.type === "FRAME" ? "F" : n.type === "TEXT" ? "T" : n.type === "MEDIA" ? "M" : "S" }; o.w = R(n.w); o.h = R(n.h); if (n.type === "SPACER") return o; if (n.fills && n.fills.length) o.f = n.fills; if (n.strokes && n.strokes.length) o.s = n.strokes; if (n.radius && n.radius.some((v) => v > 0)) o.r = n.radius.map(R); if (n.cshape) o.cs = n.cshape; if (n.opacity < 1) o.o = R(n.opacity); if (n.clip) o.cl = 1; if (n.shadows && n.shadows.length) o.sh = n.shadows; if (n.type === "TEXT") { o.tx = n.text; o.ft = n.font; if (n.runs) o.rn = n.runs; return o; } if (n.type === "MEDIA") { o.k = n.mediaKind; o.alt = n.alt || ""; if (haveMedia.has(n.id)) o.img = n.id; o.src = (n.src || "").split("/").pop().split("?")[0].slice(0, 60); return o; } const L = layoutFor(n); if (L.dir) { o.L = { d: L.dir, g: L.gap, p: L.pad, c: L.counter }; o.ch = L.flow.map(emit); } else { o.ch = []; } if (L.abs && L.abs.length) { // Figma paints later children on top, so emit these in z-index order. L.abs.sort((p, q) => (p.z || 0) - (q.z || 0)); o.abs = L.abs.map((c) => { const e = emit(c); e.ax = R(c.x - n.x); e.ay = R(c.y - n.y); return e; }); } if (!o.ch.length) delete o.ch; return o; } // Find the real section container (skip the single-child wrapper chain). let root = collapse(raw.root); const findId = (n, id) => { if (n.name === id) return n; for (const c of n.children || []) { const r = findId(c, id); if (r) return r; } return null; }; let host = findId(root, "#root") || root; while (host.children && host.children.length === 1) host = host.children[0]; const sections = host.children.map((s, i) => { const e = emit(s); e.n = (String(i).padStart(2, "0")) + " " + e.n; e.top = R(s.y); e.left = R(s.x); e.fixed = isAbs(s) ? 1 : 0; return e; }); const out = { width: raw.width, height: R(raw.height), sections }; fs.writeFileSync(path.join(OUT, "tree.fig.json"), JSON.stringify(out)); let nodes = 0; (function c(n) { nodes++; (n.ch || []).forEach(c); (n.abs || []).forEach(c); })({ ch: sections }); console.log(JSON.stringify({ sections: sections.length, nodes, bytes: fs.statSync(path.join(OUT, "tree.fig.json")).size, perSection: sections.map((s) => ({ n: s.n, h: s.h, kb: Math.round(JSON.stringify(s).length / 1024), })), }, null, 1));