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
+14
View File
@@ -0,0 +1,14 @@
import fs from 'node:fs';
import { readCanvas, prettyPrintSchema } from '../src/figfile.js';
const [,, canvasPath, outDir] = process.argv;
const r = readCanvas(new Uint8Array(fs.readFileSync(canvasPath)));
console.log('version', r.version, 'codecs', r.codecs, 'extra chunks', r.extraChunks.length);
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(`${outDir}/schema.kiwi.txt`, prettyPrintSchema(r.schema));
const replacer = (k, v) => v instanceof Uint8Array ? `<bytes ${v.length}>` : v;
fs.writeFileSync(`${outDir}/message.json`, JSON.stringify(r.message, replacer, 1));
const m = r.message;
console.log('message keys', Object.keys(m), 'type', m.type, 'nodes', m.nodeChanges?.length, 'blobs', m.blobs?.length);
const types = {};
for (const n of m.nodeChanges) types[n.type] = (types[n.type] || 0) + 1;
console.log(types);
+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);
}
+75
View File
@@ -0,0 +1,75 @@
// Génère des .fig de test pour valider ce que Figma accepte à l'import
import fs from 'node:fs';
import crypto from 'node:crypto';
import JSZip from 'jszip';
import { readCanvas, writeCanvas } from '../src/figfile.js';
const [,, refPath, outDir] = process.argv;
fs.mkdirSync(outDir, { recursive: true });
const refZip = await JSZip.loadAsync(fs.readFileSync(refPath));
const ref = readCanvas(await refZip.file('canvas.fig').async('uint8array'));
const meta = await refZip.file('meta.json').async('string');
const thumb = await refZip.file('thumbnail.png').async('uint8array');
async function pack(name, canvas, images = {}) {
const zip = new JSZip();
zip.file('canvas.fig', writeCanvas(canvas));
zip.file('thumbnail.png', thumb);
const m = JSON.parse(meta); m.file_name = name;
zip.file('meta.json', JSON.stringify(m));
zip.folder('images');
for (const [h, b] of Object.entries(images)) zip.file(`images/${h}`, b);
fs.writeFileSync(`${outDir}/${name}.fig`, await zip.generateAsync({ type: 'uint8array', compression: 'STORE' }));
}
// Test 1 : aller-retour décodage/réencodage du fichier de référence
const refImages = {};
for (const f of Object.keys(refZip.files).filter(f => f.startsWith('images/') && !refZip.files[f].dir))
refImages[f.slice(7)] = await refZip.file(f).async('uint8array');
await pack('test1-roundtrip', ref, refImages);
// Test 2 : document minimal construit de zéro, sans géométrie ni layout texte précalculés
const id = (localID) => ({ sessionID: 1, localID });
const T = (x, y) => ({ m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y });
const solid = (r, g, b, a = 1) => ({ type: 'SOLID', color: { r, g, b, a }, opacity: 1, visible: true, blendMode: 'NORMAL' });
const base = { phase: 'CREATED', visible: true, opacity: 1, strokeWeight: 1, strokeAlign: 'INSIDE', strokeJoin: 'MITER' };
const imgBytes = fs.readFileSync(process.argv[4]);
const imgHash = crypto.createHash('sha1').update(imgBytes).digest();
const imgHex = imgHash.toString('hex');
// blob de géométrie : 1=moveTo, 2=lineTo, 4=cubicTo, 0=close ; float32 LE
function geomBlob(cmds) {
const parts = [];
for (const [op, ...pts] of cmds) {
const b = Buffer.alloc(1 + pts.length * 4); b[0] = op;
pts.forEach((p, i) => b.writeFloatLE(p, 1 + i * 4)); parts.push(b);
}
return { bytes: new Uint8Array(Buffer.concat(parts)) };
}
function minimal({ withGeometry }) {
const blobs = [];
const tri = geomBlob([[1, 50, 0], [2, 100, 100], [2, 0, 100], [2, 50, 0], [0]]);
blobs.push(tri);
const nodes = [
{ ...base, guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Document', transform: T(0, 0), documentColorProfile: 'SRGB' },
{ ...base, guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page 1', transform: T(0, 0), backgroundOpacity: 1, backgroundEnabled: true, backgroundColor: { r: 0.9, g: 0.9, b: 0.9, a: 1 } },
{ ...base, guid: id(1), parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'FRAME', name: 'Artboard test', size: { x: 800, y: 600 }, transform: T(0, 0), fillPaints: [solid(1, 1, 1)], resizeToFit: false, frameMaskDisabled: false },
{ ...base, guid: id(2), parentIndex: { guid: id(1), position: '!' }, type: 'ROUNDED_RECTANGLE', name: 'Rect rouge arrondi', size: { x: 200, y: 120 }, transform: T(40, 40), fillPaints: [solid(0.9, 0.1, 0.2)], cornerRadius: 16,
effects: [{ type: 'DROP_SHADOW', color: { r: 0, g: 0, b: 0, a: 0.4 }, offset: { x: 0, y: 8 }, radius: 16, spread: 0, visible: true, blendMode: 'NORMAL', showShadowBehindNode: false }] },
{ ...base, guid: id(3), parentIndex: { guid: id(1), position: '"' }, type: 'ELLIPSE', name: 'Cercle dégradé', size: { x: 150, y: 150 }, transform: T(300, 40),
fillPaints: [{ type: 'GRADIENT_LINEAR', opacity: 1, visible: true, blendMode: 'NORMAL', transform: T(0, 0), stops: [{ color: { r: 0, g: 0.4, b: 1, a: 1 }, position: 0 }, { color: { r: 1, g: 0.8, b: 0, a: 1 }, position: 1 }] }] },
{ ...base, guid: id(4), parentIndex: { guid: id(1), position: '#' }, type: 'ROUNDED_RECTANGLE', name: 'Image', size: { x: 300, y: 200 }, transform: T(40, 250),
fillPaints: [{ type: 'IMAGE', opacity: 1, visible: true, blendMode: 'NORMAL', transform: T(0, 0), image: { hash: new Uint8Array(imgHash), name: 'image' }, imageScaleMode: 'FILL', imageShouldColorManage: false, rotation: 0, scale: 1 }] },
{ ...base, guid: id(5), parentIndex: { guid: id(1), position: '$' }, type: 'TEXT', name: 'Texte', size: { x: 400, y: 40 }, transform: T(380, 250), strokeAlign: 'OUTSIDE',
fillPaints: [solid(0.1, 0.1, 0.1)], fontSize: 32, fontName: { family: 'Roboto', style: 'Bold', postscript: 'Roboto-Bold' },
lineHeight: { value: 100, units: 'PERCENT' }, letterSpacing: { value: 0, units: 'PERCENT' }, textAlignHorizontal: 'LEFT', textAlignVertical: 'TOP', textAutoResize: 'WIDTH_AND_HEIGHT',
textData: { characters: 'Bonjour de XD → Figma' } },
{ ...base, guid: id(6), parentIndex: { guid: id(1), position: '%' }, type: 'VECTOR', name: 'Triangle', size: { x: 100, y: 100 }, transform: T(500, 40), fillPaints: [solid(0.2, 0.7, 0.3)],
...(withGeometry ? { fillGeometry: [{ windingRule: 'NONZERO', commandsBlob: 0, styleID: 0 }] } : {}) },
];
return { ...ref, message: { type: 'NODE_CHANGES', sessionID: 0, ackID: 0, nodeChanges: nodes, blobs } };
}
await pack('test2-minimal', minimal({ withGeometry: true }), { [imgHex]: imgBytes });
await pack('test3-sans-geometrie', minimal({ withGeometry: false }), { [imgHex]: imgBytes });
console.log('ok');