// 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 = ``;
const name = ab.name.replace(/[^\w-]+/g, '_');
fs.writeFileSync(`${outDir}/${name}.svg`, svg);
console.log(name);
}