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 #6 from Runroom/feature/converters

Updated structure
This commit is contained in:
Alex Sánchez 2024-04-08 17:51:42 +02:00 committed by GitHub
commit 9d1e0d2abc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 420 additions and 316 deletions

View file

@ -0,0 +1,24 @@
import { createPenpotItem } from '.';
import { NodeData } from '../interfaces';
import { PenpotFile } from '../penpot';
import { translateFills } from '../translators';
export const createPenpotBoard = (
file: PenpotFile,
node: NodeData,
baseX: number,
baseY: number
) => {
file.addArtboard({
name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
fills: translateFills(node.fills /*, node.width, node.height*/)
});
for (const child of node.children) {
createPenpotItem(file, child, node.x + baseX, node.y + baseY);
}
file.closeArtboard();
};

View file

@ -0,0 +1,19 @@
import { NodeData } from '../interfaces';
import { PenpotFile } from '../penpot';
import { translateFills } from '../translators';
export const createPenpotCircle = (
file: PenpotFile,
node: NodeData,
baseX: number,
baseY: number
) => {
file.createCircle({
name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
fills: translateFills(node.fills /*, node.width, node.height*/)
});
};

View file

@ -0,0 +1,11 @@
import { createPenpotItem } from '.';
import { NodeData } from '../interfaces';
import { createFile } from '../penpot';
export const createPenpotFile = (node: NodeData) => {
const file = createFile(node.name);
for (const page of node.children) {
createPenpotItem(file, page, 0, 0);
}
return file;
};

View file

@ -0,0 +1,16 @@
import { createPenpotItem } from '.';
import { NodeData } from '../interfaces';
import { PenpotFile } from '../penpot';
export const createPenpotGroup = (
file: PenpotFile,
node: NodeData,
baseX: number,
baseY: number
) => {
file.addGroup({ name: node.name });
for (const child of node.children) {
createPenpotItem(file, child, baseX, baseY);
}
file.closeGroup();
};

View file

@ -0,0 +1,22 @@
import { NodeData } from '../interfaces';
import { PenpotFile } from '../penpot';
export const createPenpotImage = (
file: PenpotFile,
node: NodeData,
baseX: number,
baseY: number
) => {
file.createImage({
name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
metadata: {
width: node.width,
height: node.height
},
dataUri: node.imageFill
});
};

View file

@ -0,0 +1,46 @@
import {
createPenpotBoard,
createPenpotCircle,
createPenpotGroup,
createPenpotImage,
createPenpotPage,
createPenpotRectangle,
createPenpotText
} from '.';
import { NodeData, TextData } from '../interfaces';
import { PenpotFile } from '../penpot';
import { calculateAdjustment } from '../utils';
export const createPenpotItem = (
file: PenpotFile,
node: NodeData,
baseX: number,
baseY: number
) => {
// 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: Paint) => 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] = calculateAdjustment(node);
createPenpotImage(file, node, baseX + adjustedX, baseY + adjustedY);
} else if (node.type == 'PAGE') {
createPenpotPage(file, node);
} else if (node.type == 'FRAME') {
createPenpotBoard(file, node, baseX, baseY);
} else if (node.type == 'GROUP') {
createPenpotGroup(file, node, baseX, baseY);
} else if (node.type == 'RECTANGLE') {
createPenpotRectangle(file, node, baseX, baseY);
} else if (node.type == 'ELLIPSE') {
createPenpotCircle(file, node, baseX, baseY);
} else if (node.type == 'TEXT') {
createPenpotText(file, node as unknown as TextData, baseX, baseY);
}
};

View file

@ -0,0 +1,11 @@
import { createPenpotItem } from '.';
import { NodeData } from '../interfaces';
import { PenpotFile } from '../penpot';
export const createPenpotPage = (file: PenpotFile, node: NodeData) => {
file.addPage(node.name);
for (const child of node.children) {
createPenpotItem(file, child, 0, 0);
}
file.closePage();
};

View file

@ -0,0 +1,19 @@
import { NodeData } from '../interfaces';
import { PenpotFile } from '../penpot';
import { translateFills } from '../translators';
export const createPenpotRectangle = (
file: PenpotFile,
node: NodeData,
baseX: number,
baseY: number
) => {
file.createRect({
name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
fills: translateFills(node.fills /*, node.width, node.height*/)
});
};

View file

