0
Fork 0
mirror of https://github.com/penpot/penpot-exporter-figma-plugin.git synced 2024-12-22 05:33:02 -05:00

Merge pull request #52 from penpot/dev

Plugin relaunch, initial setup, features and refactoring
This commit is contained in:
Jordi Sala Morales 2024-04-19 13:23:41 +02:00 committed by GitHub
commit 4cb8db5332
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
147 changed files with 20200 additions and 8959 deletions

8
.changeset/README.md Normal file
View file

@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View file

@ -0,0 +1,5 @@
---
"penpot-exporter": minor
---
Initial plugin release

11
.changeset/config.json Normal file
View file

@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
end_of_line = lf
indent_style = space
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_size = 2

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
ui-src/lib/penpot.js

25
.eslintrc.cjs Normal file
View file

@ -0,0 +1,25 @@
module.exports = {
root: true,
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module'
},
env: { browser: true, node: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'plugin:@figma/figma-plugins/recommended'
],
settings: {
react: {
version: 'detect'
}
},
rules: {
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }]
}
};

34
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main, dev]
pull_request:
jobs:
build:
name: Build ${{ matrix.node }}
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20.x']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- uses: bahmutov/npm-install@v1
- run: npm run build
lint:
name: Lint ${{ matrix.node }}
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20.x']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- uses: bahmutov/npm-install@v1
- run: npm run lint

31
.github/workflows/release.yaml vendored Normal file
View file

@ -0,0 +1,31 @@
name: Publish and release
on:
push:
branches: [main]
permissions:
contents: write
issues: write
pull-requests: write
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
publish:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20.x']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- uses: bahmutov/npm-install@v1
- uses: changesets/action@v1
with:
title: Release
publish: npx changeset tag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View file

@ -1,3 +1,2 @@
node_modules
package-lock.json
dist

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
20

5
.prettierignore Normal file
View file

@ -0,0 +1,5 @@
.changeset
node_modules
dist
ui-src/lib/penpot.js
LICENSE

View file

