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