feat: add initial version of xd2fig tool
This commit is contained in:
1 parent
076c5f492b
commit
116487438a
15 files changed
+2089
No files matched your search
@@ -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
@@ -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
@@ -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' };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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] },
|
||||
] }];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user