@ -1,39 +1,40 @@
# Developer guide
The plugin relies in [penpot.js](https://github.com/penpot/penpot-exporter-figma-plugin/blob/main/src/penpot.js)
library. It contains a subset of Penpot frontend app, transpiled into
javascript to be used from js code (this was easy since the frontend is written
in ClojureScript, that has direct translation to javascript).
The plugin relies in
[penpot.js](https://github.com/penpot/penpot-exporter-figma-plugin/blob/main/src/penpot.js) library.
It contains a subset of Penpot frontend app, transpiled into javascript to be used from js code
(this was easy since the frontend is written in ClojureScript, that has direct translation to
javascript).
Basically, it exports the `createFile` function and the `File` data type, that
represents a Penpot file as it resides in memory inside the frontend app. It
has function to create pages and their content, and also an `export` function
that generates and downloads a .zip archive with the Penpot file as svg
documents in Penpot annotated format, that you can import directly into Penpot.
Basically, it exports the `createFile` function and the `File` data type, that represents a Penpot
file as it resides in memory inside the frontend app. It has function to create pages and their
content, and also an `export` function that generates and downloads a .zip archive with the Penpot
file as svg documents in Penpot annotated format, that you can import directly into Penpot.
You can see the [source of the library at Penpot repo](https://github.com/penpot/penpot/tree/develop/frontend/src/app/libs).
You can see the
[source of the library at Penpot repo](https://github.com/penpot/penpot/tree/develop/frontend/src/app/libs).
To see a general description of the data types used in the functions you can
see [the data model](https://help.penpot.app/technical-guide/data-model/).
Their full specifications are in the [common types module](https://github.com/penpot/penpot/tree/develop/common/src/app/common/types).
To see a general description of the data types used in the functions you can see
[the data model](https://help.penpot.app/technical-guide/data-model/). Their full specifications are
in the
[common types module](https://github.com/penpot/penpot/tree/develop/common/src/app/common/types).
Those types are defined in [Clojure spec](https://clojure.org/guides/spec) format.
For those unfamiliar with the syntax, here is a small basic guide:
Those types are defined in [Clojure spec](https://clojure.org/guides/spec) format. For those
unfamiliar with the syntax, here is a small basic guide:
```clojure
(s/def ::name string?)
(s/def ::id uuid?)
```
A parameter or attribute called `name` is of type string, and `id` is an *uuid*
string (e.g. "000498f3-27fc-8000-8988-ca7d52f46843").
A parameter or attribute called `name` is of type string, and `id` is an _uuid_ string (e.g.
"000498f3-27fc-8000-8988-ca7d52f46843").
```clojure
(s/def ::stroke-alignment #{:center :inner :outer})
```
`stroke-alignment` is of an enumerated type, and the valid values are "center",
"inner" and "outer".
`stroke-alignment` is of an enumerated type, and the valid values are "center", "inner" and "outer".
```clojure
(ns app.common.types.shape
@ -44,17 +45,17 @@ string (e.g. "000498f3-27fc-8000-8988-ca7d52f46843").
(s/def ::line-height ::us/safe-number)
```
`line-height` is of the type `safe-number`, defined in `app.common.spec`
namespace, that here is imported with the name `us` (a *safe number* is a
integer or floating point number, with a value not too big or small).
`line-height` is of the type `safe-number`, defined in `app.common.spec` namespace, that here is
imported with the name `us` (a _safe number_ is a integer or floating point number, with a value not
too big or small).
```clojure
(s/def ::page
(s/keys :req-un [::id ::name ::objects ::options]))
```
`page` is an object with four required arguments: `id`, `name`, `objects` and
`options`, whose types have to be defined above.
`page` is an object with four required arguments: `id`, `name`, `objects` and `options`, whose types
have to be defined above.
```clojure
(s/def ::column
@ -66,8 +67,8 @@ integer or floating point number, with a value not too big or small).
::gutter]))
```
`column` has one required attribute `color` and five optional ones `size`,
`type`, `item-length`, `margin` and `gutter`.
`column` has one required attribute `color` and five optional ones `size`, `type`, `item-length`,
`margin` and `gutter`.
```clojure
(s/def ::children
@ -76,8 +77,8 @@ integer or floating point number, with a value not too big or small).
:min-count 1))
```
`children` is a collection (implemented as a vector) of objects of type
`content`, and must have a minimun lenght of 1.
`children` is a collection (implemented as a vector) of objects of type `content`, and must have a
minimun lenght of 1.
```clojure
(defmulti animation-spec :animation-type)
@ -102,11 +103,9 @@ integer or floating point number, with a value not too big or small).
(s/multi-spec animation-spec ::animation-type))
```
This is probably the most complex construct. `animation` is a multi schema
object. It has an attribute called `animation-type` and the rest of the fields
depend on its value. For example, if `animation-type` is "dissolve", the object
must also have a `duration` and an `easing` attribute.
Other constructs should be more or less auto explicative with this guide
and the clojure.spec manual linked above.
This is probably the most complex construct. `animation` is a multi schema object. It has an
attribute called `animation-type` and the rest of the fields depend on its value. For example, if
`animation-type` is "dissolve", the object must also have a `duration` and an `easing` attribute.
Other constructs should be more or less auto explicative with this guide and the clojure.spec manual
linked above.

123
README.md
View file

@ -1,10 +1,9 @@
[uri_license]: https://www.mozilla.org/en-US/MPL/2.0
[uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg
<h1 align="center">
<br>
<img style="width:100px" src="src/logo.svg" alt="PENPOT">
<img style="width:100px" src="ui-src/logo.svg" alt="PENPOT">
<br>
PENPOT EXPORTER
</h1>
@ -25,9 +24,10 @@
![](penpotexporter.gif)
This is a **very early-stage** Figma plugin to export Figma files to Penpot format. For now is little more than a proof of concept, or a first scaffolding, not a fully functional exporter.
This is a **very early-stage** Figma plugin to export Figma files to Penpot format. For now is
little more than a proof of concept, or a first scaffolding, not a fully functional exporter.
## Table of contents ##
## Table of contents
- [Table of contents](#table-of-contents)
- [Why a Penpot exporter](#why-a-penpot-exporter)
@ -42,70 +42,97 @@ This is a **very early-stage** Figma plugin to export Figma files to Penpot form
- [Contributing](#contributing)
- [License](#license)
## Why a Penpot exporter ##
## Why a Penpot exporter
The aim of this plugin is to help people migrate their files from Figma to [Penpot](https://penpot.app/). Migrating work from one design tool to another was never an easy task due to the abundance of closed and non-standard formats, and this is not a different case. Our approach to better solve this situation is to release a code skeleton for the minimum version of a Figma plugin that can convert a Figma file into a Penpot annotated SVG file.
The aim of this plugin is to help people migrate their files from Figma to
[Penpot](https://penpot.app/). Migrating work from one design tool to another was never an easy task
due to the abundance of closed and non-standard formats, and this is not a different case. Our
approach to better solve this situation is to release a code skeleton for the minimum version of a
Figma plugin that can convert a Figma file into a Penpot annotated SVG file.
There is a sense of urgency for this capability because there is a feeling that Adobe might force Figma to limit exports and interoperability via plugins very soon.
There is a sense of urgency for this capability because there is a feeling that Adobe might force
Figma to limit exports and interoperability via plugins very soon.
## Getting started
## Getting started ##
This plugin makes use of npm, webpack and react, and is written on TypeScript. It also includes a Penpot file builder library.
This plugin makes use of npm, webpack and react, and is written on TypeScript. It also includes a
Penpot file builder library.
### Pre-requisites
To use this plugin, you will need to have `node` and `npm` installed on your computer. If you don't already have these, you can download and install them from the official website ([https://nodejs.org/en/](https://nodejs.org/en/)).
To use this plugin, you will need to have `node` and `npm` installed on your computer. If you don't
already have these, you can download and install them from the official website
([https://nodejs.org/en/](https://nodejs.org/en/)).
Once you have `node` and `npm` installed, you will need to download the source code for this plugin.
You can do this by clicking the "Clone or download" button on the GitHub page for this project and
then selecting "Download ZIP". Extract the ZIP file to a location on your computer.
Once you have `node` and `npm` installed, you will need to download the source code for this plugin. You can do this by clicking the "Clone or download" button on the GitHub page for this project and then selecting "Download ZIP". Extract the ZIP file to a location on your computer.
### Building
#### For Windows users:
1. Open the terminal by searching for "Command Prompt" in the start menu.
2. Use the `cd` command to navigate to the folder where the repository has been extracted. For example, if the repository is located in the `Downloads` folder, you can use the following command: `cd Downloads/penpot-exporter-figma-plugin`.
3. Once you are in the correct folder, you can run the `npm install` command to install the dependencies, and then the `npm run build` command to build the plugin.
2. Use the `cd` command to navigate to the folder where the repository has been extracted. For
example, if the repository is located in the `Downloads` folder, you can use the following
command: `cd Downloads/penpot-exporter-figma-plugin`.
3. Once you are in the correct folder, you can run the `npm install` command to install the
dependencies, and then the `npm run build` command to build the plugin.
#### For Mac users:
1. Open the terminal by searching for "Terminal" in the Launchpad or by using the `Command + Space` keyboard shortcut and searching for "Terminal".
2. Use the `cd` command to navigate to the folder where the repository has been extracted. For example, if the repository is located in the `Downloads` folder, you can use the following command: `cd Downloads/penpot-exporter-figma-plugin`.
3. Once you are in the correct folder, you can run the `npm install` command to install the dependencies, and then the `npm run build` command to build the plugin.
1. Open the terminal by searching for "Terminal" in the Launchpad or by using the `Command + Space`
keyboard shortcut and searching for "Terminal".
2. Use the `cd` command to navigate to the folder where the repository has been extracted. For
example, if the repository is located in the `Downloads` folder, you can use the following
command: `cd Downloads/penpot-exporter-figma-plugin`.
3. Once you are in the correct folder, you can run the `npm install` command to install the
dependencies, and then the `npm run build` command to build the plugin.
#### For Linux users:
1. Open the terminal by using the `Ctrl + Alt + T` keyboard shortcut.
2. Use the `cd` command to navigate to the folder where the repository has been extracted. For example, if the repository is located in the `Downloads` folder, you can use the following command: `cd Downloads/penpot-exporter-figma-plugin`.
3. Once you are in the correct folder, you can run the `npm install` command to install the dependencies, and then the `npm run build` command to build the plugin.
2. Use the `cd` command to navigate to the folder where the repository has been extracted. For
example, if the repository is located in the `Downloads` folder, you can use the following
command: `cd Downloads/penpot-exporter-figma-plugin`.
3. Once you are in the correct folder, you can run the `npm install` command to install the
dependencies, and then the `npm run build` command to build the plugin.
### Add to Figma
`Figma menu` > `Plugins` > `Development` > `Import plugin from manifest…`
To add the plugin to Figma, open Figma and go to the `Plugins` menu. Select `Development` and then choose `Import plugin from manifest…`.
`Figma menu` > `Plugins` > `Development` > `Import plugin from manifest…` To add the plugin to
Figma, open Figma and go to the `Plugins` menu. Select `Development` and then choose
`Import plugin from manifest…`.
<img src="resources/Import plugin from manifest.png" alt='Screenshot of the Plugins > Development menus open showing the, "Import plugin from manifest" option.'>
Select the `manifest.json` file that is located in the folder where you extracted the source code for the plugin.
Select the `manifest.json` file that is located in the folder where you extracted the source code
for the plugin.
### To use the plugin
1. Select what you want to export
2. `Figma menu` > `Plugins` > `Development` > `Penpot Exporter`
go to the `Plugins` menu in Figma and select `Development` followed by `Penpot Exporter`.
2. `Figma menu` > `Plugins` > `Development` > `Penpot Exporter` go to the `Plugins` menu in Figma
and select `Development` followed by `Penpot Exporter`.
3. This will generate a .zip file that you can import into Penpot.
## Call to the community
## Call to the community ##
Answering to the interest expressed by community members to build the plugin by themselves, at the Penpot team we decided to help solve the need without having to depend on our current product priorities. That is why we have published this bare minimum version of the plugin, unsatisfactory in itself, but it unlocks the possibility for others to continue the task.
Answering to the interest expressed by community members to build the plugin by themselves, at the
Penpot team we decided to help solve the need without having to depend on our current product
priorities. That is why we have published this bare minimum version of the plugin, unsatisfactory in
itself, but it unlocks the possibility for others to continue the task.
Yes, we are asking for help. 🤗
We have explained this approach in a [community post](https://community.penpot.app/t/figma-file-importer/1684). Feel free to join the conversation there.
We have explained this approach in a
[community post](https://community.penpot.app/t/figma-file-importer/1684). Feel free to join the
conversation there.
## What can this plugin currently import? ##
## What can this plugin currently import?
As mentioned above, this plugin gets you to a starting point. Things that are currently included in the import are:
As mentioned above, this plugin gets you to a starting point. Things that are currently included in
the import are:
- **Basic shapes** (rectangles, ellipses).
- **Frames** (Boards in Penpot).
@ -114,20 +141,27 @@ As mentioned above, this plugin gets you to a starting point. Things that are cu
- **Texts** (basic support. Only fonts available on Google fonts).
- **Images** (basic support)
## Limitations ##
The obvious limitations are the features that are in Figma but not in Penpot or work differently in both tools so they can not be easily converted. We leave some comments below about the ones that are commonly considered more important:
## Limitations
- **Autolayout**: Not in Penpot yet but in a very advanced state of development. There will be news soon.
- **Components**: Currently very different from their counterparts at Figma. However, Penpot components are under a rework that we expect will make the conversion easier.
- **Variants**: Not expected in the short term. Also, we are thinking of different solutions to solve component states, things that eventually could make it difficult to import.
The obvious limitations are the features that are in Figma but not in Penpot or work differently in
both tools so they can not be easily converted. We leave some comments below about the ones that are
commonly considered more important:
- **Autolayout**: Not in Penpot yet but in a very advanced state of development. There will be news
soon.
- **Components**: Currently very different from their counterparts at Figma. However, Penpot
components are under a rework that we expect will make the conversion easier.
- **Variants**: Not expected in the short term. Also, we are thinking of different solutions to
solve component states, things that eventually could make it difficult to import.
## Contributing
## Contributing ##
If you want to make many people very happy and help us build this code skeleton for the minimum version of the Figma plugin, a further effort will be needed to have a satisfactory import experience.
If you want to make many people very happy and help us build this code skeleton for the minimum
version of the Figma plugin, a further effort will be needed to have a satisfactory import
experience.
For instance, it will be interesting to add:
- Strokes
- Fills with radial gradients
- Paths
@ -137,10 +171,11 @@ For instance, it will be interesting to add:
- Constraints
- ...
Motivated to contribute? Take a look at our [Contributing Guide](https://help.penpot.app/contributing-guide/) that explains our guidelines (they're for the Penpot Core, but are mostly of application here too).
Motivated to contribute? Take a look at our
[Contributing Guide](https://help.penpot.app/contributing-guide/) that explains our guidelines
(they're for the Penpot Core, but are mostly of application here too).
## License ##
## License
```
This Source Code Form is subject to the terms of the Mozilla Public
@ -149,4 +184,6 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/.
Copyright (c) KALEIDOS INC
```
Penpot and the Penpot exporter plugin are Kaleidos [open source projects](https://kaleidos.net/products)
Penpot and the Penpot exporter plugin are Kaleidos
[open source projects](https://kaleidos.net/products)

View file

@ -1,8 +1,10 @@
{
"name": "Penpot Exporter",
"id": "",
"id": "1362760353975083275",
"api": "1.0.0",
"main": "dist/code.js",
"ui": "dist/ui.html",
"editorType": ["figma", "figjam"]
"ui": "dist/index.html",
"editorType": ["figma"],
"networkAccess": { "allowedDomains": ["none"] },
"documentAccess": "dynamic-page"
}

9109
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,37 +1,59 @@
{
"name": "penpot-exporter",
"id": "1161652283781700708",
"version": "0.0.1",
"version": "0.0.0",
"description": "Penpot exporter",
"main": "code.js",
"type": "module",
"scripts": {
"build": "webpack",
"watch": "webpack watch"
"build": "npm run build:ui && npm run build:main -- --minify",
"build:main": "esbuild plugin-src/code.ts --bundle --outfile=dist/code.js --target=es2016",
"build:ui": "vite build --minify esbuild --emptyOutDir=false",
"build:watch": "concurrently -n widget,iframe \"npm run build:main -- --watch\" \"npm run build:ui -- --watch\"",
"dev": "concurrently -n tsc,build,vite 'npm:tsc:watch' 'npm:build:watch' 'vite'",
"lint": "concurrently \"npm:lint:*\"",
"lint:eslint": "eslint .",
"lint:stylelint": "stylelint ui-src/**.css",
"lint:prettier": "prettier --check .",
"lint:tsc-ui": "tsc -p ui-src/tsconfig.json --noEmit --pretty false",
"lint:tsc-plugin": "tsc -p plugin-src/tsconfig.json --noEmit --pretty false",
"fix-lint": "concurrently \"npm:fix-lint:*\"",
"fix-lint:eslint": "eslint . --fix",
"fix-lint:stylelint": "stylelint ui-src/**.css --fix",
"fix-lint:prettier": "prettier --write ."
},
"author": "Kaleidos",
"license": "MPL2.0",
"devDependencies": {
"@figma/plugin-typings": "*",
"@types/node": "^16.11.64",
"css-loader": "^6.2.0",
"html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^5.3.2",
"style-loader": "^3.2.1",
"ts-loader": "^9.2.5",
"typescript": "^4.3.5",
"url-loader": "^4.1.1",
"webpack": "^5.51.1",
"webpack-cli": "^4.8.0"
},
"dependencies": {
"@figma-plugin/helpers": "^0.15.2",
"@types/react": "^17.0.19",
"@types/react-dom": "^17.0.9",
"crypto": "^1.0.1",
"crypto-browserify": "^3.12.0",
"react": "^17.0.2",
"react-dev-utils": "^12.0.1",
"react-dom": "^17.0.2",
"slugify": "^1.6.5"
"react": "^18.2",
"react-dom": "^18.2",
"slugify": "^1.6",
"svg-path-parser": "^1.1"
},
"devDependencies": {
"@changesets/changelog-github": "^0.5",
"@changesets/cli": "^2.27",
"@figma/eslint-plugin-figma-plugins": "^0.15",
"@figma/plugin-typings": "^1.90",
"@trivago/prettier-plugin-sort-imports": "^4.3",
"@types/react": "^18.2",
"@types/react-dom": "^18.2",
"@typescript-eslint/eslint-plugin": "^7.5",
"@typescript-eslint/parser": "^7.5",
"@types/svg-path-parser": "^1.1",
"@vitejs/plugin-react-swc": "^3.6",
"concurrently": "^8.2",
"esbuild": "^0.20",
"eslint": "^8.57",
"eslint-config-prettier": "^9.1",
"eslint-plugin-prettier": "^5.1",
"eslint-plugin-react": "^7.34",
"prettier": "^3.2",
"stylelint": "^16.3",
"stylelint-config-standard": "^36.0",
"tsconfig-paths-webpack-plugin": "^4.1",
"typescript": "^5.4",
"vite": "^5.2",
"vite-plugin-singlefile": "^2.0",
"vite-plugin-svgr": "^4.2",
"vite-tsconfig-paths": "^4.3"
}
}

19
plugin-src/code.ts Normal file
View file

@ -0,0 +1,19 @@
import {
handleCancelMessage,
handleExportMessage,
handleResizeMessage
} from '@plugin/messageHandlers';
figma.showUI(__html__, { themeColors: true, height: 200, width: 300 });
figma.ui.onmessage = async msg => {
if (msg.type === 'export') {
await handleExportMessage();
}
if (msg.type === 'cancel') {
handleCancelMessage();
}
if (msg.type === 'resize') {
handleResizeMessage(msg.width, msg.height);
}
};

View file

@ -0,0 +1,3 @@
export function handleCancelMessage() {
figma.closePlugin();
}

View file

@ -0,0 +1,8 @@
import { transformDocumentNode } from '@plugin/transformers';
export async function handleExportMessage() {
await figma.loadAllPagesAsync();
const penpotNode = await transformDocumentNode(figma.root);
figma.ui.postMessage({ type: 'FIGMAFILE', data: penpotNode });
}

View file

@ -0,0 +1,3 @@
export function handleResizeMessage(width: number, height: number) {
figma.ui.resize(width, height);
}

View file

@ -0,0 +1,3 @@
export * from './handleCancelMessage';
export * from './handleExportMessage';
export * from './handleResizeMessage';

View file

@ -0,0 +1,10 @@
export * from './transformDocumentNode';
export * from './transformEllipseNode';
export * from './transformFrameNode';
export * from './transformGroupNode';
export * from './transformImageNode';
export * from './transformPageNode';
export * from './transformPathNode';
export * from './transformRectangleNode';
export * from './transformSceneNode';
export * from './transformTextNode';

View file

@ -0,0 +1,9 @@
export * from './transformBlend';
export * from './transformChildren';
export * from './transformDimensionAndPosition';
export * from './transformFills';
export * from './transformProportion';
export * from './transformSceneNode';
export * from './transformStrokes';
export * from './transformTextStyle';
export * from './transformVectorPaths';

View file

@ -0,0 +1,12 @@
import { translateBlendMode } from '@plugin/translators';
import { ShapeAttributes } from '@ui/lib/types/shape/shapeAttributes';
export const transformBlend = (
node: SceneNodeMixin & MinimalBlendMixin
): Partial<ShapeAttributes> => {
return {
blendMode: translateBlendMode(node.blendMode),
opacity: !node.visible ? 0 : node.opacity // @TODO: check this. If we use the property hidden and it's hidden, it won't export
};
};

View file

@ -0,0 +1,16 @@
import { transformSceneNode } from '@plugin/transformers';
import { PenpotNode } from '@ui/lib/types/penpotNode';
import { Children } from '@ui/lib/types/utils/children';
export const transformChildren = async (
node: ChildrenMixin,
baseX: number = 0,
baseY: number = 0
): Promise<Children> => {
return {
children: (
await Promise.all(node.children.map(child => transformSceneNode(child, baseX, baseY)))
).filter((child): child is PenpotNode => !!child)
};
};

View file

@ -0,0 +1,14 @@
import { ShapeGeomAttributes } from '@ui/lib/types/shape/shapeGeomAttributes';
export const transformDimensionAndPosition = (
node: DimensionAndPositionMixin,
baseX: number,
baseY: number
): ShapeGeomAttributes => {
return {
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height
};
};

View file

@ -0,0 +1,11 @@
import { translateFills } from '@plugin/translators';
import { ShapeAttributes } from '@ui/lib/types/shape/shapeAttributes';
export const transformFills = (
node: MinimalFillsMixin & DimensionAndPositionMixin
): Partial<ShapeAttributes> => {
return {
fills: translateFills(node.fills, node.width, node.height)
};
};

View file

@ -0,0 +1,7 @@
import { ShapeAttributes } from '@ui/lib/types/shape/shapeAttributes';
export const transformProportion = (node: LayoutMixin): Partial<ShapeAttributes> => {
return {
proportionLock: node.constrainProportions
};
};

View file

@ -0,0 +1,8 @@
import { ShapeAttributes } from '@ui/lib/types/shape/shapeAttributes';
export const transformSceneNode = (node: SceneNodeMixin): Partial<ShapeAttributes> => {
return {
blocked: node.locked,
hidden: false // @TODO: check this. it won't export if we hide it
};
};

View file

@ -0,0 +1,30 @@
import { translateStrokes } from '@plugin/translators';
import { ShapeAttributes } from '@ui/lib/types/shape/shapeAttributes';
const isVectorLike = (node: GeometryMixin | VectorLikeMixin): node is VectorLikeMixin => {
return 'vectorNetwork' in node;
};
const isIndividualStrokes = (
node: GeometryMixin | IndividualStrokesMixin
): node is IndividualStrokesMixin => {
return 'strokeTopWeight' in node;
};
const hasFillGeometry = (node: GeometryMixin): boolean => {
return node.fillGeometry.length > 0;
};
export const transformStrokes = (
node: GeometryMixin | (GeometryMixin & IndividualStrokesMixin)
): Partial<ShapeAttributes> => {
return {
strokes: translateStrokes(
node,
hasFillGeometry(node),
isVectorLike(node) ? node.vectorNetwork : undefined,
isIndividualStrokes(node) ? node : undefined
)
};
};

View file

@ -0,0 +1,29 @@
import { translateTextDecoration, translateTextTransform } from '@plugin/translators';
import { TextStyle } from '@ui/lib/types/text/textContent';
export const transformTextStyle = (
node: Pick<
StyledTextSegment,
| 'characters'
| 'start'
| 'end'
| 'fontName'
| 'fontSize'
| 'fontWeight'
| 'lineHeight'
| 'letterSpacing'
| 'textCase'
| 'textDecoration'
| 'fills'
>
): Partial<TextStyle> => {
return {
fontFamily: node.fontName.family,
fontSize: node.fontSize.toString(),
fontStyle: node.fontName.style,
fontWeight: node.fontWeight.toString(),
textDecoration: translateTextDecoration(node),
textTransform: translateTextTransform(node)
};
};

View file

@ -0,0 +1,28 @@
import { translateVectorPaths } from '@plugin/translators';
import { PathAttributes } from '@ui/lib/types/path/pathAttributes';
const getVectorPaths = (node: VectorNode | StarNode | LineNode | PolygonNode): VectorPaths => {
switch (node.type) {
case 'STAR':
case 'POLYGON':
return node.fillGeometry;
case 'VECTOR':
return node.vectorPaths;
case 'LINE':
return node.strokeGeometry;
}
};
export const transformVectorPaths = (
node: VectorNode | StarNode | LineNode | PolygonNode,
baseX: number,
baseY: number
): PathAttributes => {
const vectorPaths = getVectorPaths(node);
return {
type: 'path',
content: translateVectorPaths(vectorPaths, baseX + node.x, baseY + node.y)
};
};

View file

@ -0,0 +1,10 @@
import { PenpotDocument } from '@ui/lib/types/penpotDocument';
import { transformPageNode } from '.';
export const transformDocumentNode = async (node: DocumentNode): Promise<PenpotDocument> => {
return {
name: node.name,
children: await Promise.all(node.children.map(child => transformPageNode(child)))
};
};

View file

@ -0,0 +1,27 @@
import {
transformBlend,
transformDimensionAndPosition,
transformFills,
transformProportion,
transformSceneNode,
transformStrokes
} from '@plugin/transformers/partials';
import { CircleShape } from '@ui/lib/types/circle/circleShape';
export const transformEllipseNode = (
node: EllipseNode,
baseX: number,
baseY: number
): CircleShape => {
return {
type: 'circle',
name: node.name,
...transformFills(node),
...transformStrokes(node),
...transformDimensionAndPosition(node, baseX, baseY),
...transformSceneNode(node),
...transformBlend(node),
...transformProportion(node)
};
};

View file

@ -0,0 +1,41 @@
import {
transformBlend,
transformChildren,
transformDimensionAndPosition,
transformFills,
transformProportion,
transformSceneNode,
transformStrokes
} from '@plugin/transformers/partials';
import { FrameShape } from '@ui/lib/types/frame/frameShape';
const isSectionNode = (node: FrameNode | SectionNode): node is SectionNode => {
return node.type === 'SECTION';
};
export const transformFrameNode = async (
node: FrameNode | SectionNode,
baseX: number,
baseY: number
): Promise<FrameShape> => {
return {
type: 'frame',
name: node.name,
showContent: isSectionNode(node) ? true : !node.clipsContent,
...transformFills(node),
// Figma API does not expose strokes for sections,
// they plan to add it in the future. Refactor this when available.
// @see: https://forum.figma.com/t/why-are-strokes-not-available-on-section-nodes/41658
...(isSectionNode(node) ? [] : transformStrokes(node)),
...(await transformChildren(node, baseX + node.x, baseY + node.y)),
...transformDimensionAndPosition(node, baseX, baseY),
// Figma API does not expose blend modes for sections,
// they plan to add it in the future. Refactor this when available.
// @see: https://forum.figma.com/t/add-a-blendmode-property-for-sectionnode/58560
...(isSectionNode(node) ? [] : transformBlend(node)),
...transformSceneNode(node),
// Figma API does not expose constraints proportions for sections
...(isSectionNode(node) ? [] : transformProportion(node))
};
};

View file

@ -0,0 +1,23 @@
import {
transformBlend,
transformDimensionAndPosition,
transformSceneNode
} from '@plugin/transformers/partials';
import { transformChildren } from '@plugin/transformers/partials';
import { GroupShape } from '@ui/lib/types/group/groupShape';
export const transformGroupNode = async (
node: GroupNode,
baseX: number,
baseY: number
): Promise<GroupShape> => {
return {
type: 'group',
name: node.name,
...(await transformChildren(node, baseX, baseY)),
...transformDimensionAndPosition(node, baseX, baseY),
...transformSceneNode(node),
...transformBlend(node)
};
};

View file

@ -0,0 +1,28 @@
import { transformDimensionAndPosition } from '@plugin/transformers/partials';
import { detectMimeType } from '@plugin/utils';
import { ImageShape } from '@ui/lib/types/image/imageShape';
export const transformImageNode = async (
node: SceneNode,
baseX: number,
baseY: number
): Promise<ImageShape> => {
let dataUri = '';
if ('exportAsync' in node) {
const value = await node.exportAsync({ format: 'PNG' });
const b64 = figma.base64Encode(value);
dataUri = 'data:' + detectMimeType(b64) + ';base64,' + b64;
}
return {
type: 'image',
name: node.name,
metadata: {
width: node.width,
height: node.height
},
dataUri: dataUri,
...transformDimensionAndPosition(node, baseX, baseY)
};
};

View file

@ -0,0 +1,10 @@
import { transformChildren } from '@plugin/transformers/partials';
import { PenpotPage } from '@ui/lib/types/penpotPage';
export const transformPageNode = async (node: PageNode): Promise<PenpotPage> => {
return {
name: node.name,
...(await transformChildren(node))
};
};

View file

@ -0,0 +1,32 @@
import {
transformBlend,
transformDimensionAndPosition,
transformFills,
transformProportion,
transformSceneNode,
transformStrokes,
transformVectorPaths
} from '@plugin/transformers/partials';
import { PathShape } from '@ui/lib/types/path/pathShape';
const hasFillGeometry = (node: VectorNode | StarNode | LineNode | PolygonNode): boolean => {
return 'fillGeometry' in node && node.fillGeometry.length > 0;
};
export const transformPathNode = (
node: VectorNode | StarNode | LineNode | PolygonNode,
baseX: number,
baseY: number
): PathShape => {
return {
name: node.name,
...(hasFillGeometry(node) ? transformFills(node) : []),
...transformStrokes(node),
...transformVectorPaths(node, baseX, baseY),
...transformDimensionAndPosition(node, baseX, baseY),
...transformSceneNode(node),
...transformBlend(node),
...transformProportion(node)
};
};

View file

@ -0,0 +1,27 @@
import {
transformBlend,
transformDimensionAndPosition,
transformFills,
transformProportion,
transformSceneNode,
transformStrokes
} from '@plugin/transformers/partials';
import { RectShape } from '@ui/lib/types/rect/rectShape';
export const transformRectangleNode = (
node: RectangleNode,
baseX: number,
baseY: number
): RectShape => {
return {
type: 'rect',
name: node.name,
...transformFills(node),
...transformStrokes(node),
...transformDimensionAndPosition(node, baseX, baseY),
...transformSceneNode(node),
...transformBlend(node),
...transformProportion(node)
};
};

View file

@ -0,0 +1,53 @@
import { calculateAdjustment } from '@plugin/utils';
import { PenpotNode } from '@ui/lib/types/penpotNode';
import {
transformEllipseNode,
transformFrameNode,
transformGroupNode,
transformImageNode,
transformPathNode,
transformRectangleNode,
transformTextNode
} from '.';
export const transformSceneNode = async (
node: SceneNode,
baseX: number = 0,
baseY: number = 0
): Promise<PenpotNode | undefined> => {
// @TODO: when penpot 2.0, manage image as fills for the basic types
if (
'fills' in node &&
node.fills !== figma.mixed &&
node.fills.find(fill => fill.type === 'IMAGE')
) {
// If the nested frames extended the bounds of the rasterized image, we need to
// account for this both in position on the canvas and the calculated width and
// height of the image.
const [adjustedX, adjustedY] = calculateAdjustment(node);
return await transformImageNode(node, baseX + adjustedX, baseY + adjustedY);
}
switch (node.type) {
case 'RECTANGLE':
return transformRectangleNode(node, baseX, baseY);
case 'ELLIPSE':
return transformEllipseNode(node, baseX, baseY);
case 'SECTION':
case 'FRAME':
return await transformFrameNode(node, baseX, baseY);
case 'GROUP':
return await transformGroupNode(node, baseX, baseY);
case 'TEXT':
return transformTextNode(node, baseX, baseY);
case 'STAR':
case 'POLYGON':
case 'VECTOR':
case 'LINE':
return transformPathNode(node, baseX, baseY);
}
console.error(`Unsupported node type: ${node.type}`);
};

View file

@ -0,0 +1,49 @@
import {
transformBlend,
transformDimensionAndPosition,
transformFills,
transformProportion,
transformSceneNode,
transformTextStyle
} from '@plugin/transformers/partials';
import { translateStyledTextSegments } from '@plugin/translators';
import { TextShape } from '@ui/lib/types/text/textShape';
export const transformTextNode = (node: TextNode, baseX: number, baseY: number): TextShape => {
const styledTextSegments = node.getStyledTextSegments([
'fontName',
'fontSize',
'fontWeight',
'lineHeight',
'letterSpacing',
'textCase',
'textDecoration',
'fills'
]);
return {
type: 'text',
name: node.name,
content: {
type: 'root',
children: [
{
type: 'paragraph-set',
children: [
{
type: 'paragraph',
children: translateStyledTextSegments(styledTextSegments, node.width, node.height),
...(styledTextSegments.length ? transformTextStyle(styledTextSegments[0]) : {}),
...transformFills(node)
}
]
}
]
},
...transformDimensionAndPosition(node, baseX, baseY),
...transformSceneNode(node),
...transformBlend(node),
...transformProportion(node)
};
};

View file

@ -0,0 +1,7 @@
export * from './translateBlendMode';
export * from './translateFills';
export * from './translateStrokes';
export * from './translateStyledTextSegments';
export * from './translateTextDecoration';
export * from './translateTextTransform';
export * from './translateVectorPaths';

View file

@ -0,0 +1,46 @@
import { BlendMode as PenpotBlendMode } from '@ui/lib/types/utils/blendModes';
export const translateBlendMode = (blendMode: BlendMode): PenpotBlendMode => {
switch (blendMode) {
//@TODO: is not translatable in penpot, this is the closest one
case 'PASS_THROUGH':
case 'NORMAL':
return 'normal';
//@TODO: is not translatable in penpot, this is the closest one
case 'LINEAR_BURN':
case 'DARKEN':
return 'darken';
case 'MULTIPLY':
return 'multiply';
case 'COLOR_BURN':
return 'color-burn';
case 'LIGHTEN':
return 'lighten';
case 'SCREEN':
return 'screen';
//@TODO: is not translatable in penpot, this is the closest one
case 'LINEAR_DODGE':
case 'COLOR_DODGE':
return 'color-dodge';
case 'OVERLAY':
return 'overlay';
case 'SOFT_LIGHT':
return 'soft-light';
case 'HARD_LIGHT':
return 'hard-light';
case 'DIFFERENCE':
return 'difference';
case 'EXCLUSION':
return 'exclusion';
case 'HUE':
return 'hue';
case 'SATURATION':
return 'saturation';
case 'COLOR':
return 'color';
case 'LUMINOSITY':
return 'luminosity';
default:
return 'normal';
}
};

View file

@ -0,0 +1,69 @@
import { rgbToHex } from '@plugin/utils';
import { calculateLinearGradient } from '@plugin/utils/calculateLinearGradient';
import { Fill } from '@ui/lib/types/utils/fill';
export const translateFill = (fill: Paint, width: number, height: number): Fill | undefined => {
switch (fill.type) {
case 'SOLID':
return translateSolidFill(fill);
case 'GRADIENT_LINEAR':
return translateGradientLinearFill(fill, width, height);
}
console.error(`Unsupported fill type: ${fill.type}`);
};
export const translateFills = (
fills: readonly Paint[] | typeof figma.mixed,
width: number,
height: number
): Fill[] => {
const figmaFills = fills === figma.mixed ? [] : fills;
const penpotFills: Fill[] = [];
for (const fill of figmaFills) {
const penpotFill = translateFill(fill, width, height);
if (penpotFill) {
// colors are applied in reverse order in Figma, that's why we unshift
penpotFills.unshift(penpotFill);
}
}
return penpotFills;
};
const translateSolidFill = (fill: SolidPaint): Fill => {
return {
fillColor: rgbToHex(fill.color),
fillOpacity: !fill.visible ? 0 : fill.opacity
};
};
const translateGradientLinearFill = (fill: GradientPaint, width: number, height: number): Fill => {
const points = calculateLinearGradient(width, height, fill.gradientTransform);
return {
fillColorGradient: {
type: 'linear',
startX: points.start[0] / width,
startY: points.start[1] / height,
endX: points.end[0] / width,
endY: points.end[1] / height,
width: 1,
stops: [
{
color: rgbToHex(fill.gradientStops[0].color),
offset: fill.gradientStops[0].position,
opacity: fill.gradientStops[0].color.a * (fill.opacity ?? 1)
},
{
color: rgbToHex(fill.gradientStops[1].color),
offset: fill.gradientStops[1].position,
opacity: fill.gradientStops[1].color.a * (fill.opacity ?? 1)
}
]
},
fillOpacity: fill.visible === false ? 0 : undefined
};
};

View file

@ -0,0 +1,84 @@
import { translateFill } from '@plugin/translators/translateFills';
import { Stroke, StrokeAlignment, StrokeCaps } from '@ui/lib/types/utils/stroke';
export const translateStrokes = (
nodeStrokes: MinimalStrokesMixin,
hasFillGeometry?: boolean,
vectorNetwork?: VectorNetwork,
individualStrokes?: IndividualStrokesMixin
): Stroke[] => {
return nodeStrokes.strokes.map((paint, index) => {
const fill = translateFill(paint, 0, 0);
const stroke: Stroke = {
strokeColor: fill?.fillColor,
strokeOpacity: fill?.fillOpacity,
strokeWidth: translateStrokeWeight(nodeStrokes.strokeWeight, individualStrokes),
strokeAlignment: translateStrokeAlignment(nodeStrokes.strokeAlign),
strokeStyle: nodeStrokes.dashPattern.length ? 'dashed' : 'solid'
};
if (!hasFillGeometry && index === 0 && vectorNetwork && vectorNetwork.vertices.length) {
stroke.strokeCapStart = translateStrokeCap(vectorNetwork.vertices[0]);
stroke.strokeCapEnd = translateStrokeCap(
vectorNetwork.vertices[vectorNetwork.vertices.length - 1]
);
}
return stroke;
});
};
const translateStrokeWeight = (
strokeWeight: number | typeof figma.mixed,
individualStrokes?: IndividualStrokesMixin
): number => {
if (strokeWeight !== figma.mixed) {
return strokeWeight;
}
if (!individualStrokes) {
return 1;
}
return Math.max(
individualStrokes.strokeTopWeight,
individualStrokes.strokeRightWeight,
individualStrokes.strokeBottomWeight,
individualStrokes.strokeLeftWeight
);
};
const translateStrokeAlignment = (
strokeAlign: 'CENTER' | 'INSIDE' | 'OUTSIDE'
): StrokeAlignment => {
switch (strokeAlign) {
case 'CENTER':
return 'center';
case 'INSIDE':
return 'inner';
case 'OUTSIDE':
return 'outer';
}
};
const translateStrokeCap = (vertex: VectorVertex): StrokeCaps | undefined => {
switch (vertex.strokeCap as StrokeCap | ConnectorStrokeCap) {
case 'NONE':
return;
case 'ROUND':
return 'round';
case 'ARROW_EQUILATERAL':
case 'TRIANGLE_FILLED':
return 'triangle-arrow';
case 'SQUARE':
return 'square';
case 'CIRCLE_FILLED':
return 'circle-marker';
case 'DIAMOND_FILLED':
return 'diamond-marker';
case 'ARROW_LINES':
default:
return 'line-arrow';
}
};

View file

@ -0,0 +1,33 @@
import { transformTextStyle } from '@plugin/transformers/partials';
import { translateFills } from '@plugin/translators/translateFills';
import { TextNode } from '@ui/lib/types/text/textContent';
export const translateStyledTextSegments = (
segments: Pick<
StyledTextSegment,
| 'characters'
| 'start'
| 'end'
| 'fontName'
| 'fontSize'
| 'fontWeight'
| 'lineHeight'
| 'letterSpacing'
| 'textCase'
| 'textDecoration'
| 'fills'
>[],
width: number,
height: number
): TextNode[] => {
return segments.map(segment => {
figma.ui.postMessage({ type: 'FONT_NAME', data: segment.fontName.family });
return {
fills: translateFills(segment.fills, width, height),
text: segment.characters,
...transformTextStyle(segment)
};
});
};

View file

@ -0,0 +1,10 @@
export const translateTextDecoration = (segment: Pick<StyledTextSegment, 'textDecoration'>) => {
switch (segment.textDecoration) {
case 'STRIKETHROUGH':
return 'line-through';
case 'UNDERLINE':
return 'underline';
default:
return 'none';
}
};

View file

@ -0,0 +1,12 @@
export const translateTextTransform = (segment: Pick<StyledTextSegment, 'textCase'>) => {
switch (segment.textCase) {
case 'UPPER':
return 'uppercase';
case 'LOWER':
return 'lowercase';
case 'TITLE':
return 'capitalize';
default:
return 'none';
}
};

View file

@ -0,0 +1,69 @@
import { CurveToCommand, LineToCommand, MoveToCommand, parseSVG } from 'svg-path-parser';
import { Segment } from '@ui/lib/types/path/PathContent';
export const translateVectorPaths = (
paths: VectorPaths,
baseX: number,
baseY: number
): Segment[] => {
let segments: Segment[] = [];
for (const path of paths) {
segments = [...segments, ...translateVectorPath(path, baseX, baseY)];
}
return segments;
};
const translateVectorPath = (path: VectorPath, baseX: number, baseY: number): Segment[] => {
const normalizedPaths = parseSVG(path.data);
return normalizedPaths.map(command => {
switch (command.command) {
case 'moveto':
return translateMoveToCommand(command, baseX, baseY);
case 'lineto':
return translateLineToCommand(command, baseX, baseY);
case 'curveto':
return translateCurveToCommand(command, baseX, baseY);
case 'closepath':
default:
return {
command: 'close-path'
};
}
});
};
const translateMoveToCommand = (command: MoveToCommand, baseX: number, baseY: number): Segment => {
return {
command: 'move-to',
params: { x: command.x + baseX, y: command.y + baseY }
};
};
const translateLineToCommand = (command: LineToCommand, baseX: number, baseY: number): Segment => {
return {
command: 'line-to',
params: { x: command.x + baseX, y: command.y + baseY }
};
};
const translateCurveToCommand = (
command: CurveToCommand,
baseX: number,
baseY: number
): Segment => {
return {
command: 'curve-to',
params: {
c1x: command.x1 + baseX,
c1y: command.y1 + baseY,
c2x: command.x2 + baseX,
c2y: command.y2 + baseY,
x: command.x + baseX,
y: command.y + baseY
}
};
};

10
plugin-src/tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "es6",
"lib": ["es6"],
"strict": true,
"typeRoots": ["../node_modules/@figma"],
"moduleResolution": "Node"
}
}

View file

@ -0,0 +1,6 @@
export const applyMatrixToPoint = (matrix: number[][], point: number[]) => {
return [
point[0] * matrix[0][0] + point[1] * matrix[0][1] + matrix[0][2],
point[0] * matrix[1][0] + point[1] * matrix[1][1] + matrix[1][2]
];
};

View file

@ -0,0 +1,17 @@
export const calculateAdjustment = (node: SceneNode) => {
// For each child, check whether the X or Y position is less than 0 and less than the
// current adjustment.
let adjustedX = 0;
let adjustedY = 0;
if ('children' in node) {
for (const child of node.children) {
if (child.x < adjustedX) {
adjustedX = child.x;
}
if (child.y < adjustedY) {
adjustedY = child.y;
}
}
}
return [adjustedX, adjustedY];
};

View file

@ -0,0 +1,23 @@
import { applyMatrixToPoint } from '@plugin/utils/applyMatrixToPoint';
import { matrixInvert } from '@plugin/utils/matrixInvert';
export const calculateLinearGradient = (shapeWidth: number, shapeHeight: number, t: Transform) => {
const transform = t.length === 2 ? [...t, [0, 0, 1]] : [...t];
const mxInv = matrixInvert(transform);
if (!mxInv) {
return {
start: [0, 0],
end: [0, 0]
};
}
const startEnd = [
[0, 0.5],
[1, 0.5]
].map(p => applyMatrixToPoint(mxInv, p));
return {
start: [startEnd[0][0] * shapeWidth, startEnd[0][1] * shapeHeight],
end: [startEnd[1][0] * shapeWidth, startEnd[1][1] * shapeHeight]
};
};

View file

@ -0,0 +1,18 @@
export interface Signatures {
[key: string]: string;
}
const signatures: Signatures = {
'R0lGODdh': 'image/gif',
'R0lGODlh': 'image/gif',
'iVBORw0KGgo': 'image/png',
'/9j/': 'image/jpg'
};
export const detectMimeType = (b64: string) => {
for (const s in signatures) {
if (b64.indexOf(s) === 0) {
return signatures[s];
}
}
};

View file

@ -0,0 +1,3 @@
export * from './detectMimeType';
export * from './rgbToHex';
export * from './calculateAdjustment';

View file

@ -0,0 +1,96 @@
export const matrixInvert = (M: number[][]): number[][] | undefined => {
// if the matrix isn't square: exit (error)
if (M.length !== M[0].length) {
return;
}
// create the identity matrix (I), and a copy (C) of the original
const dim = M.length;
let i = 0,
ii = 0,
j = 0,
e = 0;
const I: number[][] = [],
C: number[][] = [];
for (i = 0; i < dim; i += 1) {
// Create the row
I[i] = [];
C[i] = [];
for (j = 0; j < dim; j += 1) {
// if we're on the diagonal, put a 1 (for identity)
if (i === j) {
I[i][j] = 1;
} else {
I[i][j] = 0;
}
// Also, make the copy of the original
C[i][j] = M[i][j];
}
}
// Perform elementary row operations
for (i = 0; i < dim; i += 1) {
// get the element e on the diagonal
e = C[i][i];
// if we have a 0 on the diagonal (we'll need to swap with a lower row)
if (e === 0) {
// look through every row below the i'th row
for (ii = i + 1; ii < dim; ii += 1) {
// if the ii'th row has a non-0 in the i'th col
if (C[ii][i] !== 0) {
// it would make the diagonal have a non-0 so swap it
for (j = 0; j < dim; j++) {
e = C[i][j]; // temp store i'th row
C[i][j] = C[ii][j]; // replace i'th row by ii'th
C[ii][j] = e; // replace ii'th by temp
e = I[i][j]; // temp store i'th row
I[i][j] = I[ii][j]; // replace i'th row by ii'th
I[ii][j] = e; // replace ii'th by temp
}
// don't bother checking other rows since we've swapped
break;
}
}
// get the new diagonal
e = C[i][i];
// if it's still 0, not invertable (error)
if (e === 0) {
return;
}
}
// Scale this row down by e (so we have a 1 on the diagonal)
for (j = 0; j < dim; j++) {
C[i][j] = C[i][j] / e; // apply to original matrix
I[i][j] = I[i][j] / e; // apply to identity
}
// Subtract this row (scaled appropriately for each row) from ALL of
// the other rows so that there will be 0's in this column in the
// rows above and below this one
for (ii = 0; ii < dim; ii++) {
// Only apply to other rows (we want a 1 on the diagonal)
if (ii === i) {
continue;
}
// We want to change this element to 0
e = C[ii][i];
// Subtract (the row above(or below) scaled by e) from (the
// current row) but start at the i'th column and assume all the
// stuff left of diagonal is 0 (which it should be if we made this
// algorithm correctly)
for (j = 0; j < dim; j++) {
C[ii][j] -= e * C[i][j]; // apply to original matrix
I[ii][j] -= e * I[i][j]; // apply to identity
}
}
}
// we've done all operations, C should be the identity
// matrix I should be the inverse:
return I;
};

View file

@ -0,0 +1,7 @@
export const rgbToHex = (color: RGB) => {
const r = Math.round(255 * color.r);
const g = Math.round(255 * color.g);
const b = Math.round(255 * color.b);
const rgb = (r << 16) | (g << 8) | (b << 0);
return '#' + (0x1000000 + rgb).toString(16).slice(1);
};

19
prettier.config.mjs Normal file
View file

@ -0,0 +1,19 @@
/** @type {import("prettier").Config} */
const config = {
arrowParens: 'avoid',
bracketSpacing: true,
printWidth: 100,
proseWrap: 'always',
quoteProps: 'consistent',
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'none',
useTabs: false,
plugins: ['@trivago/prettier-plugin-sort-imports'],
importOrder: ['^@plugin/(.*)$', '^@ui/(.*)$', '^[./]'],
importOrderSeparation: true,
importOrderSortSpecifiers: true
};
export default config;

View file

@ -1,112 +0,0 @@
let fileData = [];
type NodeData = {
id: string
name: string,
type: string,
children: Node[],
x: number,
y: number,
width: number,
height: number,
fills: any
}
const signatures = {
R0lGODdh: "image/gif",
R0lGODlh: "image/gif",
iVBORw0KGgo: "image/png",
"/9j/": "image/jpg"
};
function detectMimeType(b64) {
for (var s in signatures) {
if (b64.indexOf(s) === 0) {
return signatures[s];
}
}
}
function traverse(node): NodeData {
let children:any[] = [];
if ("children" in node) {
if (node.type !== "INSTANCE") {
for (const child of node.children) {
children.push (traverse(child))
}
}
}
let result = {
id: node.id,
type: node.type,
name: node.name,
children: children,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
fills: node.fills === figma.mixed ? [] : node.fills //TODO: Support mixed fills
}
if (node.fills && Array.isArray(node.fills)){
// Find any fill of type image
const imageFill = node.fills.find(fill => fill.type === "IMAGE");
if (imageFill) {
// An "image" in Figma is a shape with one or more image fills, potentially blended with other fill
// types. Given the complexity of mirroring this exactly in Penpot, which treats images as first-class
// objects, we're going to simplify this by exporting this shape as a PNG image.
node.exportAsync({format: "PNG"}).then((value) => {
const b64 = figma.base64Encode(value);
figma.ui.postMessage({type: "IMAGE", data: {
id: node.id,
value: "data:" + detectMimeType(b64) + ";base64," + b64
}});
});
}
}
if (node.type == "TEXT") {
const styledTextSegments = node.getStyledTextSegments(["fontName", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "textCase", "textDecoration", "fills"]);
if (styledTextSegments[0]) {
let font = {
fontName: styledTextSegments[0].fontName,
fontSize: styledTextSegments[0].fontSize.toString(),
fontWeight: styledTextSegments[0].fontWeight.toString(),
characters: node.characters,
lineHeight: styledTextSegments[0].lineHeight,
letterSpacing: styledTextSegments[0].letterSpacing,
fills: styledTextSegments[0].fills,
textCase: styledTextSegments[0].textCase,
textDecoration: styledTextSegments[0].textDecoration,
textAlignHorizontal: node.textAlignHorizontal,
textAlignVertical: node.textAlignVertical,
children: styledTextSegments
};
result = {...result, ...font};
}
}
return result;
}
figma.showUI(__html__, { themeColors: true, height: 200, width: 300 });
let root: NodeData = traverse(figma.root) // start the traversal at the root
figma.ui.postMessage({type: "FIGMAFILE", data: root});
figma.ui.onmessage = (msg) => {
if (msg.type === "cancel") {
figma.closePlugin();
}
if (msg.type === "resize") {
figma.ui.resize(msg.width, msg.height);
}
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
<div id="penpot-export-page"></div>

View file

@ -1,482 +0,0 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import "./ui.css";
import * as penpot from "./penpot.js";
import { extractLinearGradientParamsFromTransform } from "@figma-plugin/helpers";
import slugify from "slugify";
declare function require(path: string): any;
// Open resources/gfonts.json and create a set of matched font names
const gfonts = new Set();
require("./gfonts.json").forEach((font) => gfonts.add(font));
type PenpotExporterProps = {
}
type FigmaImageData = {
value: string,
width: number,
height: number
}
type PenpotExporterState = {
isDebug: boolean,
penpotFileData: string
missingFonts: Set<string>
figmaFileData: string
figmaRootNode: NodeData
images: { [id: string] : FigmaImageData; };
}
export default class PenpotExporter extends React.Component<PenpotExporterProps, PenpotExporterState> {
state: PenpotExporterState = {
isDebug: false,
penpotFileData: "",
figmaFileData: "",
missingFonts: new Set(),
figmaRootNode: null,
images: {}
};
componentDidMount = () => {
window.addEventListener("message", this.onMessage);
}
componentDidUpdate = () => {
this.setDimensions();
}
componentWillUnmount = () =>{
window.removeEventListener('message', this.onMessage);
}
rgbToHex = (color) => {
const r = Math.round(255 * color.r);
const g = Math.round(255 * color.g);
const b = Math.round(255 * color.b);
const rgb = (r << 16) | (g << 8) | (b << 0);
return '#' + (0x1000000 + rgb).toString(16).slice(1);
}
translateSolidFill(fill){
return {
fillColor: this.rgbToHex(fill.color),
fillOpacity: fill.opacity
}
}
translateGradientLinearFill(fill, width, height){
const points = extractLinearGradientParamsFromTransform(width, height, fill.gradientTransform);
return {
fillColorGradient: {
type: Symbol.for("linear"),
width: 1,
startX: points.start[0] / width,
startY: points.start[1] / height,
endX: points.end[0] / width,
endY: points.end[1] / height,
stops: [{
color: this.rgbToHex(fill.gradientStops[0].color),
offset: fill.gradientStops[0].position,
opacity: fill.gradientStops[0].color.a * fill.opacity
},
{
color: this.rgbToHex(fill.gradientStops[1].color),
offset: fill.gradientStops[1].position,
opacity: fill.gradientStops[1].color.a * fill.opacity
}
]
}
}
}
translateFill(fill, width, height){
if (fill.type === "SOLID"){
return this.translateSolidFill(fill);
} else if (fill.type === "GRADIENT_LINEAR"){
return this.translateGradientLinearFill(fill, width, height);
} else {
console.error('Color type '+fill.type+' not supported yet');
return null;
}
}
translateFills(fills, width, height){
let penpotFills = [];
let penpotFill = null;
for (var fill of fills){
penpotFill = this.translateFill(fill, width, height);
// Penpot does not track fill visibility, so if the Figma fill is invisible we
// force opacity to 0
if (fill.visible === false){
penpotFill.fillOpacity = 0;
}
if (penpotFill !== null){
penpotFills.unshift(penpotFill);
}
}
return penpotFills;
}
addFontWarning(font){
const newMissingFonts = this.state.missingFonts;
newMissingFonts.add(font);
this.setState(_ => ({missingFonts: newMissingFonts }));
}
createPenpotPage(file, node){
file.addPage(node.name);
for (var child of node.children){
this.createPenpotItem(file, child, 0, 0);
}
file.closePage();
}
createPenpotBoard(file, node, baseX, baseY){
file.addArtboard({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: node.width, height: node.height,
fills: this.translateFills(node.fills, node.width, node.height)
});
for (var child of node.children){
this.createPenpotItem(file, child, node.x + baseX, node.y + baseY);
}
file.closeArtboard();
}
createPenpotGroup(file, node, baseX, baseY){
file.addGroup({name: node.name});
for (var child of node.children){
this.createPenpotItem(file, child, baseX, baseY);
}
file.closeGroup();
}
createPenpotRectangle(file, node, baseX, baseY){
file.createRect({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: node.width, height: node.height,
fills: this.translateFills(node.fills, node.width, node.height),
});
}
createPenpotCircle(file, node, baseX, baseY){
file.createCircle({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: node.width, height: node.height,
fills: this.translateFills(node.fills, node.width, node.height),
});
}
translateHorizontalAlign(align: string){
if (align === "RIGHT"){
return Symbol.for("right");
}
if (align === "CENTER"){
return Symbol.for("center");
}
return Symbol.for("left")
}
translateVerticalAlign(align: string){
if (align === "BOTTOM"){
return Symbol.for("bottom");
}
if (align === "CENTER"){
return Symbol.for("center");
}
return Symbol.for("top")
}
translateFontStyle(style:string){
return style.toLowerCase().replace(/\s/g, "")
}
getTextDecoration(node){
const textDecoration = node.textDecoration;
if (textDecoration === "STRIKETHROUGH"){
return "line-through";
}
if (textDecoration === "UNDERLINE"){
return "underline";
}
return "none";
}
getTextTransform(node){
const textCase = node.textCase;
if (textCase === "UPPER"){
return "uppercase";
}
if (textCase === "LOWER"){
return "lowercase";
}
if (textCase === "TITLE"){
return "capitalize";
}
return "none";
}
validateFont(fontName) {
const name = slugify(fontName.family.toLowerCase());
if (!gfonts.has(name)) {
this.addFontWarning(name);
}
}
createPenpotText(file, node, baseX, baseY){
const children = node.children.map((val) => {
this.validateFont(val.fontName);
return {
lineHeight: val.lineHeight,
fontStyle: "normal",
textAlign: this.translateHorizontalAlign(node.textAlignHorizontal),
fontId: "gfont-" + slugify(val.fontName.family.toLowerCase()),
fontSize: val.fontSize.toString(),
fontWeight: val.fontWeight.toString(),
fontVariantId: this.translateFontStyle(val.fontName.style),
textDecoration: this.getTextDecoration(val),
textTransform: this.getTextTransform(val),
letterSpacing: val.letterSpacing,
fills: this.translateFills(val.fills, node.width, node.height),
fontFamily: val.fontName.family,
text: val.characters }
});
this.validateFont(node.fontName);
file.createText({
name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
rotation: 0,
type: Symbol.for("text"),
content: {
type: "root",
verticalAlign: this.translateVerticalAlign(node.textAlignVertical),
children: [{
type: "paragraph-set",
children: [{
lineHeight: node.lineHeight,
fontStyle: "normal",
children: children,
textTransform: this.getTextTransform(node),
textAlign: this.translateHorizontalAlign(node.textAlignHorizontal),
fontId: "gfont-" + slugify(node.fontName.family.toLowerCase()),
fontSize: node.fontSize.toString(),
fontWeight: node.fontWeight.toString(),
type: "paragraph",
textDecoration: this.getTextDecoration(node),
letterSpacing: node.letterSpacing,
fills: this.translateFills(node.fills, node.width, node.height),
fontFamily: node.fontName.family
}]
}]
}
});
}
createPenpotImage(file, node, baseX, baseY, image){
file.createImage({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: image.width, height: image.height,
metadata: {
width: image.width,
height: image.height
},
dataUri: image.value
});
}
calculateAdjustment(node){
// For each child, check whether the X or Y position is less than 0 and less than the
// current adjustment.
let adjustedX = 0;
let adjustedY = 0;
for (var child of node.children){
if (child.x < adjustedX){
adjustedX = child.x;
}
if (child.y < adjustedY){
adjustedY = child.y;
}
}
return [adjustedX, adjustedY];
}
createPenpotItem(file, node, baseX, baseY){
// We special-case images because an image in figma is a shape with one or many
// image fills. Given that handling images in Penpot is a bit different, we
// rasterize a figma shape with any image fills to a PNG and then add it as a single
// Penpot image. Implication is that any node that has an image fill will only be
// treated as an image, so we skip node type checks.
const hasImageFill = node.fills?.some(fill => fill.type === "IMAGE");
if (hasImageFill){
// If the nested frames extended the bounds of the rasterized image, we need to
// account for this both in position on the canvas and the calculated width and
// height of the image.
const [adjustedX, adjustedY] = this.calculateAdjustment(node);
const width = node.width + Math.abs(adjustedX);
const height = node.height + Math.abs(adjustedY);
this.createPenpotImage(file, node, baseX + adjustedX, baseY + adjustedY, this.state.images[node.id]);
}
else if (node.type == "PAGE"){
this.createPenpotPage(file, node);
}
else if (node.type == "FRAME"){
this.createPenpotBoard(file, node, baseX, baseY);
}
else if (node.type == "GROUP"){
this.createPenpotGroup(file, node,baseX, baseY);
}
else if (node.type == "RECTANGLE"){
this.createPenpotRectangle(file, node, baseX, baseY);
}
else if (node.type == "ELLIPSE"){
this.createPenpotCircle(file, node, baseX, baseY);
}
else if (node.type == "TEXT"){
this.createPenpotText(file, node, baseX, baseY);
}
}
createPenpotFile(){
let node = this.state.figmaRootNode;
const file = penpot.createFile(node.name);
for (var page of node.children){
this.createPenpotItem(file, page, 0, 0);
}
return file;
}
onCreatePenpot = () => {
const file = this.createPenpotFile();
const penpotFileMap = file.asMap();
this.setState(state => ({penpotFileData: JSON.stringify(penpotFileMap, (key, value) => (value instanceof Map ? [...value] : value), 4)}));
file.export()
};
onCancel = () => {
parent.postMessage({ pluginMessage: { type: "cancel" } }, "*");
};
onMessage = (event) => {
if (event.data.pluginMessage.type == "FIGMAFILE") {
this.setState(state => ({
figmaFileData: JSON.stringify(event.data.pluginMessage.data, (key, value) => (value instanceof Map ? [...value] : value), 4),
figmaRootNode: event.data.pluginMessage.data}));
}
else if (event.data.pluginMessage.type == "IMAGE") {
const data = event.data.pluginMessage.data;
const image = document.createElement('img');
const thisObj = this;
image.addEventListener('load', function() {
// Get byte array from response
thisObj.setState(state =>
{
state.images[data.id] = {
value: data.value,
width: image.naturalWidth,
height: image.naturalHeight
};
return state;
}
);
});
image.src = data.value;
}
}
setDimensions = () => {
const isMissingFonts = this.state.missingFonts.size > 0;
let width = 300;
let height = 280;
if (isMissingFonts) {
height += (this.state.missingFonts.size * 20);
width = 400;
}
if (this.state.isDebug){
height += 600;
width = 800;
}
parent.postMessage({ pluginMessage: { type: "resize", width: width, height: height } }, "*");
}
toggleDebug = (event) => {
const isDebug = event.currentTarget.checked;
this.setState (state => ({isDebug: isDebug}));
}
renderFontWarnings = () => {
return (
<ul >
{Array.from(this.state.missingFonts).map((font) => (
<li key={font}>{font}</li>
))}
</ul>
);
}
render() {
// Update the dimensions of the plugin window based on available data and selections
return (
<main>
<header>
<img src={require("./logo.svg")} />
<h2>Penpot Exporter</h2>
</header>
<section>
<div style={{display:this.state.missingFonts.size > 0 ? "inline" : "none"}}>
<div id="missing-fonts">{this.state.missingFonts.size} non-default font{this.state.missingFonts.size > 1 ? 's' : ''}: </div>
<small>Ensure fonts are installed in Penpot before importing.</small>
<div id="missing-fonts-list">
{this.renderFontWarnings()}
</div>
</div>
<div >
<input type="checkbox" id="chkDebug" name="chkDebug" onChange={this.toggleDebug}/>
<label htmlFor="chkDebug">Show debug data</label>
</div>
</section>
<div style={{display: this.state.isDebug? '': 'none'}}>
<section>
<textarea style={{width:'790px', height:'270px'}} id="figma-file-data" value={this.state.figmaFileData} readOnly />
<label htmlFor="figma-file-data">Figma file data</label>
</section>
<section>
<textarea style={{width:'790px', height:'270px'}} id="penpot-file-data" value={this.state.penpotFileData} readOnly />
<label htmlFor="penpot-file-data">Penpot file data</label>
</section>
</div>
<footer>
<button className="brand" onClick={this.onCreatePenpot}>
Export
</button>
<button onClick={this.onCancel}>Cancel</button>
</footer>
</main>
);
}
}
ReactDOM.render(
<React.StrictMode>
<PenpotExporter />
</React.StrictMode>,
document.getElementById('penpot-export-page')
);

4
stylelint.config.mjs Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('stylelint').Config} */
export default {
extends: ['stylelint-config-recommended', 'stylelint-config-standard']
};

9
tsconfig.base.json Normal file
View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@plugin/*": ["./plugin-src/*"],
"@ui/*": ["./ui-src/*"]
}
}
}

View file

@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "es6",
"jsx": "react",
"typeRoots": [
"./node_modules/@types",
"./node_modules/@figma"
],
"moduleResolution":"node"
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

100
ui-src/PenpotExporter.tsx Normal file
View file

@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import slugify from 'slugify';
import { createPenpotFile } from '@ui/converters';
import { PenpotDocument } from '@ui/lib/types/penpotDocument';
import { validateFont } from '@ui/validators';
import Logo from './logo.svg?react';
export const PenpotExporter = () => {
const [missingFonts, setMissingFonts] = useState(new Set<string>());
const [exporting, setExporting] = useState(false);
const addFontWarning = (font: string) => {
setMissingFonts(missingFonts => missingFonts.add(font));
};
const onMessage = (event: MessageEvent<{ pluginMessage: { type: string; data: unknown } }>) => {
if (event.data.pluginMessage?.type == 'FIGMAFILE') {
const document = event.data.pluginMessage.data as PenpotDocument;
const file = createPenpotFile(document);
file.export();
setExporting(false);
} else if (event.data.pluginMessage?.type == 'FONT_NAME') {
const fontName = event.data.pluginMessage.data as string;
if (!validateFont(fontName)) {
addFontWarning(slugify(fontName.toLowerCase()));
}
}
};
const onCreatePenpot = () => {
setExporting(true);
parent.postMessage({ pluginMessage: { type: 'export' } }, '*');
};
const onCancel = () => {
parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*');
};
const setDimensions = () => {
const isMissingFonts = missingFonts.size > 0;
let width = 300;
let height = 280;
if (isMissingFonts) {
height += missingFonts.size * 20;
width = 400;
parent.postMessage({ pluginMessage: { type: 'resize', width: width, height: height } }, '*');
}
};
useEffect(() => {
window.addEventListener('message', onMessage);
return () => {
window.removeEventListener('message', onMessage);
};
}, []);
useEffect(() => {
setDimensions();
}, [missingFonts]);
return (
<main>
<header>
<Logo />
<h2>Penpot Exporter</h2>
</header>
<section>
<div style={{ display: missingFonts.size > 0 ? 'inline' : 'none' }}>
<div id="missing-fonts">
{missingFonts.size} non-default font
{missingFonts.size > 1 ? 's' : ''}:{' '}
</div>
<small>Ensure fonts are installed in Penpot before importing.</small>
<div id="missing-fonts-list">
<ul>
{Array.from(missingFonts).map(font => (
<li key={font}>{font}</li>
))}
</ul>
</div>
</div>
</section>
<footer>
<button className="brand" disabled={exporting} onClick={onCreatePenpot}>
{exporting ? 'Exporting...' : 'Export to Penpot'}
</button>
<button onClick={onCancel}>Cancel</button>
</footer>
</main>
);
};

View file

@ -0,0 +1,18 @@
import { Gradient, LINEAR_TYPE, RADIAL_TYPE } from '@ui/lib/types/utils/gradient';
export const createGradientFill = ({ type, ...rest }: Gradient): Gradient | undefined => {
switch (type) {
case 'linear':
return {
type: LINEAR_TYPE,
...rest
};
case 'radial':
return {
type: RADIAL_TYPE,
...rest
};
}
console.error(`Unsupported gradient type: ${String(type)}`);
};

View file

@ -0,0 +1,21 @@
import { PenpotFile } from '@ui/lib/penpot';
import { FRAME_TYPE } from '@ui/lib/types/frame/frameAttributes';
import { FrameShape } from '@ui/lib/types/frame/frameShape';
import { createPenpotItem } from '.';
export const createPenpotArtboard = (
file: PenpotFile,
{ type, children = [], ...rest }: FrameShape
) => {
file.addArtboard({
type: FRAME_TYPE,
...rest
});
for (const child of children) {
createPenpotItem(file, child);
}
file.closeArtboard();
};

View file

@ -0,0 +1,17 @@
import { PenpotFile } from '@ui/lib/penpot';
import { CIRCLE_TYPE } from '@ui/lib/types/circle/circleAttributes';
import { CircleShape } from '@ui/lib/types/circle/circleShape';
import { translateFillGradients, translateUiBlendMode } from '../translators';
export const createPenpotCircle = (
file: PenpotFile,
{ type, fills, blendMode, ...rest }: CircleShape
) => {
file.createCircle({
type: CIRCLE_TYPE,
fills: translateFillGradients(fills),
blendMode: translateUiBlendMode(blendMode),
...rest
});
};

View file

@ -0,0 +1,14 @@
import { createFile } from '@ui/lib/penpot.js';
import { PenpotDocument } from '@ui/lib/types/penpotDocument';
import { createPenpotPage } from '.';
export const createPenpotFile = (node: PenpotDocument) => {
const file = createFile(node.name);
for (const page of node.children ?? []) {
createPenpotPage(file, page);
}
return file;
};

View file

@ -0,0 +1,23 @@
import { PenpotFile } from '@ui/lib/penpot';
import { GROUP_TYPE } from '@ui/lib/types/group/groupAttributes';
import { GroupShape } from '@ui/lib/types/group/groupShape';
import { translateUiBlendMode } from '@ui/translators';
import { createPenpotItem } from '.';
export const createPenpotGroup = (
file: PenpotFile,
{ type, blendMode, children = [], ...rest }: GroupShape
) => {
file.addGroup({
type: GROUP_TYPE,
blendMode: translateUiBlendMode(blendMode),
...rest
});
for (const child of children) {
createPenpotItem(file, child);
}
file.closeGroup();
};

View file

@ -0,0 +1,10 @@
import { PenpotFile } from '@ui/lib/penpot';
import { IMAGE_TYPE } from '@ui/lib/types/image/imageAttributes';
import { ImageShape } from '@ui/lib/types/image/imageShape';
export const createPenpotImage = (file: PenpotFile, { type, ...rest }: ImageShape) => {
file.createImage({
type: IMAGE_TYPE,
...rest
});
};

View file

@ -0,0 +1,31 @@
import { PenpotFile } from '@ui/lib/penpot';
import { PenpotNode } from '@ui/lib/types/penpotNode';
import {
createPenpotArtboard,
createPenpotCircle,
createPenpotGroup,
createPenpotImage,
createPenpotPath,
createPenpotRectangle,
createPenpotText
} from '.';
export const createPenpotItem = (file: PenpotFile, node: PenpotNode) => {
switch (node.type) {
case 'rect':
return createPenpotRectangle(file, node);
case 'circle':
return createPenpotCircle(file, node);
case 'frame':
return createPenpotArtboard(file, node);
case 'group':
return createPenpotGroup(file, node);
case 'image':
return createPenpotImage(file, node);
case 'path':
return createPenpotPath(file, node);
case 'text':
return createPenpotText(file, node);
}
};

View file

@ -0,0 +1,14 @@
import { PenpotFile } from '@ui/lib/penpot';
import { PenpotPage } from '@ui/lib/types/penpotPage';
import { createPenpotItem } from '.';
export const createPenpotPage = (file: PenpotFile, node: PenpotPage) => {
file.addPage(node.name);
for (const child of node.children ?? []) {
createPenpotItem(file, child);
}
file.closePage();
};

View file

@ -0,0 +1,21 @@
import { PenpotFile } from '@ui/lib/penpot';
import { PATH_TYPE } from '@ui/lib/types/path/pathAttributes';
import { PathShape } from '@ui/lib/types/path/pathShape';
import {
translateFillGradients,
translatePathContent,
translateUiBlendMode
} from '@ui/translators';
export const createPenpotPath = (
file: PenpotFile,
{ type, fills, blendMode, content, ...rest }: PathShape
) => {
file.createPath({
type: PATH_TYPE,
fills: translateFillGradients(fills),
blendMode: translateUiBlendMode(blendMode),
content: translatePathContent(content),
...rest
});
};

View file

@ -0,0 +1,16 @@
import { PenpotFile } from '@ui/lib/penpot';
import { RECT_TYPE } from '@ui/lib/types/rect/rectAttributes';
import { RectShape } from '@ui/lib/types/rect/rectShape';
import { translateFillGradients, translateUiBlendMode } from '@ui/translators';
export const createPenpotRectangle = (
file: PenpotFile,
{ type, fills, blendMode, ...rest }: RectShape
) => {
file.createRect({
type: RECT_TYPE,
fills: translateFillGradients(fills),
blendMode: translateUiBlendMode(blendMode),
...rest
});
};

View file

@ -0,0 +1,12 @@
import { PenpotFile } from '@ui/lib/penpot';
import { TEXT_TYPE } from '@ui/lib/types/text/textAttributes';
import { TextShape } from '@ui/lib/types/text/textShape';
import { translateUiBlendMode } from '@ui/translators';
export const createPenpotText = (file: PenpotFile, { type, blendMode, ...rest }: TextShape) => {
file.createText({
type: TEXT_TYPE,
blendMode: translateUiBlendMode(blendMode),
...rest
});
};

View file

@ -0,0 +1,10 @@
export * from './createPenpotArtboard';
export * from './createPenpotCircle';
export * from './createPenpotFile';
export * from './createPenpotGroup';
export * from './createPenpotImage';
export * from './createPenpotItem';
export * from './createPenpotPage';
export * from './createPenpotPath';
export * from './createPenpotRectangle';
export * from './createPenpotText';

1434
ui-src/gfonts.json Normal file

File diff suppressed because it is too large Load diff

12
ui-src/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Penpot Exporter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

44
ui-src/lib/penpot.d.ts vendored Normal file
View file

@ -0,0 +1,44 @@
import { BoolShape } from '@ui/lib/types/bool/boolShape';
import { CircleShape } from '@ui/lib/types/circle/circleShape';
import { FrameShape } from '@ui/lib/types/frame/frameShape';
import { GroupShape } from '@ui/lib/types/group/groupShape';
import { ImageShape } from '@ui/lib/types/image/imageShape';
import { PathShape } from '@ui/lib/types/path/pathShape';
import { RectShape } from '@ui/lib/types/rect/rectShape';
import { TextShape } from '@ui/lib/types/text/textShape';
export interface PenpotFile {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
addPage(name: string, options?: any): void;
closePage(): void;
addArtboard(artboard: FrameShape): void;
closeArtboard(): void;
addGroup(group: GroupShape): void;
closeGroup(): void;
addBool(bool: BoolShape): void;
closeBool(): void;
createRect(rect: RectShape): void;
createCircle(circle: CircleShape): void;
createPath(path: PathShape): void;
createText(options: TextShape): void;
createImage(image: ImageShape): void;
// createSVG(svg: any): void;
// closeSVG(): void;
// addLibraryColor(color: any): void;
// updateLibraryColor(color: any): void;
// deleteLibraryColor(color: any): void;
// addLibraryMedia(media: any): void;
// deleteLibraryMedia(media: any): void;
// addLibraryTypography(typography: any): void;
// deleteLibraryTypography(typography: any): void;
// startComponent(component: any): void;
// finishComponent(): void;
// createComponentInstance(instance: any): void;
// lookupShape(shapeId: string): void;
// updateObject(id: string, object: any): void;
// deleteObject(id: string): void;
asMap(): unknown;
export(): void;
}
export function createFile(name: string): PenpotFile;

6868
ui-src/lib/penpot.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,12 @@
import { Uuid } from '@ui/lib/types/utils/uuid';
import { BoolContent } from './boolContent';
export const BOOL_TYPE: unique symbol = Symbol.for('bool');
export type BoolAttributes = {
type: 'bool' | typeof BOOL_TYPE;
shapes?: Uuid[];
boolType: string; // @TODO: in Penpot this is of type :keyword. check if it makes sense
boolContent: BoolContent[];
};

View file

@ -0,0 +1,8 @@
import { Point } from '@ui/lib/types/utils/point';
export type BoolContent = {
command: string; // @TODO: in Penpot this is of type :keyword. check if it makes sense
relative?: boolean;
prevPos?: Point;
params?: { [keyword: string]: number }; // @TODO: in Penpot this is of type :keyword. check if it makes sense
};

10
ui-src/lib/types/bool/boolShape.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
import { LayoutChildAttributes } from '@ui/lib/types/layout/layoutChildAttributes';
import { ShapeAttributes } from '@ui/lib/types/shape/shapeAttributes';
import { ShapeBaseAttributes } from '@ui/lib/types/shape/shapeBaseAttributes';
import { BoolAttributes } from './boolAttributes';
export type BoolShape = ShapeBaseAttributes &
ShapeAttributes &
BoolAttributes &
LayoutChildAttributes;

View file

@ -0,0 +1,5 @@
export const CIRCLE_TYPE: unique symbol = Symbol.for('circle');
export type CircleAttributes = {
type: 'circle' | typeof CIRCLE_TYPE;
};

View file

@ -0,0 +1,12 @@
import { LayoutChildAttributes } from '@ui/lib/types/layout/layoutChildAttributes';
import { ShapeAttributes } from '@ui/lib/types/shape/shapeAttributes';
import { ShapeBaseAttributes } from '@ui/lib/types/shape/shapeBaseAttributes';
import { ShapeGeomAttributes } from '@ui/lib/types/shape/shapeGeomAttributes';
import { CircleAttributes } from './circleAttributes';
export type CircleShape = ShapeBaseAttributes &
ShapeGeomAttributes &
ShapeAttributes &
CircleAttributes &
LayoutChildAttributes;

View file

@ -0,0 +1,16 @@
import { Stroke } from '@ui/lib/types/utils/stroke';
import { Uuid } from '@ui/lib/types/utils/uuid';
import { Fill } from '../utils/fill';
export const FRAME_TYPE: unique symbol = Symbol.for('frame');
export type FrameAttributes = {
type: 'frame' | typeof FRAME_TYPE;
shapes?: Uuid[];
hideFillOnExport?: boolean;
showContent?: boolean;
hideInViewer?: boolean;
fills?: Fill[];
strokes?: Stroke[];
};

14
ui-src/lib/types/frame/frameShape.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
import { LayoutAttributes } from '@ui/lib/types/layout/layoutAttributes';
import { LayoutChildAttributes } from '@ui/lib/types/layout/layoutChildAttributes';
import { ShapeBaseAttributes } from '@ui/lib/types/shape/shapeBaseAttributes';
import { ShapeGeomAttributes } from '@ui/lib/types/shape/shapeGeomAttributes';
import { Children } from '@ui/lib/types/utils/children';
import { FrameAttributes } from './frameAttributes';
export type FrameShape = ShapeBaseAttributes &
ShapeGeomAttributes &
FrameAttributes &
LayoutAttributes &
LayoutChildAttributes &
Children;

View file

@ -0,0 +1,8 @@
import { Uuid } from '@ui/lib/types/utils/uuid';
export const GROUP_TYPE: unique symbol = Symbol.for('group');
export type GroupAttributes = {
type: 'group' | typeof GROUP_TYPE;
shapes?: Uuid[];
};

12
ui-src/lib/types/group/groupShape.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
import { ShapeAttributes } from '@ui/lib/types/shape/shapeAttributes';
import { ShapeBaseAttributes } from '@ui/lib/types/shape/shapeBaseAttributes';
import { ShapeGeomAttributes } from '@ui/lib/types/shape/shapeGeomAttributes';
import { Children } from '@ui/lib/types/utils/children';
import { GroupAttributes } from './groupAttributes';
export type GroupShape = ShapeBaseAttributes &
ShapeGeomAttributes &
ShapeAttributes &
GroupAttributes &
Children;

View file

@ -0,0 +1,14 @@
import { Uuid } from '@ui/lib/types/utils/uuid';
export const IMAGE_TYPE: unique symbol = Symbol.for('image');
export type ImageAttributes = {
type: 'image' | typeof IMAGE_TYPE;
dataUri?: string; //@TODO: check how this works because it's not defined in penpot, it's just used in the export
metadata: {
width: number;
height: number;
mtype?: string;
id?: Uuid;
};
};

12
ui-src/lib/types/image/imageShape.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
import { LayoutChildAttributes } from '@ui/lib/types/layout/layoutChildAttributes';
import { ShapeAttributes } from '@ui/lib/types/shape/shapeAttributes';
import { ShapeBaseAttributes } from '@ui/lib/types/shape/shapeBaseAttributes';
import { ShapeGeomAttributes } from '@ui/lib/types/shape/shapeGeomAttributes';
import { ImageAttributes } from './imageAttributes';
export type ImageShape = ShapeBaseAttributes &
ShapeGeomAttributes &
ShapeAttributes &
ImageAttributes &
LayoutChildAttributes;

14
ui-src/lib/types/layout/gridCell.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
import { Uuid } from '@ui/lib/types/utils/uuid';
export type GridCell = {
id?: Uuid;
areaName?: string;
row: number;
rowSpan: number;
column: number;
columnSpan: number;
position?: 'auto' | 'manual' | 'area';
alignSelf?: 'auto' | 'start' | 'end' | 'center' | 'stretch';
justifySelf?: 'auto' | 'start' | 'end' | 'center' | 'stretch';
shapes?: Uuid[];
};

View file

@ -0,0 +1,4 @@
export type GridTrack = {
type: 'percent' | 'flex' | 'auto' | 'fixed';
value?: number;
};

View file

@ -0,0 +1,46 @@
import { GridCell } from '@ui/lib/types/layout/gridCell';
import { GridTrack } from '@ui/lib/types/layout/gridTrack';
import { Uuid } from '@ui/lib/types/utils/uuid';
type JustifyAlignContent =
| 'start'
| 'center'
| 'end'
| 'space-between'
| 'space-around'
| 'space-evenly'
| 'stretch';
type JustifyAlignItems = 'start' | 'end' | 'center' | 'stretch';
export type LayoutAttributes = {
layout?: 'flex' | 'grid';
layoutFlexDir?:
| 'row'
| 'reverse-row'
| 'row-reverse'
| 'column'
| 'reverse-column'
| 'column-reverse';
layoutGap?: {
rowGap?: number;
columnGap?: number;
};
layoutGapType?: 'simple' | 'multiple';
layoutWrapType?: 'wrap' | 'nowrap' | 'no-wrap';
layoutPaddingType?: 'simple' | 'multiple';
layoutPadding?: {
p1?: number;
p2?: number;
p3?: number;
p4?: number;
};
layoutJustifyContent?: JustifyAlignContent;
layoutJustifyItems?: JustifyAlignItems;
layoutAlignContent?: JustifyAlignContent;
layoutAlignItems?: JustifyAlignItems;
layoutGridDir?: 'row' | 'column';
layoutGridRows?: GridTrack[];
layoutGridColumns?: GridTrack[];
layoutGridCells?: { [uuid: Uuid]: GridCell };
};

View file

@ -0,0 +1,55 @@
export const ITEM_MARGIN_SIMPLE_TYPE: unique symbol = Symbol.for('simple');
export const ITEM_MARGIN_MULTIPLE_TYPE: unique symbol = Symbol.for('multiple');
export const ITEM_HSIZING_FILL: unique symbol = Symbol.for('fill');
export const ITEM_HSIZING_FIX: unique symbol = Symbol.for('fix');
export const ITEM_HSIZING_AUTO: unique symbol = Symbol.for('auto');
export const ITEM_VSIZING_FILL: unique symbol = Symbol.for('fill');
export const ITEM_VSIZING_FIX: unique symbol = Symbol.for('fix');
export const ITEM_VSIZING_AUTO: unique symbol = Symbol.for('auto');
export const ITEM_ALIGN_SELF_START: unique symbol = Symbol.for('start');
export const ITEM_ALIGN_SELF_END: unique symbol = Symbol.for('end');
export const ITEM_ALIGN_SELF_CENTER: unique symbol = Symbol.for('center');
export const ITEM_ALIGN_SELF_STRETCH: unique symbol = Symbol.for('stretch');
export type LayoutChildAttributes = {
layoutItemMarginType?:
| 'simple'
| 'multiple'
| typeof ITEM_MARGIN_SIMPLE_TYPE
| typeof ITEM_MARGIN_MULTIPLE_TYPE;
layoutItemMargin?: {
m1?: number;
m2?: number;
m3?: number;
m4?: number;
};
layoutItemMaxH?: number;
layoutItemMinH?: number;
layoutItemMaxW?: number;
layoutItemMinW?: number;
layoutItemHSizing?:
| 'fill'
| 'fix'
| 'auto'
| typeof ITEM_HSIZING_FILL
| typeof ITEM_HSIZING_FIX
| typeof ITEM_HSIZING_AUTO;
layoutItemVSizing?:
| 'fill'
| 'fix'
| 'auto'
| typeof ITEM_VSIZING_FILL
| typeof ITEM_VSIZING_FIX
| typeof ITEM_VSIZING_AUTO;
layoutItemAlignSelf?:
| 'start'
| 'end'
| 'center'
| 'stretch'
| typeof ITEM_ALIGN_SELF_START
| typeof ITEM_ALIGN_SELF_END
| typeof ITEM_ALIGN_SELF_CENTER
| typeof ITEM_ALIGN_SELF_STRETCH;
layoutItemAbsolute?: boolean;
layoutItemZIndex?: number;
};

View file

@ -0,0 +1,48 @@
export const VECTOR_LINE_TO: unique symbol = Symbol.for('line-to');
export const VECTOR_CLOSE_PATH: unique symbol = Symbol.for('close-path');
export const VECTOR_MOVE_TO: unique symbol = Symbol.for('move-to');
export const VECTOR_CURVE_TO: unique symbol = Symbol.for('curve-to');
export type PathContent = Segment[];
export type Segment = LineTo | ClosePath | MoveTo | CurveTo;
export type Command =
| 'line-to'
| 'close-path'
| 'move-to'
| 'curve-to'
| typeof VECTOR_LINE_TO
| typeof VECTOR_CLOSE_PATH
| typeof VECTOR_MOVE_TO
| typeof VECTOR_CURVE_TO;
type LineTo = {
command: 'line-to' | typeof VECTOR_LINE_TO;
params: {
x: number;
y: number;
};
};
type ClosePath = {
command: 'close-path' | typeof VECTOR_CLOSE_PATH;
};
type MoveTo = {
command: 'move-to' | typeof VECTOR_MOVE_TO;
params: {
x: number;
y: number;
};
};
type CurveTo = {
command: 'curve-to' | typeof VECTOR_CURVE_TO;
params: {
x: number;
y: number;
c1x: number;
c1y: number;
c2x: number;
c2y: number;
};
};

Some files were not shown because too many files have changed in this diff Show more