feat: add initial version of xd2fig tool
This commit is contained in:
@@ -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
@@ -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, '&').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 => `<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);
|
||||
}
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user