feat: add initial version of xd2fig tool

This commit is contained in:
hykocx committed 2026-09-25 10:06:13 -04:00
1 parent 076c5f492b
commit 116487438a
15 files changed
+2089

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
// Arbre intermédiaire → message kiwi Figma (nodeChanges + blobs) + images
import { mul, inv, translate, toFig } from './matrix.js';
import { mapPath, geometryBlob, vectorNetworkBlob } from './path.js';
const BASE = { phase: 'CREATED', visible: true, opacity: 1, strokeWeight: 1, strokeAlign: 'INSIDE', strokeJoin: 'MITER' };
// Positions fractionnaires à longueur fixe (ordre lexicographique = ordre d'empilement, du fond vers le dessus)
const ALPHA = 90, FIRST = 35;
const position = (i) => String.fromCharCode(FIRST + Math.floor(i / ALPHA / ALPHA) % ALPHA, FIRST + Math.floor(i / ALPHA) % ALPHA, FIRST + i % ALPHA);
export class FigBuilder {
constructor(conv) {
this.conv = conv;
this.nodes = [];
this.blobs = [];
this.nextId = 1;
this.usedImages = new Map();
}
guid() { return { sessionID: 1, localID: this.nextId++ }; }
blob(bytes) { this.blobs.push({ bytes }); return this.blobs.length - 1; }
document(pageName) {
this.nodes.push(
{ ...BASE, guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Document', transform: toFig([1, 0, 0, 1, 0, 0]), documentColorProfile: 'SRGB' },
{ ...BASE, guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: pageName,
transform: toFig([1, 0, 0, 1, 0, 0]), backgroundOpacity: 1, backgroundEnabled: true, backgroundColor: { r: 0.9, g: 0.9, b: 0.9, a: 1 } },
);
return { sessionID: 0, localID: 1 };
}
emitAll(items, parentGuid, parentOrigin) {
items.forEach((ir, i) => this.emit(ir, parentGuid, parentOrigin, i));
}
emit(ir, parentGuid, parentOrigin, index) {
const guid = this.guid();
const b = ir.box;
const T = mul(inv(parentOrigin), mul(ir.M, translate(b.x, b.y)));
const size = { x: Math.max(b.w, 0.01), y: Math.max(b.h, 0.01) };
const node = {
...BASE, guid, parentIndex: { guid: parentGuid, position: position(index) },
type: ir.type, name: ir.name, size, transform: toFig(T), ...ir.props,
};
for (const p of [...(node.fillPaints || []), ...(node.strokePaints || [])]) this.resolveImage(p);
for (const o of node.textData?.styleOverrideTable || []) for (const p of o.fillPaints || []) this.resolveImage(p);
if (ir.type === 'VECTOR') {
const local = mapPath(ir.path, p => [p[0] - b.x, p[1] - b.y]);
const hasFill = node.fillPaints?.length;
node.fillGeometry = hasFill ? [{ windingRule: ir.windingRule, commandsBlob: this.blob(geometryBlob(local, { closeAll: true })), styleID: 0 }] : [];
node.vectorData = { vectorNetworkBlob: this.blob(vectorNetworkBlob(local, ir.windingRule)), normalizedSize: size };
}
this.nodes.push(node);
if (ir.children?.length) this.emitAll(ir.children, guid, translate(b.x, b.y));
return guid;
}
resolveImage(p) {
if (p.type !== 'IMAGE' || !p.image?.uid) return;
const img = this.conv.images.get(p.image.uid);
if (!img) { p.visible = false; return; }
this.usedImages.set(img.hash.toString('hex'), img.bytes);
p.image = { hash: new Uint8Array(img.hash), name: p.image.name };
if (img.width) { p.originalImageWidth = img.width; p.originalImageHeight = img.height; }
}
message() {
return { type: 'NODE_CHANGES', sessionID: 0, ackID: 0, nodeChanges: this.nodes, blobs: this.blobs };
}
}
Executable
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
// Usage : node src/cli.js entree.xd [sortie.fig]
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import JSZip from 'jszip';
import { readXd } from './xd.js';
import { Converter } from './convert.js';
import { FigBuilder } from './build.js';
import { readCanvas, writeCanvas } from './figfile.js';
const [,, input, outArg] = process.argv;
if (!input) { console.error('Usage : xd2fig entree.xd [sortie.fig]'); process.exit(1); }
const output = outArg || input.replace(/\.xd$/i, '') + '.fig';
const here = path.dirname(fileURLToPath(import.meta.url));
const t0 = Date.now();
const xd = await readXd(fs.readFileSync(input));
const conv = new Converter(xd);
conv.learnTextMetrics([...xd.artboards.map(a => a.node), ...xd.pasteboard]);
const artboards = xd.artboards.map(ab => conv.artboard(ab));
const loose = xd.pasteboard.map(n => conv.convert(n)).filter(Boolean);
await conv.loadImages();
const b = new FigBuilder(conv);
const page = b.document(path.basename(input, path.extname(input)));
const I = [1, 0, 0, 1, 0, 0];
b.emitAll([...artboards, ...loose], page, I);
// Le schéma kiwi est repris du gabarit fourni avec l'outil
const template = readCanvas(new Uint8Array(fs.readFileSync(path.join(here, '../template/canvas.fig'))));
const zip = new JSZip();
zip.file('canvas.fig', writeCanvas({ ...template, message: b.message() }));
zip.file('meta.json', JSON.stringify({
client_meta: { background_color: { r: 0.9, g: 0.9, b: 0.9, a: 1 }, thumbnail_size: { width: 400, height: 300 } },
file_name: path.basename(output, '.fig'), developer_related_links: [], exported_at: new Date().toISOString(),
}));
const thumb = xd.getResource ? await (async () => { const z = await JSZip.loadAsync(fs.readFileSync(input)); return z.file('thumbnail.png')?.async('nodebuffer'); })() : null;
if (thumb) zip.file('thumbnail.png', thumb);
zip.folder('images');
for (const [hash, bytes] of b.usedImages) zip.file(`images/${hash}`, bytes);
fs.writeFileSync(output, await zip.generateAsync({ type: 'nodebuffer', compression: 'STORE' }));
// Rapport
console.log(`✔ ${output}`);
console.log(` ${artboards.length} artboards, ${loose.length} éléments hors artboard, ${b.nodes.length} nœuds, ${b.usedImages.size} images, ${((Date.now() - t0) / 1000).toFixed(1)} s`);
console.log('\nPolices utilisées (à installer si Figma ne les a pas) :');
for (const [f, n] of [...conv.fonts].sort((a, b) => b[1] - a[1])) console.log(` ${f.replace('|', ' — ')} (${n})`);
if (conv.warnings.size) {
console.log('\nAvertissements :');
for (const [code, w] of conv.warnings) console.log(` ${code} ×${w.count}${w.examples.length ? ' ex: ' + w.examples.slice(0, 2).join(' ; ') : ''}`);
}
+521
View File
@@ -0,0 +1,521 @@
// XD (AGC) → arbre intermédiaire prêt à être émis en nœuds Figma
import crypto from 'node:crypto';
import sharp from 'sharp';
const MAX_IMAGE = 4096; // limite de Figma
import { I, fromXd, translate, mul, inv, apply, isOrthonormal, linear, transformBox, unionBoxes } from './matrix.js';
import { parsePath, mapPath, pathBounds, rectPath, ellipsePath } from './path.js';
// ---------- Couleurs et peintures ----------
const xdColor = (c) => {
const v = c?.value || { r: 0, g: 0, b: 0 };
return { r: v.r / 255, g: v.g / 255, b: v.b / 255, a: c?.alpha ?? 1 };
};
const argb = (n) => ({ r: ((n >>> 16) & 255) / 255, g: ((n >>> 8) & 255) / 255, b: (n & 255) / 255, a: ((n >>> 24) & 255) / 255 });
const solid = (c, opacity = 1) => ({ type: 'SOLID', color: { ...c, a: 1 }, opacity: c.a * opacity, visible: true, blendMode: 'NORMAL' });
const toFigM = (M) => ({ m00: M[0], m01: M[2], m02: M[4], m10: M[1], m11: M[3], m12: M[5] });
const BLEND = {
normal: 'NORMAL', 'pass-through': 'PASS_THROUGH', darken: 'DARKEN', multiply: 'MULTIPLY', 'color-burn': 'COLOR_BURN',
lighten: 'LIGHTEN', screen: 'SCREEN', 'color-dodge': 'COLOR_DODGE', overlay: 'OVERLAY', 'soft-light': 'SOFT_LIGHT',
'hard-light': 'HARD_LIGHT', difference: 'DIFFERENCE', exclusion: 'EXCLUSION', hue: 'HUE', saturation: 'SATURATION',
color: 'COLOR', luminosity: 'LUMINOSITY',
};
export class Converter {
constructor(xd) {
this.xd = xd;
this.warnings = new Map();
this.fonts = new Map();
this.images = new Map(); // uid → { hash, bytes, width, height }
this.pendingImages = new Set();
this.metrics = new Map();
}
warn(code, detail) {
const w = this.warnings.get(code) || { count: 0, examples: [] };
w.count++;
if (detail && w.examples.length < 5) w.examples.push(detail);
this.warnings.set(code, w);
}
async loadImages() {
for (const uid of this.pendingImages) {
const bytes = await this.xd.getResource(uid);
if (!bytes) { this.warn('image-manquante', uid); continue; }
let data = bytes, size = imageSize(bytes);
if (size.width > MAX_IMAGE || size.height > MAX_IMAGE) {
const img = sharp(bytes, { limitInputPixels: false }).resize(MAX_IMAGE, MAX_IMAGE, { fit: 'inside' });
data = await (size.format === 'jpeg' ? img.jpeg({ quality: 92 }) : img.png()).toBuffer();
this.warn('image-reduite', `${uid}: ${size.width}×${size.height} → max ${MAX_IMAGE}px`);
size = imageSize(data);
}
const hash = crypto.createHash('sha1').update(data).digest();
this.images.set(uid, { hash, bytes: data, ...size });
}
}
// ---------- Métriques de texte déduites du document ----------
// XD place la 1re ligne de base de façon déterministe ; on apprend le ratio par police
// à partir des textes à zone (dont l'origine est le coin haut-gauche) pour positionner
// correctement les textes ponctuels (dont l'origine est la ligne de base).
learnTextMetrics(nodes) {
const samples = new Map();
const visit = (n) => {
if (n.type === 'text' && n.text?.frame?.type !== 'positioned') {
const seg = n.text.paragraphs?.[0]?.lines?.[0]?.[0];
const font = n.style?.font;
if (seg && font?.size) {
const L = n.style?.textAttributes?.lineHeight || 0;
const s = samples.get(font.postscriptName) || { r: [], k: [] };
if (L) s.k.push((seg.y - L / 2) / font.size); else s.r.push(seg.y / font.size);
samples.set(font.postscriptName, s);
}
}
for (const c of childrenOf(n)) visit(c);
};
nodes.forEach(visit);
const median = (a) => a.length ? a.slice().sort((x, y) => x - y)[a.length >> 1] : null;
for (const [ps, s] of samples) this.metrics.set(ps, { r: median(s.r), k: median(s.k) });
}
baselineFromTop(font, L) {
const m = this.metrics.get(font.postscriptName) || {};
const s = font.size;
if (L) return L / 2 + (m.k ?? (m.r != null ? m.r - 0.6 : 0.34)) * s;
return (m.r ?? (m.k != null ? m.k + 0.6 : 0.93)) * s;
}
// ---------- Peintures ----------
paint(fill, box, { fillOpacity = 1 } = {}) {
if (!fill || fill.type === 'none') return null;
if (fill.type === 'solid') return solid(xdColor(fill.color), fillOpacity);
if (fill.type === 'gradient') return this.gradient(fill.gradient, box, fillOpacity);
if (fill.type === 'pattern') return this.pattern(fill.pattern, box, fillOpacity);
this.warn('remplissage-inconnu', fill.type);
return null;
}
gradient(g, box, opacity) {
const res = g.meta?.ux?.gradientResources || this.xd.resources?.gradients?.[g.ref];
if (!res) { this.warn('degrade-sans-ressource', g.ref); return null; }
const stops = res.stops.map(s => ({ color: xdColor(s.color), position: s.offset }));
const norm = (x, y) => g.units === 'userSpaceOnUse'
? [(x - box.x) / (box.w || 1), (y - box.y) / (box.h || 1)] : [x, y];
let Minv;
if (res.type === 'linear') {
// XD calcule la perpendiculaire en pixels (pas dans l'espace normalisé de la boîte)
const W = box.w || 1, H = box.h || 1;
const S = norm(g.x1, g.y1), E = norm(g.x2, g.y2);
const D = [(E[0] - S[0]) * W, (E[1] - S[1]) * H];
if (!D[0] && !D[1]) { this.warn('degrade-degenere'); return solid(stops[0].color, opacity); }
const P = [-D[1], D[0]];
const Mpx = [D[0], D[1], P[0], P[1], S[0] * W - 0.5 * P[0], S[1] * H - 0.5 * P[1]];
Minv = mul([1 / W, 0, 0, 1 / H, 0, 0], Mpx);
} else if (res.type === 'radial') {
const C = norm(g.cx, g.cy);
const r = g.units === 'userSpaceOnUse' ? [g.r / (box.w || 1), g.r / (box.h || 1)] : [g.r, g.r];
if (g.fx != null && (Math.abs(g.fx - g.cx) > 1e-3 || Math.abs(g.fy - g.cy) > 1e-3)) this.warn('degrade-radial-foyer-decale');
Minv = [2 * r[0], 0, 0, 2 * r[1], C[0] - r[0], C[1] - r[1]];
} else { this.warn('degrade-inconnu', res.type); return null; }
if (g.gradientTransform) {
this.warn('degrade-transform', 'gradientTransform appliqué');
Minv = mul(fromXd(g.gradientTransform), Minv);
}
return {
type: res.type === 'linear' ? 'GRADIENT_LINEAR' : 'GRADIENT_RADIAL',
opacity, visible: true, blendMode: 'NORMAL', transform: toFigM(inv(Minv)), stops,
};
}
pattern(p, box, opacity) {
const ux = p.meta?.ux || {};
if (!ux.uid) { this.warn('image-sans-uid'); return null; }
this.pendingImages.add(ux.uid);
const iw = p.width, ih = p.height, W = box.w || 1, H = box.h || 1;
const paint = {
type: 'IMAGE', opacity, visible: true, blendMode: 'NORMAL', transform: toFigM(I),
image: { uid: ux.uid, name: 'image' }, imageScaleMode: 'FILL', imageShouldColorManage: false,
rotation: 0, scale: 1, originalImageWidth: iw, originalImageHeight: ih,
};
const cover = !ux.scaleBehavior || ux.scaleBehavior === 'fill' || ux.scaleBehavior === 'cover';
const custom = !cover || ux.offsetX || ux.offsetY || (ux.scale && ux.scale !== 1) || ux.flipX || ux.flipY;
if (!custom) return paint;
// Recadrage personnalisé → mode STRETCH avec transformée image
let s = ux.scaleBehavior === 'fit' ? Math.min(W / iw, H / ih) : Math.max(W / iw, H / ih);
s *= ux.scale || 1;
const dw = iw * s, dh = ih * s;
const px = (W - dw) / 2 + (ux.offsetX || 0) * dw;
const py = (H - dh) / 2 + (ux.offsetY || 0) * dh;
let T = [W / dw, 0, 0, H / dh, -px / dw, -py / dh];
if (ux.flipX) T = mul([-1, 0, 0, 1, 1, 0], T);
if (ux.flipY) T = mul([1, 0, 0, -1, 0, 1], T);
this.warn('image-recadree', `${ux.uid} (${ux.scaleBehavior}, offset ${(+ux.offsetX || 0).toFixed(3)},${(+ux.offsetY || 0).toFixed(3)}, scale ${ux.scale ?? 1})`);
return { ...paint, imageScaleMode: 'STRETCH', transform: toFigM(T) };
}
// ---------- Style commun ----------
applyStyle(node, xd, box) {
const st = xd.style || {};
const props = node.props;
let fillOpacity = 1;
const effects = [];
for (const f of Array.isArray(st.filters) ? st.filters : []) {
const visible = f.visible !== false;
if (f.type === 'dropShadow' || f.type === 'uxdesign#innerShadow') {
const list = f.params?.dropShadows || f.params?.innerShadows || [];
for (const s of list) effects.push({
type: f.type === 'dropShadow' ? 'DROP_SHADOW' : 'INNER_SHADOW', color: xdColor(s.color),
offset: { x: s.dx || 0, y: s.dy || 0 }, radius: s.r || 0, spread: 0, visible, blendMode: 'NORMAL',
showShadowBehindNode: false,
});
} else if (f.type === 'uxdesign#blur') {
const p = f.params || {};
if (p.backgroundEffect) {
effects.push({ type: 'BACKGROUND_BLUR', radius: p.blurAmount || 0, visible });
if (visible) {
fillOpacity = p.fillOpacity ?? 1;
if (Math.abs(p.brightnessAmount || 0) >= 5) this.warn('flou-arriere-plan-luminosite', `${xd.name}: luminosité ${p.brightnessAmount} non reproduite`);
}
} else effects.push({ type: 'FOREGROUND_BLUR', radius: p.blurAmount || 0, visible });
} else this.warn('filtre-inconnu', f.type);
}
if (effects.length) props.effects = effects;
if (node.type !== 'FRAME' || node.isArtboard) {
const fill = this.paint(st.fill, box, { fillOpacity });
props.fillPaints = fill ? [fill] : [];
} else if (st.fill && st.fill.type !== 'none') this.warn('remplissage-sur-groupe', xd.name);
const sk = st.stroke;
if (sk && sk.type !== 'none' && sk.width) {
const p = this.paint(sk, box);
if (p) {
props.strokePaints = [p];
props.strokeWeight = sk.width;
props.strokeAlign = { inside: 'INSIDE', outside: 'OUTSIDE' }[sk.align] || 'CENTER';
if (sk.cap) props.strokeCap = { round: 'ROUND', square: 'SQUARE' }[sk.cap] || 'NONE';
if (sk.join) props.strokeJoin = { round: 'ROUND', bevel: 'BEVEL' }[sk.join] || 'MITER';
if (sk.miterLimit) props.miterLimit = sk.miterLimit;
if (sk.dash?.length) props.dashPattern = sk.dash;
}
}
if (st.opacity != null) props.opacity = st.opacity;
if (st.blendMode) {
if (BLEND[st.blendMode]) props.blendMode = BLEND[st.blendMode];
else this.warn('mode-fusion-inconnu', st.blendMode);
}
if (xd.visible === false) props.visible = false;
if (xd.meta?.ux?.locked) props.locked = true;
Object.assign(props, constraints(xd.meta?.ux));
}
// ---------- Nœuds ----------
convert(xd) {
switch (xd.type) {
case 'group': return this.group(xd);
case 'shape': return this.shape(xd);
case 'text': return this.text(xd);
case 'syncRef': {
// Élément d'instance identique au maître : on convertit la source
const src = this.xd.byId.get(xd.syncSourceGuid);
if (!src) { this.warn('syncref-introuvable', xd.syncSourceGuid); return null; }
if ((this.syncDepth = (this.syncDepth || 0) + 1) > 50) throw new Error('Boucle de syncRef');
// Le syncRef peut surcharger des propriétés de la source (transform, enfants…)
const { type, syncSourceGuid, guid, ...over } = xd;
try { return this.convert(deepMerge(src, over)); } finally { this.syncDepth--; }
}
default: this.warn('type-inconnu', xd.type); return null;
}
}
group(xd) {
const ux = xd.meta?.ux || {};
const children = [];
const clip = ux.clipPathResources?.children;
if (clip?.length) {
for (const c of clip) {
const m = this.convert(c);
if (m) {
// Un masque Figma utilise l'opacité du remplissage : on garde une forme pleine, sans contour ni effet
Object.assign(m.props, { mask: true, visible: true, fillPaints: [solid({ r: 1, g: 1, b: 1, a: 1 })] });
delete m.props.strokePaints; delete m.props.effects; delete m.props.opacity;
children.push(m);
}
}
if (clip.length > 1) this.warn('masque-multiple', xd.name);
}
for (const c of xd.group?.children || []) { const n = this.convert(c); if (n) children.push(n); }
const node = {
type: 'FRAME', name: xd.name || 'Groupe', M: fromXd(xd.transform), children,
props: { resizeToFit: true, frameMaskDisabled: false, fillPaints: [] },
};
if (ux.symbolId) node.props.name = xd.name;
node.box = unionBoxes((clip?.length ? children.slice(0, 1) : children).map(c => transformBox(c.M, c.box)));
this.applyStyle(node, xd, node.box);
return this.normalize(node);
}
shape(xd) {
const s = xd.shape;
const node = { name: xd.name || s.type, M: fromXd(xd.transform), props: {}, children: [] };
switch (s.type) {
case 'rect': {
node.type = 'ROUNDED_RECTANGLE';
node.box = { x: s.x || 0, y: s.y || 0, w: s.width, h: s.height };
if (s.r) {
const r = Array.isArray(s.r) ? s.r : [s.r, s.r, s.r, s.r];
const max = Math.min(s.width, s.height) / 2;
const [tl, tr, br, bl] = r.map(v => Math.min(v, max));
if (tl === tr && tr === br && br === bl) node.props.cornerRadius = tl;
else Object.assign(node.props, {
rectangleCornerRadiiIndependent: true, cornerRadius: tl,
rectangleTopLeftCornerRadius: tl, rectangleTopRightCornerRadius: tr,
rectangleBottomRightCornerRadius: br, rectangleBottomLeftCornerRadius: bl,
});
}
node.fallbackPath = () => roundedRectPath(node.box, s.r);
break;
}
case 'ellipse': case 'circle': {
const rx = s.rx ?? s.r, ry = s.ry ?? s.r;
node.type = 'ELLIPSE';
node.box = { x: s.cx - rx, y: s.cy - ry, w: 2 * rx, h: 2 * ry };
node.fallbackPath = () => ellipsePath(s.cx, s.cy, rx, ry);
break;
}
case 'line':
node.type = 'VECTOR';
node.path = [{ start: [s.x1, s.y1], segs: [{ p: [s.x2, s.y2] }], closed: false }];
break;
case 'path': case 'compound':
node.type = 'VECTOR';
node.path = parsePath(s.path);
break;
case 'polygon':
node.type = 'VECTOR';
node.path = s.points?.length ? [{ start: [s.points[0].x, s.points[0].y], segs: s.points.slice(1).map(p => ({ p: [p.x, p.y] })), closed: true }] : [];
break;
default:
this.warn('forme-inconnue', s.type);
return null;
}
if (node.type === 'VECTOR') {
node.box = pathBounds(node.path);
node.windingRule = xd.style?.fill?.fillRule === 'evenodd' ? 'ODD' : 'NONZERO';
}
this.applyStyle(node, xd, node.box);
return this.normalize(node);
}
text(xd) {
const st = xd.style || {};
const font = st.font || {};
const t = xd.text || {};
const frame = t.frame || { type: 'positioned' };
const ta = st.textAttributes || {};
const L = ta.lineHeight || 0;
const characters = t.rawText ?? '';
const lines = (t.paragraphs || []).flatMap(p => p.lines || []);
const firstSeg = lines[0]?.[0] || { x: 0, y: 0 };
const node = { type: 'TEXT', name: xd.name || characters.slice(0, 40), M: fromXd(xd.transform), children: [], props: {} };
// Styles par plage
const ranges = xd.meta?.ux?.rangedStyles?.length ? xd.meta.ux.rangedStyles : [{
length: characters.length, fontFamily: font.family, fontStyle: font.style, postscriptName: font.postscriptName,
fontSize: font.size, charSpacing: st.charSpacing || 0, underline: false, strikethrough: false, textTransform: 'none',
}];
const styleOf = (r) => {
const s = {
fontName: { family: r.fontFamily, style: r.fontStyle, postscript: r.postscriptName || '' },
fontSize: r.fontSize,
letterSpacing: { value: (r.charSpacing || 0) / 10, units: 'PERCENT' },
textDecoration: r.underline ? 'UNDERLINE' : r.strikethrough ? 'STRIKETHROUGH' : 'NONE',
textCase: { uppercase: 'UPPER', lowercase: 'LOWER', titlecase: 'TITLE' }[r.textTransform] || 'ORIGINAL',
};
if (r.fill?.value != null) s.fillPaints = [solid(argb(r.fill.value))];
if (r.hyperlinkUrl) s.hyperlink = { url: r.hyperlinkUrl };
if (r.textScript && r.textScript !== 'none') this.warn('texte-exposant-indice', `${xd.name}: ${r.textScript}`);
const key = `${r.fontFamily}|${r.fontStyle}`;
this.fonts.set(key, (this.fonts.get(key) || 0) + 1);
return s;
};
const styles = ranges.map(styleOf);
const keyOf = (s) => JSON.stringify(s);
const weight = new Map();
ranges.forEach((r, i) => weight.set(keyOf(styles[i]), (weight.get(keyOf(styles[i])) || 0) + r.length));
const baseKey = [...weight.entries()].sort((a, b) => b[1] - a[1])[0][0];
const base = JSON.parse(baseKey);
Object.assign(node.props, base, {
textAlignHorizontal: { center: 'CENTER', right: 'RIGHT', justify: 'JUSTIFIED' }[ta.paragraphAlign] || 'LEFT',
textAlignVertical: 'TOP',
lineHeight: L ? { value: L, units: 'PIXELS' } : { value: 100, units: 'PERCENT' },
strokeAlign: 'OUTSIDE',
});
const textData = { characters };
if (weight.size > 1) {
const table = [], ids = new Map([[baseKey, 0]]);
const perChar = [];
ranges.forEach((r, i) => {
const k = keyOf(styles[i]);
if (!ids.has(k)) {
const id = ids.size;
ids.set(k, id);
const diff = { styleID: id };
for (const [f, v] of Object.entries(styles[i])) if (JSON.stringify(v) !== JSON.stringify(base[f])) diff[f] = v;
table.push(diff);
}
for (let j = 0; j < r.length; j++) perChar.push(ids.get(k));
});
perChar.length = Math.min(perChar.length, characters.length);
while (perChar.length < characters.length) perChar.push(perChar[perChar.length - 1] ?? 0);
textData.characterStyleIDs = perChar;
textData.styleOverrideTable = table;
if (ranges.reduce((n, r) => n + r.length, 0) !== characters.length) this.warn('texte-longueurs-plages', xd.name);
}
node.props.textData = textData;
// Boîte et redimensionnement
const fs = base.fontSize || font.size || 12;
const lh = L || fs * 1.2;
const longest = Math.max(1, ...characters.split('\n').map(l => l.length));
if (frame.type === 'area') {
node.props.textAutoResize = 'NONE';
node.box = { x: 0, y: 0, w: frame.width, h: frame.height };
} else if (frame.type === 'autoHeight') {
node.props.textAutoResize = 'HEIGHT';
const lastY = lines.length ? lines[lines.length - 1][0].y : 0;
node.box = { x: 0, y: 0, w: frame.width, h: Math.max(lh, lastY + fs * 0.35) };
} else {
node.props.textAutoResize = 'WIDTH_AND_HEIGHT';
const minX = Math.min(0, ...lines.map(l => l[0]?.x ?? 0));
const top = firstSeg.y - this.baselineFromTop({ ...font, size: fs, postscriptName: base.fontName.postscript }, L);
node.box = { x: minX, y: top, w: longest * fs * 0.6, h: lines.length * lh || lh };
}
this.applyStyle(node, xd, node.box);
// La couleur du texte vient des plages ; sinon du remplissage du nœud
if (base.fillPaints) node.props.fillPaints = base.fillPaints;
return this.normalize(node);
}
// Figma n'accepte que rotation/symétrie dans la matrice : on absorbe l'échelle et l'inclinaison
normalize(node) {
const M = node.M;
if (isOrthonormal(M)) return node;
const L = linear(M);
if (node.type === 'FRAME') {
for (const c of node.children) {
c.M = mul(L, c.M);
Object.assign(c, this.normalize(c));
}
node.M = translate(M[4], M[5]);
node.box = unionBoxes(node.children.map(c => transformBox(c.M, c.box)));
return node;
}
if (node.type === 'TEXT') {
// Échelle uniforme : on l'applique à la taille de police
const sx = Math.hypot(M[0], M[1]), sy = Math.hypot(M[2], M[3]);
this.warn('texte-mis-a-echelle', `${node.name}: ${sx.toFixed(3)}×${sy.toFixed(3)}`);
const s = Math.sqrt(sx * sy);
node.props.fontSize *= s;
for (const o of node.props.textData.styleOverrideTable || []) if (o.fontSize) o.fontSize *= s;
if (node.props.lineHeight.units === 'PIXELS') node.props.lineHeight.value *= s;
node.box = { x: node.box.x * s, y: node.box.y * s, w: node.box.w * s, h: node.box.h * s };
node.M = [M[0] / sx, M[1] / sx, M[2] / sy, M[3] / sy, M[4], M[5]];
return node;
}
// Rectangle/ellipse sans rotation : on redimensionne la boîte
if ((node.type === 'ROUNDED_RECTANGLE' || node.type === 'ELLIPSE') && Math.abs(M[1]) < 1e-6 && Math.abs(M[2]) < 1e-6) {
const b = node.box;
node.box = { x: b.x * M[0], y: b.y * M[3], w: b.w * Math.abs(M[0]), h: b.h * Math.abs(M[3]) };
if (M[0] < 0) node.box.x -= node.box.w;
if (M[3] < 0) node.box.y -= node.box.h;
node.M = translate(M[4], M[5]);
this.rescalePaints(node, b);
return node;
}
// Sinon : conversion en vecteur avec la matrice appliquée à la géométrie
const path = node.path || node.fallbackPath?.();
if (!path) { this.warn('matrice-non-supportee', node.name); return node; }
const oldBox = node.box;
node.type = 'VECTOR';
node.path = mapPath(path, p => apply(L, p));
node.box = pathBounds(node.path);
node.M = translate(M[4], M[5]);
for (const k of ['cornerRadius', 'rectangleCornerRadiiIndependent', 'rectangleTopLeftCornerRadius', 'rectangleTopRightCornerRadius', 'rectangleBottomRightCornerRadius', 'rectangleBottomLeftCornerRadius']) delete node.props[k];
this.rescalePaints(node, oldBox);
return node;
}
rescalePaints(node) {
// Les transformées de peinture sont en coordonnées normalisées : inchangées si la boîte suit la forme.
if (node.props.fillPaints?.some(p => p.type !== 'SOLID')) this.warn('peinture-sur-forme-transformee', node.name);
}
artboard(ab) {
const xd = ab.node;
const { x, y, width, height } = ab.bounds;
const children = (xd.artboard?.children || []).map(c => this.convert(c)).filter(Boolean);
const node = {
type: 'FRAME', isArtboard: true, name: ab.name, M: I, box: { x, y, w: width, h: height },
children,
props: { frameMaskDisabled: false, resizeToFit: false },
};
this.applyStyle(node, xd, { x: 0, y: 0, w: width, h: height });
for (const k of ['horizontalConstraint', 'verticalConstraint']) delete node.props[k];
const grid = xd.meta?.ux?.gridStyle;
if (grid?.columns) node.layoutGrid = grid;
return node;
}
}
// Fusion profonde : les objets fusionnent, les tableaux et scalaires remplacent
function deepMerge(base, over) {
if (!over || typeof over !== 'object' || Array.isArray(over) || !base || typeof base !== 'object' || Array.isArray(base)) return over === undefined ? base : over;
const out = { ...base };
for (const [k, v] of Object.entries(over)) out[k] = deepMerge(base[k], v);
return out;
}
function childrenOf(n) {
return n.group?.children || n.artboard?.children || [];
}
function constraints(ux) {
if (!ux || ux.constraintsDisabled) return {};
const h = ux.constraintLeft && ux.constraintRight ? 'STRETCH' : ux.constraintRight ? 'MAX' : ux.constraintLeft ? 'MIN' : ux.constraintWidth ? 'CENTER' : 'SCALE';
const v = ux.constraintTop && ux.constraintBottom ? 'STRETCH' : ux.constraintBottom ? 'MAX' : ux.constraintTop ? 'MIN' : ux.constraintHeight ? 'CENTER' : 'SCALE';
return { horizontalConstraint: h, verticalConstraint: v };
}
function roundedRectPath(b, r) {
if (!r) return rectPath(b.x, b.y, b.w, b.h);
const [tl, tr, br, bl] = (Array.isArray(r) ? r : [r, r, r, r]).map(v => Math.min(v, b.w / 2, b.h / 2));
const k = 0.5522847498, x = b.x, y = b.y, w = b.w, h = b.h;
const segs = [
{ p: [x + w - tr, y] }, { c1: [x + w - tr + k * tr, y], c2: [x + w, y + tr - k * tr], p: [x + w, y + tr] },
{ p: [x + w, y + h - br] }, { c1: [x + w, y + h - br + k * br], c2: [x + w - br + k * br, y + h], p: [x + w - br, y + h] },
{ p: [x + bl, y + h] }, { c1: [x + bl - k * bl, y + h], c2: [x, y + h - bl + k * bl], p: [x, y + h - bl] },
{ p: [x, y + tl] }, { c1: [x, y + tl - k * tl], c2: [x + tl - k * tl, y], p: [x + tl, y] },
];
return [{ start: [x + tl, y], segs, closed: true }];
}
function imageSize(buf) {
if (buf[0] === 0x89 && buf[1] === 0x50) return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20), format: 'png' };
if (buf[0] === 0xff && buf[1] === 0xd8) {
let o = 2;
while (o < buf.length) {
if (buf[o] !== 0xff) { o++; continue; }
const m = buf[o + 1], len = buf.readUInt16BE(o + 2);
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { width: buf.readUInt16BE(o + 7), height: buf.readUInt16BE(o + 5), format: 'jpeg' };
o += 2 + len;
}
}
return { width: 0, height: 0, format: 'inconnu' };
}
+48
View File
@@ -0,0 +1,48 @@
// Lecture/écriture du conteneur binaire canvas.fig ("fig-kiwi")
import { decodeBinarySchema, encodeBinarySchema, compileSchema, prettyPrintSchema } from 'kiwi-schema';
import { inflateSync, deflateSync } from 'fflate';
import { decompress as zstdDecompress } from 'fzstd';
const MAGIC = 'fig-kiwi';
function unpack(buf) {
// zstd magic 28 B5 2F FD
if (buf[0] === 0x28 && buf[1] === 0xb5 && buf[2] === 0x2f && buf[3] === 0xfd) return { data: zstdDecompress(buf), codec: 'zstd' };
return { data: inflateSync(buf), codec: 'deflate' };
}
export function readCanvas(bytes) {
const header = new TextDecoder().decode(bytes.subarray(0, 8));
if (header !== MAGIC) throw new Error(`En-tête inattendu: ${header}`);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const version = view.getUint32(8, true);
const chunks = [];
let off = 12;
while (off < bytes.length) {
const len = view.getUint32(off, true); off += 4;
chunks.push(bytes.subarray(off, off + len)); off += len;
}
const s = unpack(chunks[0]);
const d = unpack(chunks[1]);
const schema = decodeBinarySchema(s.data);
const compiled = compileSchema(schema);
const message = compiled.decodeMessage(d.data);
return { version, schema, compiled, message, codecs: [s.codec, d.codec], extraChunks: chunks.slice(2) };
}
export function writeCanvas({ version, schema, compiled, message }) {
const schemaBytes = deflateSync(encodeBinarySchema(schema));
const dataBytes = deflateSync(compiled.encodeMessage(message));
const out = new Uint8Array(12 + 4 + schemaBytes.length + 4 + dataBytes.length);
const view = new DataView(out.buffer);
out.set(new TextEncoder().encode(MAGIC), 0);
view.setUint32(8, version, true);
let off = 12;
for (const c of [schemaBytes, dataBytes]) {
view.setUint32(off, c.length, true); off += 4;
out.set(c, off); off += c.length;
}
return out;
}
export { prettyPrintSchema };
+30
View File
@@ -0,0 +1,30 @@
// Matrices affines 2D [a, b, c, d, e, f] : x' = a*x + c*y + e ; y' = b*x + d*y + f
export const I = [1, 0, 0, 1, 0, 0];
export const fromXd = (t) => t ? [t.a, t.b, t.c, t.d, t.tx, t.ty] : I;
export const translate = (x, y) => [1, 0, 0, 1, x, y];
export const mul = (A, B) => [
A[0] * B[0] + A[2] * B[1], A[1] * B[0] + A[3] * B[1],
A[0] * B[2] + A[2] * B[3], A[1] * B[2] + A[3] * B[3],
A[0] * B[4] + A[2] * B[5] + A[4], A[1] * B[4] + A[3] * B[5] + A[5],
];
export const inv = (M) => {
const det = M[0] * M[3] - M[1] * M[2];
return [M[3] / det, -M[1] / det, -M[2] / det, M[0] / det, (M[2] * M[5] - M[3] * M[4]) / det, (M[1] * M[4] - M[0] * M[5]) / det];
};
export const apply = (M, p) => [M[0] * p[0] + M[2] * p[1] + M[4], M[1] * p[0] + M[3] * p[1] + M[5]];
export const isOrthonormal = (M, eps = 1e-3) =>
Math.abs(M[0] * M[0] + M[1] * M[1] - 1) < eps && Math.abs(M[2] * M[2] + M[3] * M[3] - 1) < eps && Math.abs(M[0] * M[2] + M[1] * M[3]) < eps;
export const linear = (M) => [M[0], M[1], M[2], M[3], 0, 0];
export const toFig = (M) => ({ m00: M[0], m01: M[2], m02: M[4], m10: M[1], m11: M[3], m12: M[5] });
// Bornes alignées sur les axes d'un rectangle transformé
export function transformBox(M, b) {
const pts = [[b.x, b.y], [b.x + b.w, b.y], [b.x, b.y + b.h], [b.x + b.w, b.y + b.h]].map(p => apply(M, p));
const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]);
const x = Math.min(...xs), y = Math.min(...ys);
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}
export function unionBoxes(boxes) {
if (!boxes.length) return { x: 0, y: 0, w: 0, h: 0 };
const x = Math.min(...boxes.map(b => b.x)), y = Math.min(...boxes.map(b => b.y));
return { x, y, w: Math.max(...boxes.map(b => b.x + b.w)) - x, h: Math.max(...boxes.map(b => b.y + b.h)) - y };
}
+209
View File
@@ -0,0 +1,209 @@
// Chemins SVG : parsing, bornes exactes, blobs de géométrie et vector networks Figma
const TOKEN = /[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g;
// Renvoie une liste de sous-chemins { start:[x,y], segs:[{c1,c2,p}|{p}], closed }
export function parsePath(d) {
const toks = d.match(TOKEN) || [];
let i = 0, cmd = null;
const num = () => parseFloat(toks[i++]);
const isNum = () => i < toks.length && !/^[a-zA-Z]$/.test(toks[i]);
const subs = [];
let cur = null, x = 0, y = 0, sx = 0, sy = 0, lastC2 = null, lastQ = null;
const ensure = () => { if (!cur) { cur = { start: [x, y], segs: [], closed: false }; subs.push(cur); } };
const line = (nx, ny) => { ensure(); cur.segs.push({ p: [nx, ny] }); x = nx; y = ny; };
const cubic = (c1, c2, p) => { ensure(); cur.segs.push({ c1, c2, p }); x = p[0]; y = p[1]; };
while (i < toks.length) {
if (!isNum()) cmd = toks[i++];
const rel = cmd === cmd.toLowerCase();
const ox = rel ? x : 0, oy = rel ? y : 0;
const C = cmd.toUpperCase();
let nextC2 = null, nextQ = null;
switch (C) {
case 'M': {
const nx = num() + ox, ny = num() + oy;
cur = { start: [nx, ny], segs: [], closed: false }; subs.push(cur);
x = sx = nx; y = sy = ny;
cmd = rel ? 'l' : 'L'; // les paires suivantes sont des lineTo
break;
}
case 'L': line(num() + ox, num() + oy); break;
case 'H': line(num() + ox, y); break;
case 'V': line(x, num() + oy); break;
case 'C': {
const c1 = [num() + ox, num() + oy], c2 = [num() + ox, num() + oy], p = [num() + ox, num() + oy];
cubic(c1, c2, p); nextC2 = c2; break;
}
case 'S': {
const c1 = lastC2 ? [2 * x - lastC2[0], 2 * y - lastC2[1]] : [x, y];
const c2 = [num() + ox, num() + oy], p = [num() + ox, num() + oy];
cubic(c1, c2, p); nextC2 = c2; break;
}
case 'Q': {
const q = [num() + ox, num() + oy], p = [num() + ox, num() + oy];
cubic([x + 2 / 3 * (q[0] - x), y + 2 / 3 * (q[1] - y)], [p[0] + 2 / 3 * (q[0] - p[0]), p[1] + 2 / 3 * (q[1] - p[1])], p);
nextQ = q; break;
}
case 'T': {
const q = lastQ ? [2 * x - lastQ[0], 2 * y - lastQ[1]] : [x, y];
const p = [num() + ox, num() + oy];
cubic([x + 2 / 3 * (q[0] - x), y + 2 / 3 * (q[1] - y)], [p[0] + 2 / 3 * (q[0] - p[0]), p[1] + 2 / 3 * (q[1] - p[1])], p);
nextQ = q; break;
}
case 'A': {
const rx = num(), ry = num(), rot = num(), large = num(), sweep = num();
const p = [num() + ox, num() + oy];
for (const [c1, c2, e] of arcToCubics(x, y, rx, ry, rot, large, sweep, p[0], p[1])) cubic(c1, c2, e);
x = p[0]; y = p[1]; break;
}
case 'Z': {
if (cur) cur.closed = true;
x = sx; y = sy; cur = null; break;
}
default: throw new Error(`Commande SVG inconnue: ${cmd}`);
}
lastC2 = nextC2; lastQ = nextQ;
}
return subs.filter(s => s.segs.length || s.closed);
}
function arcToCubics(x1, y1, rx, ry, phiDeg, fa, fs, x2, y2) {
if (rx === 0 || ry === 0) return [[[x1, y1], [x2, y2], [x2, y2]]];
const phi = phiDeg * Math.PI / 180, cos = Math.cos(phi), sin = Math.sin(phi);
rx = Math.abs(rx); ry = Math.abs(ry);
const dx = (x1 - x2) / 2, dy = (y1 - y2) / 2;
const x1p = cos * dx + sin * dy, y1p = -sin * dx + cos * dy;
const lam = x1p * x1p / (rx * rx) + y1p * y1p / (ry * ry);
if (lam > 1) { rx *= Math.sqrt(lam); ry *= Math.sqrt(lam); }
const num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
let co = Math.sqrt(Math.max(0, num / (rx * rx * y1p * y1p + ry * ry * x1p * x1p)));
if (fa == fs) co = -co;
const cxp = co * rx * y1p / ry, cyp = -co * ry * x1p / rx;
const cx = cos * cxp - sin * cyp + (x1 + x2) / 2, cy = sin * cxp + cos * cyp + (y1 + y2) / 2;
const ang = (ux, uy, vx, vy) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
let t1 = ang(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
let dt = ang((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
if (!fs && dt > 0) dt -= 2 * Math.PI; else if (fs && dt < 0) dt += 2 * Math.PI;
const n = Math.ceil(Math.abs(dt) / (Math.PI / 2));
const out = [], h = dt / n, k = 4 / 3 * Math.tan(h / 4);
const pt = (t) => [cx + rx * Math.cos(t) * cos - ry * Math.sin(t) * sin, cy + rx * Math.cos(t) * sin + ry * Math.sin(t) * cos];
const dv = (t) => [-rx * Math.sin(t) * cos - ry * Math.cos(t) * sin, -rx * Math.sin(t) * sin + ry * Math.cos(t) * cos];
for (let j = 0; j < n; j++) {
const a = t1 + j * h, b = a + h, pa = pt(a), pb = pt(b), da = dv(a), db = dv(b);
out.push([[pa[0] + k * da[0], pa[1] + k * da[1]], [pb[0] - k * db[0], pb[1] - k * db[1]], j === n - 1 ? [x2, y2] : pb]);
}
return out;
}
export function mapPath(subs, f) {
return subs.map(s => ({
closed: s.closed, start: f(s.start),
segs: s.segs.map(g => g.c1 ? { c1: f(g.c1), c2: f(g.c2), p: f(g.p) } : { p: f(g.p) }),
}));
}
// Bornes exactes (extrema des cubiques)
export function pathBounds(subs) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
const add = (p) => { x0 = Math.min(x0, p[0]); y0 = Math.min(y0, p[1]); x1 = Math.max(x1, p[0]); y1 = Math.max(y1, p[1]); };
for (const s of subs) {
let prev = s.start; add(prev);
for (const g of s.segs) {
add(g.p);
if (g.c1) for (const t of cubicExtrema(prev, g.c1, g.c2, g.p)) add(cubicAt(prev, g.c1, g.c2, g.p, t));
prev = g.p;
}
}
if (x0 === Infinity) return { x: 0, y: 0, w: 0, h: 0 };
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
function cubicAt(p0, p1, p2, p3, t) {
const u = 1 - t;
return [0, 1].map(k => u * u * u * p0[k] + 3 * u * u * t * p1[k] + 3 * u * t * t * p2[k] + t * t * t * p3[k]);
}
function cubicExtrema(p0, p1, p2, p3) {
const ts = [];
for (const k of [0, 1]) {
const a = -p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k], b = 2 * (p0[k] - 2 * p1[k] + p2[k]), c = p1[k] - p0[k];
if (Math.abs(a) < 1e-12) { if (Math.abs(b) > 1e-12) ts.push(-c / b); continue; }
const disc = b * b - 4 * a * c;
if (disc < 0) continue;
const r = Math.sqrt(disc);
ts.push((-b + r) / (2 * a), (-b - r) / (2 * a));
}
return ts.filter(t => t > 0 && t < 1);
}
// Blob de commandes : 0=close, 1=moveTo, 2=lineTo, 4=cubicTo ; float32 LE
export function geometryBlob(subs, { closeAll = false } = {}) {
const parts = [];
const push = (op, ...pts) => {
const b = Buffer.alloc(1 + pts.length * 8); b[0] = op;
pts.forEach((p, i) => { b.writeFloatLE(p[0], 1 + i * 8); b.writeFloatLE(p[1], 5 + i * 8); });
parts.push(b);
};
for (const s of subs) {
push(1, s.start);
for (const g of s.segs) g.c1 ? push(4, g.c1, g.c2, g.p) : push(2, g.p);
if (s.closed || closeAll) push(0);
}
return new Uint8Array(Buffer.concat(parts));
}
// Vector network : sommets, segments (tangentes relatives), régions (boucles fermées)
export function vectorNetworkBlob(subs, windingRule = 'NONZERO') {
const verts = [], segs = [], loops = [];
const same = (a, b) => Math.abs(a[0] - b[0]) < 1e-4 && Math.abs(a[1] - b[1]) < 1e-4;
for (const s of subs) {
const first = verts.length;
verts.push(s.start);
const loop = [];
s.segs.forEach((g, j) => {
const from = verts.length - 1;
const isLast = j === s.segs.length - 1;
let to;
if (isLast && s.closed && same(g.p, s.start) && verts.length - 1 > first) to = first;
else { verts.push(g.p); to = verts.length - 1; }
const a = verts[from], b = verts[to];
const t0 = g.c1 ? [g.c1[0] - a[0], g.c1[1] - a[1]] : [0, 0];
const t1 = g.c2 ? [g.c2[0] - b[0], g.c2[1] - b[1]] : [0, 0];
loop.push(segs.length);
segs.push({ from, to, t0, t1 });
});
if (s.closed) {
const lastV = verts.length - 1;
if (!(segs.length && segs[segs.length - 1].to === first)) {
if (lastV !== first) { loop.push(segs.length); segs.push({ from: lastV, to: first, t0: [0, 0], t1: [0, 0] }); }
}
if (loop.length) loops.push(loop);
}
}
const size = 12 + verts.length * 12 + segs.length * 28 + (loops.length ? 8 + loops.reduce((n, l) => n + 4 + l.length * 4, 0) : 0);
const b = Buffer.alloc(size);
let o = 0;
const u = (v) => { b.writeUInt32LE(v, o); o += 4; }, f = (v) => { b.writeFloatLE(v, o); o += 4; };
u(verts.length); u(segs.length); u(loops.length ? 1 : 0);
for (const v of verts) { u(0); f(v[0]); f(v[1]); }
for (const s of segs) { u(0); u(s.from); f(s.t0[0]); f(s.t0[1]); u(s.to); f(s.t1[0]); f(s.t1[1]); }
if (loops.length) {
u(windingRule === 'NONZERO' ? 1 : 0); u(loops.length);
for (const l of loops) { u(l.length); l.forEach(u); }
}
return new Uint8Array(b);
}
// Rectangle / ellipse en sous-chemins (pour les cas qui doivent devenir des vecteurs)
export function rectPath(x, y, w, h) {
return [{ start: [x, y], closed: true, segs: [{ p: [x + w, y] }, { p: [x + w, y + h] }, { p: [x, y + h] }, { p: [x, y] }] }];
}
export function ellipsePath(cx, cy, rx, ry) {
const k = 0.5522847498;
return [{ start: [cx + rx, cy], closed: true, segs: [
{ c1: [cx + rx, cy + k * ry], c2: [cx + k * rx, cy + ry], p: [cx, cy + ry] },
{ c1: [cx - k * rx, cy + ry], c2: [cx - rx, cy + k * ry], p: [cx - rx, cy] },
{ c1: [cx - rx, cy - k * ry], c2: [cx - k * rx, cy - ry], p: [cx, cy - ry] },
{ c1: [cx + k * rx, cy - ry], c2: [cx + rx, cy - k * ry], p: [cx + rx, cy] },
] }];
}
+49
View File
@@ -0,0 +1,49 @@
// Lecture d'un fichier .xd (conteneur UCF/ZIP)
import JSZip from 'jszip';
export async function readXd(bytes) {
const zip = await JSZip.loadAsync(bytes);
const json = async (p) => JSON.parse(await zip.file(p).async('string'));
const manifest = await json('manifest');
const artboards = [];
const walk = (n) => {
if (n['uxdesign#bounds'] && n.path?.startsWith('artboard-')) artboards.push({ name: n.name, path: n.path, bounds: n['uxdesign#bounds'], viewport: n['uxdesign#viewport'] });
(n.children || []).forEach(walk);
};
walk(manifest);
for (const ab of artboards) {
const agc = await json(`artwork/${ab.path}/graphics/graphicContent.agc`);
ab.node = agc.children.find(c => c.type === 'artboard') || agc.children[0];
}
// Ordre visuel : de gauche à droite, de haut en bas
artboards.sort((a, b) => a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x);
const pbFile = zip.file('artwork/pasteboard/graphics/graphicContent.agc');
const pasteboard = pbFile ? JSON.parse(await pbFile.async('string')).children : [];
const resFile = zip.file('resources/graphics/graphicContent.agc');
const resources = resFile ? JSON.parse(await resFile.async('string')).resources : {};
// Index de tous les éléments (maîtres de composants inclus) pour résoudre les syncRef
const byId = new Map();
const index = (n) => {
if (Array.isArray(n)) return n.forEach(index);
if (!n || typeof n !== 'object') return;
if (n.id && n.type && n.type !== 'syncRef') byId.set(n.id, n);
Object.values(n).forEach(index);
};
index(resources);
artboards.forEach(a => index(a.node));
index(pasteboard);
const interactionsFile = zip.file('interactions/interactions.json');
const interactions = interactionsFile ? JSON.parse(await interactionsFile.async('string')) : null;
const getResource = async (uid) => {
const f = zip.file(`resources/${uid}`);
return f ? f.async('nodebuffer') : null;
};
return { manifest, artboards, pasteboard, resources, interactions, getResource, byId };
}