feat: add initial version of xd2fig tool

This commit is contained in:
2026-09-25 10:06:13 -04:00
parent 076c5f492b
commit 116487438a
15 changed files with 2089 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 => `<stop offset="${s.position}" stop-color="${rgba({ ...s.color, a: 1 })}" stop-opacity="${s.color.a * (p.opacity ?? 1)}"/>`).join('');
const gt = mat(inv(p.transform));
defs.push(p.type === 'GRADIENT_LINEAR'
? `<linearGradient id="${id}" gradientUnits="objectBoundingBox" x1="0" y1="0.5" x2="1" y2="0.5" gradientTransform="${gt}">${stops}</linearGradient>`
: `<radialGradient id="${id}" gradientUnits="objectBoundingBox" cx="0.5" cy="0.5" r="0.5" gradientTransform="${gt}">${stops}</radialGradient>`);
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 = `<image href="img/${h}" x="${(W - iw * s) / 2}" y="${(H - ih * s) / 2}" width="${iw * s}" height="${ih * s}" preserveAspectRatio="none"/>`;
} else {
// image normalisée = T * nœud normalisé → nœud = inv(T) * image
const it = inv(p.transform);
img = `<g transform="scale(${W} ${H}) ${mat(it)}"><image href="img/${h}" x="0" y="0" width="1" height="1" preserveAspectRatio="none"/></g>`;
}
defs.push(`<pattern id="${id}" patternUnits="userSpaceOnUse" x="0" y="0" width="${W}" height="${H}">${img}</pattern>`);
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'
? `<feDropShadow dx="${e.offset.x}" dy="${e.offset.y}" stdDeviation="${e.radius / 2}" flood-color="${rgba({ ...e.color, a: 1 })}" flood-opacity="${e.color.a}"/>`
: `<feGaussianBlur stdDeviation="${e.radius / 2}"/>`);
defs.push(`<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%">${parts.join('')}</filter>`);
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 `<text font-family="${esc(fam)}, sans-serif" font-size="${fs_}" font-weight="${weight}" fill="${fill}" text-anchor="${anchor}"${ls}>` +
lines.map((l, i) => `<tspan x="${x}" y="${lh / 2 + fs_ * 0.35 + i * lh}">${esc(l)}</tspan>`).join('') + '</text>';
}
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 += `<path d="${d}" fill="${f}"/>`; }
for (const p of n.strokePaints || []) {
const s = paint(p, n, defs);
if (s) body += `<path d="${n.type === 'VECTOR' ? vnPath(n) : d}" fill="none" stroke="${s}" stroke-width="${n.strokeWeight}"${n.dashPattern ? ` stroke-dasharray="${n.dashPattern.join(' ')}"` : ''}/>`;
}
}
// 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(`<clipPath id="${clipId}"><path transform="${mat(c.transform)}" d="${shapeD(c)}"/></clipPath>`);
continue;
}
const r = render(c, defs);
inner += clipId ? `<g clip-path="url(#${clipId})">${r}</g>` : r;
}
if (n.type === 'FRAME' && !n.resizeToFit && !n.frameMaskDisabled && inner) {
const id = `c${uid++}`;
defs.push(`<clipPath id="${id}"><rect width="${n.size.x}" height="${n.size.y}"/></clipPath>`);
inner = `<g clip-path="url(#${id})">${inner}</g>`;
}
return `<g transform="${mat(n.transform)}"${op}${filt}>${body}${inner}</g>`;
}
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${ab.size.x}" height="${ab.size.y}" viewBox="0 0 ${ab.size.x} ${ab.size.y}">` +
render({ ...ab, transform: t }, defs).replace(/^/, '') + `<defs>${defs.join('')}</defs></svg>`;
const name = ab.name.replace(/[^\w-]+/g, '_');
fs.writeFileSync(`${outDir}/${name}.svg`, svg);
console.log(name);
}