@ -0,0 +1,78 @@
import slugify from 'slugify';
import { TextData } from '../interfaces';
import { PenpotFile } from '../penpot';
import {
translateFills,
translateFontStyle,
translateHorizontalAlign,
translateTextDecoration,
translateTextTransform,
translateVerticalAlign
} from '../translators';
import { validateFont } from '../validators';
export const createPenpotText = (
file: PenpotFile,
node: TextData,
baseX: number,
baseY: number
) => {
const children = node.children.map(val => {
validateFont(val.fontName);
return {
lineHeight: val.lineHeight,
fontStyle: 'normal',
textAlign: translateHorizontalAlign(node.textAlignHorizontal),
fontId: 'gfont-' + slugify(val.fontName.family.toLowerCase()),
fontSize: val.fontSize.toString(),
fontWeight: val.fontWeight.toString(),
fontVariantId: translateFontStyle(val.fontName.style),
textDecoration: translateTextDecoration(val),
textTransform: translateTextTransform(val),
letterSpacing: val.letterSpacing,
fills: translateFills(val.fills /*, node.width, node.height*/),
fontFamily: val.fontName.family,
text: val.characters
};
});
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: translateVerticalAlign(node.textAlignVertical),
children: [
{
type: 'paragraph-set',
children: [
{
lineHeight: node.lineHeight,
fontStyle: 'normal',
children: children,
textTransform: translateTextTransform(node),
textAlign: translateHorizontalAlign(node.textAlignHorizontal),
fontId: 'gfont-' + slugify(node.fontName.family.toLowerCase()),
fontSize: node.fontSize.toString(),
fontWeight: node.fontWeight.toString(),
type: 'paragraph',
textDecoration: translateTextDecoration(node),
letterSpacing: node.letterSpacing,
fills: translateFills(node.fills /*, node.width, node.height*/),
fontFamily: node.fontName.family
}
]
}
]
}
});
};

9
src/converters/index.ts Normal file
View file

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

8
src/translators/index.ts Normal file
View file

@ -0,0 +1,8 @@
export * from './translateFills';
export * from './translateFontStyle';
export * from './translateGradientLinearFill';
export * from './translateHorizontalAlign';
export * from './translateSolidFill';
export * from './translateTextDecoration';
export * from './translateTextTransform';
export * from './translateVerticalAlign';

View file

@ -0,0 +1,25 @@
import { translateGradientLinearFill, translateSolidFill } from '.';
const translateFill = (fill: Paint /*, width: number, height: number*/) => {
if (fill.type === 'SOLID') {
return translateSolidFill(fill);
} else if (fill.type === 'GRADIENT_LINEAR') {
return translateGradientLinearFill(fill /*, width, height*/);
} else {
console.error('Color type ' + fill.type + ' not supported yet');
return null;
}
};
export const translateFills = (fills: readonly Paint[] /*, width: number, height: number*/) => {
const penpotFills = [];
let penpotFill = null;
for (const fill of fills) {
penpotFill = translateFill(fill /*, width, height*/);
if (penpotFill !== null) {
penpotFills.unshift(penpotFill);
}
}
return penpotFills;
};

View file

@ -0,0 +1,3 @@
export const translateFontStyle = (style: string) => {
return style.toLowerCase().replace(/\s/g, '');
};

View file

