diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7f6961 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.xd +*.fig +!template/canvas.fig diff --git a/README.md b/README.md index 48a53c2..222240e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ # 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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fa5c02d --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f6f9b5 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/build.js b/src/build.js new file mode 100644 index 0000000..78669db --- /dev/null +++ b/src/build.js @@ -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 }; + } +} diff --git a/src/cli.js b/src/cli.js new file mode 100755 index 0000000..d037bf3 --- /dev/null +++ b/src/cli.js @@ -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(' ; ') : ''}`); +} diff --git a/src/convert.js b/src/convert.js new file mode 100644 index 0000000..338de03 --- /dev/null +++ b/src/convert.js @@ -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' }; +} diff --git a/src/figfile.js b/src/figfile.js new file mode 100644 index 0000000..79232ef --- /dev/null +++ b/src/figfile.js @@ -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 }; diff --git a/src/matrix.js b/src/matrix.js new file mode 100644 index 0000000..94c4c05 --- /dev/null +++ b/src/matrix.js @@ -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 }; +} diff --git a/src/path.js b/src/path.js new file mode 100644 index 0000000..54f3cd7 --- /dev/null +++ b/src/path.js @@ -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] }, + ] }]; +} diff --git a/src/xd.js b/src/xd.js new file mode 100644 index 0000000..5f7d655 --- /dev/null +++ b/src/xd.js @@ -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 }; +} diff --git a/template/canvas.fig b/template/canvas.fig new file mode 100644 index 0000000..63f9896 Binary files /dev/null and b/template/canvas.fig differ diff --git a/tools/inspect-fig.js b/tools/inspect-fig.js new file mode 100644 index 0000000..e35e3ec --- /dev/null +++ b/tools/inspect-fig.js @@ -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 ? `` : 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); diff --git a/tools/render.js b/tools/render.js new file mode 100644 index 0000000..72f12d9 --- /dev/null +++ b/tools/render.js @@ -0,0 +1,180 @@ +// Aperçu SVG d'un .fig généré (contrôle visuel sans Figma) : node tools/render.js fichier.fig dossierSortie +import fs from 'node:fs'; +import path from 'node:path'; +import JSZip from 'jszip'; +import { readCanvas } from '../src/figfile.js'; + +const [,, figPath, outDir] = process.argv; +fs.mkdirSync(`${outDir}/img`, { recursive: true }); +const zip = await JSZip.loadAsync(fs.readFileSync(figPath)); +const { message } = readCanvas(await zip.file('canvas.fig').async('uint8array')); +const nodes = message.nodeChanges, blobs = message.blobs; +const key = (g) => `${g.sessionID}:${g.localID}`; +const kids = new Map(); +for (const n of nodes) if (n.parentIndex) { + const k = key(n.parentIndex.guid); + if (!kids.has(k)) kids.set(k, []); + kids.get(k).push(n); +} +for (const l of kids.values()) l.sort((a, b) => a.parentIndex.position < b.parentIndex.position ? -1 : 1); +for (const f of Object.keys(zip.files)) if (f.startsWith('images/') && !zip.files[f].dir) { + const out = `${outDir}/img/${f.slice(7)}`; + if (!fs.existsSync(out)) fs.writeFileSync(out, await zip.file(f).async('nodebuffer')); +} + +let uid = 0; +const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +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 => ``).join(''); + const gt = mat(inv(p.transform)); + defs.push(p.type === 'GRADIENT_LINEAR' + ? `${stops}` + : `${stops}`); + 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 = ``; + } else { + // image normalisée = T * nœud normalisé → nœud = inv(T) * image + const it = inv(p.transform); + img = ``; + } + defs.push(`${img}`); + 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' + ? `` + : ``); + defs.push(`${parts.join('')}`); + 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 `` + + lines.map((l, i) => `${esc(l)}`).join('') + ''; +} + +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 += ``; } + for (const p of n.strokePaints || []) { + const s = paint(p, n, defs); + if (s) body += ``; + } + } + // 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(``); + continue; + } + const r = render(c, defs); + inner += clipId ? `${r}` : r; + } + if (n.type === 'FRAME' && !n.resizeToFit && !n.frameMaskDisabled && inner) { + const id = `c${uid++}`; + defs.push(``); + inner = `${inner}`; + } + return `${body}${inner}`; +} + +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 = `` + + render({ ...ab, transform: t }, defs).replace(/^/, '') + `${defs.join('')}`; + const name = ab.name.replace(/[^\w-]+/g, '_'); + fs.writeFileSync(`${outDir}/${name}.svg`, svg); + console.log(name); +} diff --git a/tools/test-fig.js b/tools/test-fig.js new file mode 100644 index 0000000..e108374 --- /dev/null +++ b/tools/test-fig.js @@ -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');