// Aperçu SVG d'un .fig généré (contrôle visuel sans Figma) : node tools/render.js fichier.fig dossierSortie import fs from 'node:fs'; import path from 'node:path'; import JSZip from 'jszip'; import { readCanvas } from '../src/figfile.js'; const [,, figPath, outDir] = process.argv; fs.mkdirSync(`${outDir}/img`, { recursive: true }); const zip = await JSZip.loadAsync(fs.readFileSync(figPath)); const { message } = readCanvas(await zip.file('canvas.fig').async('uint8array')); const nodes = message.nodeChanges, blobs = message.blobs; const key = (g) => `${g.sessionID}:${g.localID}`; const kids = new Map(); for (const n of nodes) if (n.parentIndex) { const k = key(n.parentIndex.guid); if (!kids.has(k)) kids.set(k, []); kids.get(k).push(n); } for (const l of kids.values()) l.sort((a, b) => a.parentIndex.position < b.parentIndex.position ? -1 : 1); for (const f of Object.keys(zip.files)) if (f.startsWith('images/') && !zip.files[f].dir) { const out = `${outDir}/img/${f.slice(7)}`; if (!fs.existsSync(out)) fs.writeFileSync(out, await zip.file(f).async('nodebuffer')); } let uid = 0; const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const rgba = (c, o = 1) => `rgba(${Math.round(c.r * 255)},${Math.round(c.g * 255)},${Math.round(c.b * 255)},${(c.a ?? 1) * o})`; const mat = (t) => `matrix(${t.m00} ${t.m10} ${t.m01} ${t.m11} ${t.m02} ${t.m12})`; const inv = (t) => { const d = t.m00 * t.m11 - t.m01 * t.m10; return { m00: t.m11 / d, m10: -t.m10 / d, m01: -t.m01 / d, m11: t.m00 / d, m02: (t.m01 * t.m12 - t.m11 * t.m02) / d, m12: (t.m10 * t.m02 - t.m00 * t.m12) / d }; }; function geomPath(i) { const b = Buffer.from(blobs[i].bytes); let o = 0, d = ''; const f = () => { const v = b.readFloatLE(o); o += 4; return v.toFixed(2); }; while (o < b.length) { const c = b[o++]; if (c === 0) d += 'Z'; else if (c === 1) d += `M${f()} ${f()}`; else if (c === 2) d += `L${f()} ${f()}`; else if (c === 3) d += `Q${f()} ${f()} ${f()} ${f()}`; else if (c === 4) d += `C${f()} ${f()} ${f()} ${f()} ${f()} ${f()}`; else break; } return d; } function paint(p, n, defs) { if (!p || p.visible === false) return null; if (p.type === 'SOLID') return rgba(p.color, p.opacity ?? 1); const id = `p${uid++}`; if (p.type === 'GRADIENT_LINEAR' || p.type === 'GRADIENT_RADIAL') { const stops = p.stops.map(s => ``).join(''); const gt = mat(inv(p.transform)); defs.push(p.type === 'GRADIENT_LINEAR' ? `${stops}` : `${stops}`); return `url(#${id})`; } if (p.type === 'IMAGE') { const h = Buffer.from(p.image.hash).toString('hex'); const W = n.size.x, H = n.size.y, iw = p.originalImageWidth, ih = p.originalImageHeight; let img; if (p.imageScaleMode === 'FILL') { const s = Math.max(W / iw, H / ih); img = ``; } else { // image normalisée = T * nœud normalisé → nœud = inv(T) * image const it = inv(p.transform); img = ``; } defs.push(`${img}`); return `url(#${id})`; } return null; } function shapeD(n) { const w = n.size.x, h = n.size.y; if (n.type === 'ELLIPSE') return `M${w} ${h / 2}A${w / 2} ${h / 2} 0 1 1 0 ${h / 2}A${w / 2} ${h / 2} 0 1 1 ${w} ${h / 2}Z`; if (n.type === 'VECTOR') return n.fillGeometry?.[0] ? geomPath(n.fillGeometry[0].commandsBlob) : vnPath(n); const r = n.cornerRadius || 0; const [tl, tr, br, bl] = n.rectangleCornerRadiiIndependent ? [n.rectangleTopLeftCornerRadius, n.rectangleTopRightCornerRadius, n.rectangleBottomRightCornerRadius, n.rectangleBottomLeftCornerRadius] : [r, r, r, r]; return `M${tl} 0H${w - tr}A${tr} ${tr} 0 0 1 ${w} ${tr}V${h - br}A${br} ${br} 0 0 1 ${w - br} ${h}H${bl}A${bl} ${bl} 0 0 1 0 ${h - bl}V${tl}A${tl} ${tl} 0 0 1 ${tl} 0Z`; } function vnPath(n) { const b = Buffer.from(blobs[n.vectorData.vectorNetworkBlob].bytes); let o = 0; const u = () => { const v = b.readUInt32LE(o); o += 4; return v; }, f = () => { const v = b.readFloatLE(o); o += 4; return v; }; const nv = u(), ns = u(); u(); const V = []; for (let i = 0; i < nv; i++) { u(); V.push([f(), f()]); } let d = '', last = -1; for (let i = 0; i < ns; i++) { u(); const a = u(), t0 = [f(), f()], e = u(), t1 = [f(), f()]; if (a !== last) d += `M${V[a][0]} ${V[a][1]}`; d += `C${V[a][0] + t0[0]} ${V[a][1] + t0[1]} ${V[e][0] + t1[0]} ${V[e][1] + t1[1]} ${V[e][0]} ${V[e][1]}`; last = e; } return d; } function effectsFilter(n, defs) { const fx = (n.effects || []).filter(e => e.visible !== false && (e.type === 'DROP_SHADOW' || e.type === 'FOREGROUND_BLUR')); if (!fx.length) return ''; const id = `f${uid++}`; const parts = fx.map((e, i) => e.type === 'DROP_SHADOW' ? `` : ``); defs.push(`${parts.join('')}`); return ` filter="url(#${id})"`; } function textSvg(n) { const fs_ = n.fontSize, fam = n.fontName.family; const weight = /black|ultra|extra ?bold|heavy/i.test(n.fontName.style) ? 900 : /semi|demi/i.test(n.fontName.style) ? 600 : /bold/i.test(n.fontName.style) ? 700 : /medium/i.test(n.fontName.style) ? 500 : /light/i.test(n.fontName.style) ? 300 : 400; const lh = n.lineHeight.units === 'PIXELS' ? n.lineHeight.value : fs_ * 1.2; const anchor = { CENTER: 'middle', RIGHT: 'end' }[n.textAlignHorizontal] || 'start'; const x = anchor === 'middle' ? n.size.x / 2 : anchor === 'end' ? n.size.x : 0; let chars = n.textData.characters; if (n.textCase === 'UPPER') chars = chars.toUpperCase(); // retour à la ligne approximatif const maxW = n.textAutoResize === 'WIDTH_AND_HEIGHT' ? Infinity : n.size.x; const lines = []; for (const para of chars.split('\n')) { let line = ''; for (const w of para.split(' ')) { const cand = line ? line + ' ' + w : w; if (cand.length * fs_ * 0.55 > maxW && line) { lines.push(line); line = w; } else line = cand; } lines.push(line); } const fill = n.fillPaints?.[0]?.type === 'SOLID' ? rgba(n.fillPaints[0].color, n.fillPaints[0].opacity) : '#000'; const ls = n.letterSpacing?.value ? ` letter-spacing="${n.letterSpacing.value / 100 * fs_}"` : ''; return `` + lines.map((l, i) => `${esc(l)}`).join('') + ''; } function render(n, defs) { if (n.visible === false) return ''; const children = kids.get(key(n.guid)) || []; const op = n.opacity != null && n.opacity < 1 ? ` opacity="${n.opacity}"` : ''; const filt = effectsFilter(n, defs); let body = ''; if (n.type === 'TEXT') body = textSvg(n); else if (n.type !== 'FRAME' || n.fillPaints?.length) { const d = n.type === 'FRAME' ? `M0 0H${n.size.x}V${n.size.y}H0Z` : shapeD(n); for (const p of n.fillPaints || []) { const f = paint(p, n, defs); if (f) body += ``; } for (const p of n.strokePaints || []) { const s = paint(p, n, defs); if (s) body += ``; } } // enfants + masques (un masque découpe les frères qui le suivent) let inner = '', clipId = null; for (const c of children) { if (c.mask) { clipId = `c${uid++}`; defs.push(``); continue; } const r = render(c, defs); inner += clipId ? `${r}` : r; } if (n.type === 'FRAME' && !n.resizeToFit && !n.frameMaskDisabled && inner) { const id = `c${uid++}`; defs.push(``); inner = `${inner}`; } return `${body}${inner}`; } const page = nodes.find(n => n.type === 'CANVAS'); const tops = kids.get(key(page.guid)).filter(n => n.type === 'FRAME' && !n.resizeToFit); for (const ab of tops) { const defs = []; const t = { ...ab.transform, m02: 0, m12: 0 }; const svg = `` + render({ ...ab, transform: t }, defs).replace(/^/, '') + `${defs.join('')}`; const name = ab.name.replace(/[^\w-]+/g, '_'); fs.writeFileSync(`${outDir}/${name}.svg`, svg); console.log(name); }