feat: add initial version of xd2fig tool

This commit is contained in:
2026-09-25 10:06:13 -04:00
parent 076c5f492b
commit 116487438a
15 changed files with 2089 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
*.xd
*.fig
!template/canvas.fig
+41
View File
@@ -1,2 +1,43 @@
# xd2fig # xd2fig
Convertisseur Adobe XD (`.xd`) → Figma (`.fig`). Le fichier produit s'importe dans Figma par glisser-déposer dans le navigateur de fichiers.
## Utilisation
```sh
npm install
node src/cli.js maquette.xd [sortie.fig]
```
Le rapport affiche les polices à installer et les éléments qui n'ont pas pu être reproduits à l'identique.
## Ce qui est converti
- Artboards (frames), groupes, masques, éléments hors artboard
- Rectangles (coins arrondis), ellipses, lignes, chemins et formes composées (vecteurs éditables)
- Textes ponctuels, à hauteur auto et à zone, avec styles mixtes, interligne, alignement, casse, soulignement
- Remplissages unis, dégradés linéaires et radiaux, images (recadrage, symétrie), contours
- Ombres portées et internes, flou de calque et d'arrière-plan
- Contraintes responsive, opacité, modes de fusion
- Instances de composants (contenu résolu depuis les maîtres, surcharges comprises) — converties en groupes
## Limites connues
- Les composants XD deviennent des groupes, pas des composants Figma
- La luminosité du flou d'arrière-plan n'est pas reproduite
- Grilles et repères des artboards non transférés
- Les images de plus de 4096 px sont réduites (limite de Figma)
## Fonctionnement
- `src/xd.js` : lecture du `.xd` (ZIP : manifest, `graphicContent.agc` par artboard, ressources)
- `src/convert.js` : arbre XD → arbre intermédiaire (transformations, peintures, textes, effets)
- `src/build.js` : arbre intermédiaire → nœuds Figma (géométrie, vector networks, images)
- `src/figfile.js` : lecture/écriture du binaire `canvas.fig` (`fig-kiwi` : schéma kiwi + données compressées)
- `template/canvas.fig` : schéma kiwi de Figma (version 106), sans contenu. Si Figma change de format, le remplacer par le `canvas.fig` d'un fichier récent (Fichier → Enregistrer une copie locale), vidé avec `writeCanvas`.
## Outils de développement
- `tools/render.js fichier.fig dossier/` : aperçu SVG d'un `.fig` généré, pour comparer sans Figma
- `tools/inspect-fig.js canvas.fig dossier/` : dump du schéma et des nœuds d'un `.fig`
- `tools/test-fig.js` : génère des `.fig` de test minimaux
+774
View File
@@ -0,0 +1,774 @@
{
"name": "xd2fig",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xd2fig",
"version": "1.0.0",
"dependencies": {
"fflate": "^0.8.3",
"fzstd": "^0.1.1",
"jszip": "^3.10.2",
"kiwi-schema": "^0.5.0",
"sharp": "^0.35.4"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fzstd": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz",
"integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==",
"license": "MIT"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/kiwi-schema": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/kiwi-schema/-/kiwi-schema-0.5.0.tgz",
"integrity": "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA==",
"license": "MIT",
"bin": {
"kiwic": "cli.js"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "xd2fig",
"version": "1.0.0",
"description": "Convertisseur Adobe XD (.xd) vers Figma (.fig)",
"scripts": {
"convert": "node src/cli.js"
},
"keywords": [],
"type": "module",
"dependencies": {
"fflate": "^0.8.3",
"fzstd": "^0.1.1",
"jszip": "^3.10.2",
"kiwi-schema": "^0.5.0",
"sharp": "^0.35.4"
},
"bin": {
"xd2fig": "src/cli.js"
}
}
+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 };
}
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
import fs from 'node:fs';
import { readCanvas, prettyPrintSchema } from '../src/figfile.js';
const [,, canvasPath, outDir] = process.argv;
const r = readCanvas(new Uint8Array(fs.readFileSync(canvasPath)));
console.log('version', r.version, 'codecs', r.codecs, 'extra chunks', r.extraChunks.length);
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(`${outDir}/schema.kiwi.txt`, prettyPrintSchema(r.schema));
const replacer = (k, v) => v instanceof Uint8Array ? `<bytes ${v.length}>` : v;
fs.writeFileSync(`${outDir}/message.json`, JSON.stringify(r.message, replacer, 1));
const m = r.message;
console.log('message keys', Object.keys(m), 'type', m.type, 'nodes', m.nodeChanges?.length, 'blobs', m.blobs?.length);
const types = {};
for (const n of m.nodeChanges) types[n.type] = (types[n.type] || 0) + 1;
console.log(types);
+180
View File
@@ -0,0 +1,180 @@
// Aperçu SVG d'un .fig généré (contrôle visuel sans Figma) : node tools/render.js fichier.fig dossierSortie
import fs from 'node:fs';
import path from 'node:path';
import JSZip from 'jszip';
import { readCanvas } from '../src/figfile.js';
const [,, figPath, outDir] = process.argv;
fs.mkdirSync(`${outDir}/img`, { recursive: true });
const zip = await JSZip.loadAsync(fs.readFileSync(figPath));
const { message } = readCanvas(await zip.file('canvas.fig').async('uint8array'));
const nodes = message.nodeChanges, blobs = message.blobs;
const key = (g) => `${g.sessionID}:${g.localID}`;
const kids = new Map();
for (const n of nodes) if (n.parentIndex) {
const k = key(n.parentIndex.guid);
if (!kids.has(k)) kids.set(k, []);
kids.get(k).push(n);
}
for (const l of kids.values()) l.sort((a, b) => a.parentIndex.position < b.parentIndex.position ? -1 : 1);
for (const f of Object.keys(zip.files)) if (f.startsWith('images/') && !zip.files[f].dir) {
const out = `${outDir}/img/${f.slice(7)}`;
if (!fs.existsSync(out)) fs.writeFileSync(out, await zip.file(f).async('nodebuffer'));
}
let uid = 0;
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const rgba = (c, o = 1) => `rgba(${Math.round(c.r * 255)},${Math.round(c.g * 255)},${Math.round(c.b * 255)},${(c.a ?? 1) * o})`;
const mat = (t) => `matrix(${t.m00} ${t.m10} ${t.m01} ${t.m11} ${t.m02} ${t.m12})`;
const inv = (t) => { const d = t.m00 * t.m11 - t.m01 * t.m10; return { m00: t.m11 / d, m10: -t.m10 / d, m01: -t.m01 / d, m11: t.m00 / d, m02: (t.m01 * t.m12 - t.m11 * t.m02) / d, m12: (t.m10 * t.m02 - t.m00 * t.m12) / d }; };
function geomPath(i) {
const b = Buffer.from(blobs[i].bytes); let o = 0, d = '';
const f = () => { const v = b.readFloatLE(o); o += 4; return v.toFixed(2); };
while (o < b.length) {
const c = b[o++];
if (c === 0) d += 'Z';
else if (c === 1) d += `M${f()} ${f()}`;
else if (c === 2) d += `L${f()} ${f()}`;
else if (c === 3) d += `Q${f()} ${f()} ${f()} ${f()}`;
else if (c === 4) d += `C${f()} ${f()} ${f()} ${f()} ${f()} ${f()}`;
else break;
}
return d;
}
function paint(p, n, defs) {
if (!p || p.visible === false) return null;
if (p.type === 'SOLID') return rgba(p.color, p.opacity ?? 1);
const id = `p${uid++}`;
if (p.type === 'GRADIENT_LINEAR' || p.type === 'GRADIENT_RADIAL') {
const stops = p.stops.map(s => `<stop offset="${s.position}" stop-color="${rgba({ ...s.color, a: 1 })}" stop-opacity="${s.color.a * (p.opacity ?? 1)}"/>`).join('');
const gt = mat(inv(p.transform));
defs.push(p.type === 'GRADIENT_LINEAR'
? `<linearGradient id="${id}" gradientUnits="objectBoundingBox" x1="0" y1="0.5" x2="1" y2="0.5" gradientTransform="${gt}">${stops}</linearGradient>`
: `<radialGradient id="${id}" gradientUnits="objectBoundingBox" cx="0.5" cy="0.5" r="0.5" gradientTransform="${gt}">${stops}</radialGradient>`);
return `url(#${id})`;
}
if (p.type === 'IMAGE') {
const h = Buffer.from(p.image.hash).toString('hex');
const W = n.size.x, H = n.size.y, iw = p.originalImageWidth, ih = p.originalImageHeight;
let img;
if (p.imageScaleMode === 'FILL') {
const s = Math.max(W / iw, H / ih);
img = `<image href="img/${h}" x="${(W - iw * s) / 2}" y="${(H - ih * s) / 2}" width="${iw * s}" height="${ih * s}" preserveAspectRatio="none"/>`;
} else {
// image normalisée = T * nœud normalisé → nœud = inv(T) * image
const it = inv(p.transform);
img = `<g transform="scale(${W} ${H}) ${mat(it)}"><image href="img/${h}" x="0" y="0" width="1" height="1" preserveAspectRatio="none"/></g>`;
}
defs.push(`<pattern id="${id}" patternUnits="userSpaceOnUse" x="0" y="0" width="${W}" height="${H}">${img}</pattern>`);
return `url(#${id})`;
}
return null;
}
function shapeD(n) {
const w = n.size.x, h = n.size.y;
if (n.type === 'ELLIPSE') return `M${w} ${h / 2}A${w / 2} ${h / 2} 0 1 1 0 ${h / 2}A${w / 2} ${h / 2} 0 1 1 ${w} ${h / 2}Z`;
if (n.type === 'VECTOR') return n.fillGeometry?.[0] ? geomPath(n.fillGeometry[0].commandsBlob) : vnPath(n);
const r = n.cornerRadius || 0;
const [tl, tr, br, bl] = n.rectangleCornerRadiiIndependent ? [n.rectangleTopLeftCornerRadius, n.rectangleTopRightCornerRadius, n.rectangleBottomRightCornerRadius, n.rectangleBottomLeftCornerRadius] : [r, r, r, r];
return `M${tl} 0H${w - tr}A${tr} ${tr} 0 0 1 ${w} ${tr}V${h - br}A${br} ${br} 0 0 1 ${w - br} ${h}H${bl}A${bl} ${bl} 0 0 1 0 ${h - bl}V${tl}A${tl} ${tl} 0 0 1 ${tl} 0Z`;
}
function vnPath(n) {
const b = Buffer.from(blobs[n.vectorData.vectorNetworkBlob].bytes); let o = 0;
const u = () => { const v = b.readUInt32LE(o); o += 4; return v; }, f = () => { const v = b.readFloatLE(o); o += 4; return v; };
const nv = u(), ns = u(); u();
const V = []; for (let i = 0; i < nv; i++) { u(); V.push([f(), f()]); }
let d = '', last = -1;
for (let i = 0; i < ns; i++) {
u(); const a = u(), t0 = [f(), f()], e = u(), t1 = [f(), f()];
if (a !== last) d += `M${V[a][0]} ${V[a][1]}`;
d += `C${V[a][0] + t0[0]} ${V[a][1] + t0[1]} ${V[e][0] + t1[0]} ${V[e][1] + t1[1]} ${V[e][0]} ${V[e][1]}`;
last = e;
}
return d;
}
function effectsFilter(n, defs) {
const fx = (n.effects || []).filter(e => e.visible !== false && (e.type === 'DROP_SHADOW' || e.type === 'FOREGROUND_BLUR'));
if (!fx.length) return '';
const id = `f${uid++}`;
const parts = fx.map((e, i) => e.type === 'DROP_SHADOW'
? `<feDropShadow dx="${e.offset.x}" dy="${e.offset.y}" stdDeviation="${e.radius / 2}" flood-color="${rgba({ ...e.color, a: 1 })}" flood-opacity="${e.color.a}"/>`
: `<feGaussianBlur stdDeviation="${e.radius / 2}"/>`);
defs.push(`<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%">${parts.join('')}</filter>`);
return ` filter="url(#${id})"`;
}
function textSvg(n) {
const fs_ = n.fontSize, fam = n.fontName.family;
const weight = /black|ultra|extra ?bold|heavy/i.test(n.fontName.style) ? 900 : /semi|demi/i.test(n.fontName.style) ? 600 : /bold/i.test(n.fontName.style) ? 700 : /medium/i.test(n.fontName.style) ? 500 : /light/i.test(n.fontName.style) ? 300 : 400;
const lh = n.lineHeight.units === 'PIXELS' ? n.lineHeight.value : fs_ * 1.2;
const anchor = { CENTER: 'middle', RIGHT: 'end' }[n.textAlignHorizontal] || 'start';
const x = anchor === 'middle' ? n.size.x / 2 : anchor === 'end' ? n.size.x : 0;
let chars = n.textData.characters;
if (n.textCase === 'UPPER') chars = chars.toUpperCase();
// retour à la ligne approximatif
const maxW = n.textAutoResize === 'WIDTH_AND_HEIGHT' ? Infinity : n.size.x;
const lines = [];
for (const para of chars.split('\n')) {
let line = '';
for (const w of para.split(' ')) {
const cand = line ? line + ' ' + w : w;
if (cand.length * fs_ * 0.55 > maxW && line) { lines.push(line); line = w; } else line = cand;
}
lines.push(line);
}
const fill = n.fillPaints?.[0]?.type === 'SOLID' ? rgba(n.fillPaints[0].color, n.fillPaints[0].opacity) : '#000';
const ls = n.letterSpacing?.value ? ` letter-spacing="${n.letterSpacing.value / 100 * fs_}"` : '';
return `<text font-family="${esc(fam)}, sans-serif" font-size="${fs_}" font-weight="${weight}" fill="${fill}" text-anchor="${anchor}"${ls}>` +
lines.map((l, i) => `<tspan x="${x}" y="${lh / 2 + fs_ * 0.35 + i * lh}">${esc(l)}</tspan>`).join('') + '</text>';
}
function render(n, defs) {
if (n.visible === false) return '';
const children = kids.get(key(n.guid)) || [];
const op = n.opacity != null && n.opacity < 1 ? ` opacity="${n.opacity}"` : '';
const filt = effectsFilter(n, defs);
let body = '';
if (n.type === 'TEXT') body = textSvg(n);
else if (n.type !== 'FRAME' || n.fillPaints?.length) {
const d = n.type === 'FRAME' ? `M0 0H${n.size.x}V${n.size.y}H0Z` : shapeD(n);
for (const p of n.fillPaints || []) { const f = paint(p, n, defs); if (f) body += `<path d="${d}" fill="${f}"/>`; }
for (const p of n.strokePaints || []) {
const s = paint(p, n, defs);
if (s) body += `<path d="${n.type === 'VECTOR' ? vnPath(n) : d}" fill="none" stroke="${s}" stroke-width="${n.strokeWeight}"${n.dashPattern ? ` stroke-dasharray="${n.dashPattern.join(' ')}"` : ''}/>`;
}
}
// enfants + masques (un masque découpe les frères qui le suivent)
let inner = '', clipId = null;
for (const c of children) {
if (c.mask) {
clipId = `c${uid++}`;
defs.push(`<clipPath id="${clipId}"><path transform="${mat(c.transform)}" d="${shapeD(c)}"/></clipPath>`);
continue;
}
const r = render(c, defs);
inner += clipId ? `<g clip-path="url(#${clipId})">${r}</g>` : r;
}
if (n.type === 'FRAME' && !n.resizeToFit && !n.frameMaskDisabled && inner) {
const id = `c${uid++}`;
defs.push(`<clipPath id="${id}"><rect width="${n.size.x}" height="${n.size.y}"/></clipPath>`);
inner = `<g clip-path="url(#${id})">${inner}</g>`;
}
return `<g transform="${mat(n.transform)}"${op}${filt}>${body}${inner}</g>`;
}
const page = nodes.find(n => n.type === 'CANVAS');
const tops = kids.get(key(page.guid)).filter(n => n.type === 'FRAME' && !n.resizeToFit);
for (const ab of tops) {
const defs = [];
const t = { ...ab.transform, m02: 0, m12: 0 };
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${ab.size.x}" height="${ab.size.y}" viewBox="0 0 ${ab.size.x} ${ab.size.y}">` +
render({ ...ab, transform: t }, defs).replace(/^/, '') + `<defs>${defs.join('')}</defs></svg>`;
const name = ab.name.replace(/[^\w-]+/g, '_');
fs.writeFileSync(`${outDir}/${name}.svg`, svg);
console.log(name);
}
+75
View File
@@ -0,0 +1,75 @@
// Génère des .fig de test pour valider ce que Figma accepte à l'import
import fs from 'node:fs';
import crypto from 'node:crypto';
import JSZip from 'jszip';
import { readCanvas, writeCanvas } from '../src/figfile.js';
const [,, refPath, outDir] = process.argv;
fs.mkdirSync(outDir, { recursive: true });
const refZip = await JSZip.loadAsync(fs.readFileSync(refPath));
const ref = readCanvas(await refZip.file('canvas.fig').async('uint8array'));
const meta = await refZip.file('meta.json').async('string');
const thumb = await refZip.file('thumbnail.png').async('uint8array');
async function pack(name, canvas, images = {}) {
const zip = new JSZip();
zip.file('canvas.fig', writeCanvas(canvas));
zip.file('thumbnail.png', thumb);
const m = JSON.parse(meta); m.file_name = name;
zip.file('meta.json', JSON.stringify(m));
zip.folder('images');
for (const [h, b] of Object.entries(images)) zip.file(`images/${h}`, b);
fs.writeFileSync(`${outDir}/${name}.fig`, await zip.generateAsync({ type: 'uint8array', compression: 'STORE' }));
}
// Test 1 : aller-retour décodage/réencodage du fichier de référence
const refImages = {};
for (const f of Object.keys(refZip.files).filter(f => f.startsWith('images/') && !refZip.files[f].dir))
refImages[f.slice(7)] = await refZip.file(f).async('uint8array');
await pack('test1-roundtrip', ref, refImages);
// Test 2 : document minimal construit de zéro, sans géométrie ni layout texte précalculés
const id = (localID) => ({ sessionID: 1, localID });
const T = (x, y) => ({ m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y });
const solid = (r, g, b, a = 1) => ({ type: 'SOLID', color: { r, g, b, a }, opacity: 1, visible: true, blendMode: 'NORMAL' });
const base = { phase: 'CREATED', visible: true, opacity: 1, strokeWeight: 1, strokeAlign: 'INSIDE', strokeJoin: 'MITER' };
const imgBytes = fs.readFileSync(process.argv[4]);
const imgHash = crypto.createHash('sha1').update(imgBytes).digest();
const imgHex = imgHash.toString('hex');
// blob de géométrie : 1=moveTo, 2=lineTo, 4=cubicTo, 0=close ; float32 LE
function geomBlob(cmds) {
const parts = [];
for (const [op, ...pts] of cmds) {
const b = Buffer.alloc(1 + pts.length * 4); b[0] = op;
pts.forEach((p, i) => b.writeFloatLE(p, 1 + i * 4)); parts.push(b);
}
return { bytes: new Uint8Array(Buffer.concat(parts)) };
}
function minimal({ withGeometry }) {
const blobs = [];
const tri = geomBlob([[1, 50, 0], [2, 100, 100], [2, 0, 100], [2, 50, 0], [0]]);
blobs.push(tri);
const nodes = [
{ ...base, guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Document', transform: T(0, 0), documentColorProfile: 'SRGB' },
{ ...base, guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page 1', transform: T(0, 0), backgroundOpacity: 1, backgroundEnabled: true, backgroundColor: { r: 0.9, g: 0.9, b: 0.9, a: 1 } },
{ ...base, guid: id(1), parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'FRAME', name: 'Artboard test', size: { x: 800, y: 600 }, transform: T(0, 0), fillPaints: [solid(1, 1, 1)], resizeToFit: false, frameMaskDisabled: false },
{ ...base, guid: id(2), parentIndex: { guid: id(1), position: '!' }, type: 'ROUNDED_RECTANGLE', name: 'Rect rouge arrondi', size: { x: 200, y: 120 }, transform: T(40, 40), fillPaints: [solid(0.9, 0.1, 0.2)], cornerRadius: 16,
effects: [{ type: 'DROP_SHADOW', color: { r: 0, g: 0, b: 0, a: 0.4 }, offset: { x: 0, y: 8 }, radius: 16, spread: 0, visible: true, blendMode: 'NORMAL', showShadowBehindNode: false }] },
{ ...base, guid: id(3), parentIndex: { guid: id(1), position: '"' }, type: 'ELLIPSE', name: 'Cercle dégradé', size: { x: 150, y: 150 }, transform: T(300, 40),
fillPaints: [{ type: 'GRADIENT_LINEAR', opacity: 1, visible: true, blendMode: 'NORMAL', transform: T(0, 0), stops: [{ color: { r: 0, g: 0.4, b: 1, a: 1 }, position: 0 }, { color: { r: 1, g: 0.8, b: 0, a: 1 }, position: 1 }] }] },
{ ...base, guid: id(4), parentIndex: { guid: id(1), position: '#' }, type: 'ROUNDED_RECTANGLE', name: 'Image', size: { x: 300, y: 200 }, transform: T(40, 250),
fillPaints: [{ type: 'IMAGE', opacity: 1, visible: true, blendMode: 'NORMAL', transform: T(0, 0), image: { hash: new Uint8Array(imgHash), name: 'image' }, imageScaleMode: 'FILL', imageShouldColorManage: false, rotation: 0, scale: 1 }] },
{ ...base, guid: id(5), parentIndex: { guid: id(1), position: '$' }, type: 'TEXT', name: 'Texte', size: { x: 400, y: 40 }, transform: T(380, 250), strokeAlign: 'OUTSIDE',
fillPaints: [solid(0.1, 0.1, 0.1)], fontSize: 32, fontName: { family: 'Roboto', style: 'Bold', postscript: 'Roboto-Bold' },
lineHeight: { value: 100, units: 'PERCENT' }, letterSpacing: { value: 0, units: 'PERCENT' }, textAlignHorizontal: 'LEFT', textAlignVertical: 'TOP', textAutoResize: 'WIDTH_AND_HEIGHT',
textData: { characters: 'Bonjour de XD → Figma' } },
{ ...base, guid: id(6), parentIndex: { guid: id(1), position: '%' }, type: 'VECTOR', name: 'Triangle', size: { x: 100, y: 100 }, transform: T(500, 40), fillPaints: [solid(0.2, 0.7, 0.3)],
...(withGeometry ? { fillGeometry: [{ windingRule: 'NONZERO', commandsBlob: 0, styleID: 0 }] } : {}) },
];
return { ...ref, message: { type: 'NODE_CHANGES', sessionID: 0, ackID: 0, nodeChanges: nodes, blobs } };
}
await pack('test2-minimal', minimal({ withGeometry: true }), { [imgHex]: imgBytes });
await pack('test3-sans-geometrie', minimal({ withGeometry: false }), { [imgHex]: imgBytes });
console.log('ok');