mirror of
https://github.com/penpot/penpot-exporter-figma-plugin.git
synced 2024-12-22 13:43:03 -05:00
Merge pull request #6 from Runroom/feature/converters
Updated structure
This commit is contained in:
commit
9d1e0d2abc
25 changed files with 420 additions and 316 deletions
24
src/converters/createPenpotBoard.ts
Normal file
24
src/converters/createPenpotBoard.ts
Normal 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();
|
||||||
|
};
|
19
src/converters/createPenpotCircle.ts
Normal file
19
src/converters/createPenpotCircle.ts
Normal 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*/)
|
||||||
|
});
|
||||||
|
};
|
11
src/converters/createPenpotFile.ts
Normal file
11
src/converters/createPenpotFile.ts
Normal 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;
|
||||||
|
};
|
16
src/converters/createPenpotGroup.ts
Normal file
16
src/converters/createPenpotGroup.ts
Normal 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();
|
||||||
|
};
|
22
src/converters/createPenpotImage.ts
Normal file
22
src/converters/createPenpotImage.ts
Normal 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
|
||||||
|
});
|
||||||
|
};
|
46
src/converters/createPenpotItem.ts
Normal file
46
src/converters/createPenpotItem.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
11
src/converters/createPenpotPage.ts
Normal file
11
src/converters/createPenpotPage.ts
Normal 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();
|
||||||
|
};
|
19
src/converters/createPenpotRectangle.ts
Normal file
19
src/converters/createPenpotRectangle.ts
Normal 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*/)
|
||||||
|
});
|
||||||
|
};
|
78
src/converters/createPenpotText.ts
Normal file
78
src/converters/createPenpotText.ts
Normal 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
9
src/converters/index.ts
Normal 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
8
src/translators/index.ts
Normal 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';
|
25
src/translators/translateFills.ts
Normal file
25
src/translators/translateFills.ts
Normal 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;
|
||||||
|
};
|
3
src/translators/translateFontStyle.ts
Normal file
3
src/translators/translateFontStyle.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export const translateFontStyle = (style: string) => {
|
||||||
|
return style.toLowerCase().replace(/\s/g, '');
|
||||||
|
};
|
30
src/translators/translateGradientLinearFill.ts
Normal file
30
src/translators/translateGradientLinearFill.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
9
src/translators/translateHorizontalAlign.ts
Normal file
9
src/translators/translateHorizontalAlign.ts
Normal 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');
|
||||||
|
};
|
8
src/translators/translateSolidFill.ts
Normal file
8
src/translators/translateSolidFill.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
12
src/translators/translateTextDecoration.ts
Normal file
12
src/translators/translateTextDecoration.ts
Normal 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';
|
||||||
|
};
|
15
src/translators/translateTextTransform.ts
Normal file
15
src/translators/translateTextTransform.ts
Normal 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';
|
||||||
|
};
|
9
src/translators/translateVerticalAlign.ts
Normal file
9
src/translators/translateVerticalAlign.ts
Normal 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');
|
||||||
|
};
|
325
src/ui.tsx
325
src/ui.tsx
|
@ -1,14 +1,9 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import slugify from 'slugify';
|
|
||||||
|
|
||||||
import fonts from './gfonts.json';
|
import { createPenpotFile } from './converters';
|
||||||
import { NodeData, TextData, TextDataChildren } from './interfaces';
|
|
||||||
import { PenpotFile, createFile } from './penpot';
|
|
||||||
import './ui.css';
|
import './ui.css';
|
||||||
|
|
||||||
const gfonts = new Set(fonts);
|
|
||||||
|
|
||||||
type PenpotExporterState = {
|
type PenpotExporterState = {
|
||||||
missingFonts: Set<string>;
|
missingFonts: Set<string>;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
|
@ -32,315 +27,13 @@ export default class PenpotExporter extends React.Component<unknown, PenpotExpor
|
||||||
window.removeEventListener('message', this.onMessage);
|
window.removeEventListener('message', this.onMessage);
|
||||||
};
|
};
|
||||||
|
|
||||||
rgbToHex = (color: RGB) => {
|
// TODO: FIX THIS CODE
|
||||||
const r = Math.round(255 * color.r);
|
// addFontWarning(font: string) {
|
||||||
const g = Math.round(255 * color.g);
|
// const newMissingFonts = this.state.missingFonts;
|
||||||
const b = Math.round(255 * color.b);
|
// newMissingFonts.add(font);
|
||||||
const rgb = (r << 16) | (g << 8) | (b << 0);
|
//
|
||||||
return '#' + (0x1000000 + rgb).toString(16).slice(1);
|
// this.setState(() => ({ missingFonts: newMissingFonts }));
|
||||||
};
|
// }
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
onCreatePenpot = () => {
|
onCreatePenpot = () => {
|
||||||
this.setState(() => ({ exporting: true }));
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onMessage = (event: any) => {
|
onMessage = (event: any) => {
|
||||||
if (event.data.pluginMessage.type == 'FIGMAFILE') {
|
if (event.data.pluginMessage.type == 'FIGMAFILE') {
|
||||||
const file = this.createPenpotFile(event.data.pluginMessage.data);
|
const file = createPenpotFile(event.data.pluginMessage.data);
|
||||||
file.export();
|
file.export();
|
||||||
this.setState(() => ({ exporting: false }));
|
this.setState(() => ({ exporting: false }));
|
||||||
}
|
}
|
||||||
|
|
17
src/utils/calculateAdjustment.ts
Normal file
17
src/utils/calculateAdjustment.ts
Normal 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
2
src/utils/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './calculateAdjustment';
|
||||||
|
export * from './rgbToHex';
|
7
src/utils/rgbToHex.ts
Normal file
7
src/utils/rgbToHex.ts
Normal 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
1
src/validators/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './validateFont';
|
10
src/validators/validateFont.ts
Normal file
10
src/validators/validateFont.ts
Normal 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);
|
||||||
|
};
|
Loading…
Reference in a new issue