@ -0,0 +1,30 @@
import { rgbToHex } from '../utils';
export const translateGradientLinearFill = (
fill: GradientPaint /*, width: number, height: number*/
) => {
// 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: 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,9 @@
export const translateHorizontalAlign = (align: string) => {
if (align === 'RIGHT') {
return Symbol.for('right');
}
if (align === 'CENTER') {
return Symbol.for('center');
}
return Symbol.for('left');
};

View file

@ -0,0 +1,8 @@
import { rgbToHex } from '../utils';
export const translateSolidFill = (fill: SolidPaint) => {
return {
fillColor: rgbToHex(fill.color),
fillOpacity: fill.visible === false ? 0 : fill.opacity
};
};

View file

@ -0,0 +1,12 @@
import { TextData, TextDataChildren } from '../interfaces.js';
export const translateTextDecoration = (node: TextData | TextDataChildren) => {
const textDecoration = node.textDecoration;
if (textDecoration === 'STRIKETHROUGH') {
return 'line-through';
}
if (textDecoration === 'UNDERLINE') {
return 'underline';
}
return 'none';
};

View file

@ -0,0 +1,15 @@
import { TextData, TextDataChildren } from '../interfaces.js';
export const translateTextTransform = (node: TextData | TextDataChildren) => {
const textCase = node.textCase;
if (textCase === 'UPPER') {
return 'uppercase';
}
if (textCase === 'LOWER') {
return 'lowercase';
}
if (textCase === 'TITLE') {
return 'capitalize';
}
return 'none';
};

View file

@ -0,0 +1,9 @@
export const translateVerticalAlign = (align: string) => {
if (align === 'BOTTOM') {
return Symbol.for('bottom');
}
if (align === 'CENTER') {
return Symbol.for('center');
}
return Symbol.for('top');
};

View file

@ -1,14 +1,9 @@
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import slugify from 'slugify';
import fonts from './gfonts.json';
import { NodeData, TextData, TextDataChildren } from './interfaces';
import { PenpotFile, createFile } from './penpot';
import { createPenpotFile } from './converters';
import './ui.css';
const gfonts = new Set(fonts);
type PenpotExporterState = {
missingFonts: Set<string>;
exporting: boolean;
@ -32,315 +27,13 @@ export default class PenpotExporter extends React.Component<unknown, PenpotExpor
window.removeEventListener('message', this.onMessage);
};
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);
};
translateSolidFill(fill: SolidPaint) {
return {
fillColor: this.rgbToHex(fill.color),
fillOpacity: fill.visible === false ? 0 : fill.opacity
};
}
translateGradientLinearFill(fill: GradientPaint /*, width: number, height: number*/) {
// 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 ?? 1)
},
{
color: this.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
};
}
translateFill(fill: Paint /*, width: number, height: number*/) {
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: readonly Paint[] /*, width: number, height: number*/) {
const penpotFills = [];
let penpotFill = null;
for (const fill of fills) {
penpotFill = this.translateFill(fill /*, width, height*/);
if (penpotFill !== null) {
penpotFills.unshift(penpotFill);
}
}
return penpotFills;
}
addFontWarning(font: string) {
const newMissingFonts = this.state.missingFonts;
newMissingFonts.add(font);
this.setState(() => ({ missingFonts: newMissingFonts }));
}
createPenpotPage(file: PenpotFile, node: NodeData) {
file.addPage(node.name);
for (const child of node.children) {
this.createPenpotItem(file, child, 0, 0);
}
file.closePage();
}
createPenpotBoard(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
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 (const child of node.children) {
this.createPenpotItem(file, child, node.x + baseX, node.y + baseY);
}
file.closeArtboard();
}
createPenpotGroup(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
file.addGroup({ name: node.name });
for (const child of node.children) {
this.createPenpotItem(file, child, baseX, baseY);
}
file.closeGroup();
}
createPenpotRectangle(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
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: PenpotFile, node: NodeData, baseX: number, baseY: number) {
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: TextData | TextDataChildren) {
const textDecoration = node.textDecoration;
if (textDecoration === 'STRIKETHROUGH') {
return 'line-through';
}
if (textDecoration === 'UNDERLINE') {
return 'underline';
}
return 'none';
}
getTextTransform(node: TextData | TextDataChildren) {
const textCase = node.textCase;
if (textCase === 'UPPER') {
return 'uppercase';
}
if (textCase === 'LOWER') {
return 'lowercase';
}
if (textCase === 'TITLE') {
return 'capitalize';
}
return 'none';
}
validateFont(fontName: FontName) {
const name = slugify(fontName.family.toLowerCase());
if (!gfonts.has(name)) {
this.addFontWarning(name);
}
}
createPenpotText(file: PenpotFile, node: TextData, baseX: number, baseY: number) {
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: PenpotFile, node: NodeData, baseX: number, baseY: number) {
file.createImage({
name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
metadata: {
width: node.width,
height: node.height
},
dataUri: node.imageFill
});
}
calculateAdjustment(node: NodeData) {
// 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 (const child of node.children) {
if (child.x < adjustedX) {
adjustedX = child.x;
}
if (child.y < adjustedY) {
adjustedY = child.y;
}
}
return [adjustedX, adjustedY];
}
createPenpotItem(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
// 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: Paint) => 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);
this.createPenpotImage(file, node, baseX + adjustedX, baseY + adjustedY);
} 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 as unknown as TextData, baseX, baseY);
}
}
createPenpotFile(node: NodeData) {
const file = createFile(node.name);
for (const page of node.children) {
this.createPenpotItem(file, page, 0, 0);
}
return file;
}
// TODO: FIX THIS CODE
// addFontWarning(font: string) {
// const newMissingFonts = this.state.missingFonts;
// newMissingFonts.add(font);
//
// this.setState(() => ({ missingFonts: newMissingFonts }));
// }
onCreatePenpot = () => {
this.setState(() => ({ exporting: true }));
@ -354,7 +47,7 @@ export default class PenpotExporter extends React.Component<unknown, PenpotExpor
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onMessage = (event: any) => {
if (event.data.pluginMessage.type == 'FIGMAFILE') {
const file = this.createPenpotFile(event.data.pluginMessage.data);
const file = createPenpotFile(event.data.pluginMessage.data);
file.export();
this.setState(() => ({ exporting: false }));
}

View file

@ -0,0 +1,17 @@
import { NodeData } from '../interfaces.js';
export const calculateAdjustment = (node: NodeData) => {
// 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 (const child of node.children) {
if (child.x < adjustedX) {
adjustedX = child.x;
}
if (child.y < adjustedY) {
adjustedY = child.y;
}
}
return [adjustedX, adjustedY];
};

2
src/utils/index.ts Normal file
View file

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

7
src/utils/rgbToHex.ts Normal file
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);
};

1
src/validators/index.ts Normal file
View file

@ -0,0 +1 @@
export * from './validateFont';

View file

@ -0,0 +1,10 @@
import slugify from 'slugify';
import fonts from '../gfonts.json';
const gfonts = new Set(fonts);
export const validateFont = (fontName: FontName): boolean => {
const name = slugify(fontName.family.toLowerCase());
return gfonts.has(name);
};