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

Move logic from UI to Code (#15)

* draft

* wip

* WIP

* fix group

* wip

* add todos

* fix

* fixes

* fixes

* image

* fix

* fix

* calculate adjustment

* text

* text

* improve texts

* fix

* gradient wip

* commented gradient

* simplify code

* cleanup

* more cleanup

---------

Co-authored-by: Alex Sánchez <sion333@gmail.com>
This commit is contained in:
Jordi Sala Morales 2024-04-12 13:55:42 +02:00 committed by GitHub
parent 41c42ce9a6
commit 6c67200dc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 545 additions and 473 deletions

View file

@ -1,55 +0,0 @@
import { PenpotFile } from '../ui/lib/penpot';
export type NodeData = {
id: string;
name: string;
type: string;
children: NodeData[];
x: number;
y: number;
width: number;
height: number;
fills: readonly Paint[];
imageFill?: string;
};
export type TextDataChildren = Pick<
StyledTextSegment,
| 'fills'
| 'characters'
| 'start'
| 'end'
| 'fontSize'
| 'fontName'
| 'fontWeight'
| 'textDecoration'
| 'textCase'
| 'lineHeight'
| 'letterSpacing'
>;
export type TextData = Pick<
NodeData,
'id' | 'name' | 'type' | 'x' | 'y' | 'width' | 'height' | 'fills'
> & {
fontName: FontName;
fontSize: string;
fontWeight: string;
characters: string;
lineHeight: LineHeight;
letterSpacing: LetterSpacing;
textCase: TextCase;
textDecoration: TextDecoration;
textAlignHorizontal: 'CENTER' | 'LEFT' | 'RIGHT' | 'JUSTIFIED';
textAlignVertical: 'CENTER' | 'TOP' | 'BOTTOM';
children: TextDataChildren[];
};
export type ExportFile = {
penpotFile: PenpotFile;
fontNames: Set<FontName>;
};
export interface Signatures {
[key: string]: string;
}

View file

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

View file

@ -1,24 +0,0 @@
import { NodeData, TextData } from '../../common/interfaces';
import { getImageFill, getNodeData, getTextData } from '../utils';
export async function traverse(baseNode: BaseNode): Promise<NodeData | TextData> {
const children: (NodeData | TextData)[] = [];
if ('children' in baseNode && baseNode.type !== 'INSTANCE') {
for (const child of baseNode.children) {
children.push(await traverse(child));
}
}
const nodeData = getNodeData(baseNode, children);
if (nodeData.fills) {
nodeData.imageFill = await getImageFill(baseNode, nodeData);
}
const textNode = getTextData(baseNode, nodeData);
if (textNode) {
return textNode;
}
return nodeData;
}

View file

@ -1,8 +1,8 @@
import { NodeData, TextData } from '../../common/interfaces';
import { traverse } from '../figma';
import { transformDocumentNode } from '../transformers';
export async function handleExportMessage() {
await figma.loadAllPagesAsync(); // ensures all PageNodes are loaded
const root: NodeData | TextData = await traverse(figma.root); // start the traversal at the root
figma.ui.postMessage({ type: 'FIGMAFILE', data: root });
await figma.loadAllPagesAsync();
const penpotNode = await transformDocumentNode(figma.root);
figma.ui.postMessage({ type: 'FIGMAFILE', data: penpotNode });
}

View file

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

View file

@ -0,0 +1,9 @@
import { PenpotDocument } from '../../ui/lib/types/penpotDocument';
import { transformPageNode } from './transformPageNode';
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,18 @@
import { CircleShape } from '../../ui/lib/types/circle/circleShape';
import { translateFills } from '../translators/translateFills';
export const transformEllipseNode = (
node: EllipseNode,
baseX: number,
baseY: number
): CircleShape => {
return {
type: 'circle',
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,22 @@
import { FrameShape } from '../../ui/lib/types/frame/frameShape';
import { translateFills } from '../translators/translateFills';
import { transformSceneNode } from './transformSceneNode';
export const transformFrameNode = async (
node: FrameNode,
baseX: number,
baseY: number
): Promise<FrameShape> => {
return {
type: 'frame',
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),
children: await Promise.all(
node.children.map(child => transformSceneNode(child, baseX + node.x, baseY + node.y))
)
};
};

View file

@ -0,0 +1,14 @@
import { GroupShape } from '../../ui/lib/types/group/groupShape';
import { transformSceneNode } from './transformSceneNode';
export const transformGroupNode = async (
node: GroupNode,
baseX: number,
baseY: number
): Promise<GroupShape> => {
return {
type: 'group',
name: node.name,
children: await Promise.all(node.children.map(child => transformSceneNode(child, baseX, baseY)))
};
};

View file

@ -0,0 +1,29 @@
import { ImageShape } from '../../ui/lib/types/image/imageShape';
import { detectMimeType } from '../utils';
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,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
metadata: {
width: node.width,
height: node.height
},
dataUri: dataUri
};
};

View file

@ -0,0 +1,9 @@
import { PenpotPage } from '../../ui/lib/types/penpotPage';
import { transformSceneNode } from './transformSceneNode';
export const transformPageNode = async (node: PageNode): Promise<PenpotPage> => {
return {
name: node.name,
children: await Promise.all(node.children.map(child => transformSceneNode(child)))
};
};

View file

@ -0,0 +1,18 @@
import { RectShape } from '../../ui/lib/types/rect/rectShape';
import { translateFills } from '../translators/translateFills';
export const transformRectangleNode = (
node: RectangleNode,
baseX: number,
baseY: number
): RectShape => {
return {
type: 'rect',
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,44 @@
import {
transformEllipseNode,
transformFrameNode,
transformGroupNode,
transformImageNode,
transformRectangleNode,
transformTextNode
} from '.';
import { PenpotNode } from '../../ui/lib/types/penpotNode';
import { calculateAdjustment } from '../utils';
export const transformSceneNode = async (
node: SceneNode,
baseX: number = 0,
baseY: number = 0
): Promise<PenpotNode> => {
// @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 'FRAME':
return await transformFrameNode(node, baseX, baseY);
case 'GROUP':
return await transformGroupNode(node, baseX, baseY);
case 'TEXT':
return transformTextNode(node, baseX, baseY);
}
throw new Error(`Unsupported node type: ${node.type}`);
};

View file

@ -0,0 +1,61 @@
import { TextNode as PenpotTextNode } from '../../ui/lib/types/text/textContent';
import { TextShape } from '../../ui/lib/types/text/textShape';
import { translateFills, translateTextDecoration, translateTextTransform } from '../translators';
export const transformTextNode = (node: TextNode, baseX: number, baseY: number): TextShape => {
const styledTextSegments = node.getStyledTextSegments([
'fontName',
'fontSize',
'fontWeight',
'lineHeight',
'letterSpacing',
'textCase',
'textDecoration',
'fills'
]);
const children: PenpotTextNode[] = styledTextSegments.map(segment => {
figma.ui.postMessage({ type: 'FONT_NAME', data: segment.fontName.family });
return {
text: segment.characters,
fills: translateFills(segment.fills, node.width, node.height),
fontFamily: segment.fontName.family,
fontSize: segment.fontSize.toString(),
fontStyle: segment.fontName.style,
fontWeight: segment.fontWeight.toString(),
textDecoration: translateTextDecoration(segment),
textTransform: translateTextTransform(segment)
};
});
return {
type: 'text',
name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
content: {
type: 'root',
children: [
{
type: 'paragraph-set',
children: [
{
type: 'paragraph',
fills: translateFills(node.fills, node.width, node.height),
fontFamily: children[0].fontFamily,
fontSize: children[0].fontSize,
fontStyle: children[0].fontStyle,
fontWeight: children[0].fontWeight,
textDecoration: children[0].textDecoration,
textTransform: children[0].textTransform,
children: children
}
]
}
]
}
};
};

View file

@ -0,0 +1,5 @@
export * from './translateFills';
// export * from './translateGradientLinearFill';
export * from './translateSolidFill';
export * from './translateTextDecoration';
export * from './translateTextTransform';

View file

@ -0,0 +1,43 @@
import { Fill } from '../../ui/lib/types/utils/fill';
// import { translateGradientLinearFill } from './translateGradientLinearFill';
import { translateSolidFill } from './translateSolidFill';
const translateFill = (
fill: Paint,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
width: number,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
height: number
): Fill | undefined => {
switch (fill.type) {
case 'SOLID':
return translateSolidFill(fill);
// @TODO: fix this
// case 'GRADIENT_LINEAR':
// return translateGradientLinearFill(fill, width, height);
}
console.error('Color type ' + fill.type + ' not supported yet');
};
export const translateFills = (
fills: readonly Paint[] | typeof figma.mixed,
width: number,
height: number
): Fill[] => {
// @TODO: think better variable name
// @TODO: make it work with figma.mixed
const fills2 = fills === figma.mixed ? [] : fills;
const penpotFills = [];
for (const fill of fills2) {
const penpotFill = translateFill(fill, width, height);
if (penpotFill) {
penpotFills.unshift(penpotFill);
}
}
return penpotFills;
};

View file

@ -1,6 +1,6 @@
import { extractLinearGradientParamsFromTransform } from '@figma-plugin/helpers';
import { Fill } from '../lib/types/utils/fill';
import { Fill } from '../../ui/lib/types/utils/fill';
import { rgbToHex } from '../utils';
export const translateGradientLinearFill = (
@ -9,9 +9,10 @@ export const translateGradientLinearFill = (
height: number
): Fill => {
const points = extractLinearGradientParamsFromTransform(width, height, fill.gradientTransform);
return {
fillColorGradient: {
type: Symbol.for('linear'),
type: 'linear',
startX: points.start[0] / width,
startY: points.start[1] / height,
endX: points.end[0] / width,

View file

@ -1,4 +1,4 @@
import { Fill } from '../lib/types/utils/fill';
import { Fill } from '../../ui/lib/types/utils/fill';
import { rgbToHex } from '../utils';
export const translateSolidFill = (fill: SolidPaint): Fill => {

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,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

@ -1,4 +1,6 @@
import { Signatures } from '../../common/interfaces';
export interface Signatures {
[key: string]: string;
}
const signatures: Signatures = {
'R0lGODdh': 'image/gif',

View file

@ -1,18 +0,0 @@
import { NodeData } from '../../common/interfaces';
import { detectMimeType } from './detectMimeType';
export async function getImageFill(baseNode: BaseNode, node: NodeData): Promise<string> {
// Find any fill of type image
const imageFill = node.fills.find(fill => fill.type === 'IMAGE');
if (imageFill && 'exportAsync' in baseNode) {
// 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.
const value = await baseNode.exportAsync({ format: 'PNG' });
const b64 = figma.base64Encode(value);
return 'data:' + detectMimeType(b64) + ';base64,' + b64;
}
return '';
}

View file

@ -1,16 +0,0 @@
import { NodeData, TextData } from '../../common/interfaces';
export const getNodeData = (node: BaseNode, children: (NodeData | TextData)[]): NodeData => {
return {
id: node.id,
type: node.type,
name: node.name,
children: children,
x: 'x' in node ? node.x : 0,
y: 'y' in node ? node.y : 0,
width: 'width' in node ? node.width : 0,
height: 'height' in node ? node.height : 0,
fills: 'fills' in node ? (node.fills === figma.mixed ? [] : node.fills) : [], // TODO: Support mixed fills
imageFill: ''
} as NodeData;
};

View file

@ -1,34 +0,0 @@
import { NodeData, TextData } from '../../common/interfaces';
export const getTextData = (baseNode: BaseNode, nodeData: NodeData): TextData | undefined => {
if (baseNode.type === 'TEXT') {
const styledTextSegments = baseNode.getStyledTextSegments([
'fontName',
'fontSize',
'fontWeight',
'lineHeight',
'letterSpacing',
'textCase',
'textDecoration',
'fills'
]);
if (styledTextSegments[0]) {
return {
...nodeData,
fontName: styledTextSegments[0].fontName,
fontSize: styledTextSegments[0].fontSize.toString(),
fontWeight: styledTextSegments[0].fontWeight.toString(),
characters: baseNode.characters,
lineHeight: styledTextSegments[0].lineHeight,
letterSpacing: styledTextSegments[0].letterSpacing,
fills: styledTextSegments[0].fills,
textCase: styledTextSegments[0].textCase,
textDecoration: styledTextSegments[0].textDecoration,
textAlignHorizontal: baseNode.textAlignHorizontal,
textAlignVertical: baseNode.textAlignVertical,
children: styledTextSegments
} as TextData;
}
}
};

View file

@ -1,4 +1,3 @@
export * from './detectMimeType';
export * from './getImageFill';
export * from './getNodeData';
export * from './getTextData';
export * from './rgbToHex';
export * from './calculateAdjustment';

View file

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import slugify from 'slugify';
import { NodeData } from '../common/interfaces';
import { createPenpotFile } from './converters';
import { PenpotDocument } from './lib/types/penpotDocument';
import { validateFont } from './validators';
export const PenpotExporter = () => {
@ -13,19 +13,20 @@ export const PenpotExporter = () => {
setMissingFonts(missingFonts => missingFonts.add(font));
};
const onMessage = (event: MessageEvent<{ pluginMessage: { type: string; data: NodeData } }>) => {
const onMessage = (event: MessageEvent<{ pluginMessage: { type: string; data: unknown } }>) => {
if (event.data.pluginMessage.type == 'FIGMAFILE') {
const file = createPenpotFile(event.data.pluginMessage.data);
const document = event.data.pluginMessage.data as PenpotDocument;
const file = createPenpotFile(document);
file.fontNames.forEach(font => {
if (!validateFont(font)) {
addFontWarning(slugify(font.family.toLowerCase()));
}
});
file.penpotFile.export();
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()));
}
}
};

View file

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

View file

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

View file

@ -1,24 +0,0 @@
import { createPenpotItem } from '.';
import { ExportFile, NodeData } from '../../common/interfaces';
import { translateFills } from '../translators';
export const createPenpotBoard = (
file: ExportFile,
node: NodeData,
baseX: number,
baseY: number
) => {
file.penpotFile.addArtboard({
type: Symbol.for('frame'),
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.penpotFile.closeArtboard();
};

View file

@ -1,19 +1,13 @@
import { ExportFile, NodeData } from '../../common/interfaces';
import { translateFills } from '../translators';
import { PenpotFile } from '../lib/penpot';
import { CIRCLE_TYPE } from '../lib/types/circle/circleAttributes';
import { CircleShape } from '../lib/types/circle/circleShape';
import { translateFillGradients } from '../translators';
export const createPenpotCircle = (
file: ExportFile,
node: NodeData,
baseX: number,
baseY: number
) => {
file.penpotFile.createCircle({
type: Symbol.for('circle'),
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)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const createPenpotCircle = (file: PenpotFile, { type, fills, ...rest }: CircleShape) => {
file.createCircle({
type: CIRCLE_TYPE,
fills: translateFillGradients(fills),
...rest
});
};

View file

@ -1,11 +1,13 @@
import { createPenpotItem } from '.';
import { ExportFile, NodeData } from '../../common/interfaces';
import { createPenpotPage } from '.';
import { createFile } from '../lib/penpot';
import { PenpotDocument } from '../lib/types/penpotDocument';
export const createPenpotFile = (node: NodeData) => {
const exportFile = { penpotFile: createFile(node.name), fontNames: new Set<FontName>() };
for (const page of node.children) {
createPenpotItem(exportFile as ExportFile, page, 0, 0);
export const createPenpotFile = (node: PenpotDocument) => {
const file = createFile(node.name);
for (const page of node.children ?? []) {
createPenpotPage(file, page);
}
return exportFile;
return file;
};

View file

@ -1,20 +1,21 @@
import { createPenpotItem } from '.';
import { ExportFile, NodeData } from '../../common/interfaces';
import { PenpotFile } from '../lib/penpot';
import { GROUP_TYPE } from '../lib/types/group/groupAttributes';
import { GroupShape } from '../lib/types/group/groupShape';
import { createPenpotItem } from './createPenpotItem';
export const createPenpotGroup = (
file: ExportFile,
node: NodeData,
baseX: number,
baseY: number
file: PenpotFile,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
{ type, children = [], ...rest }: GroupShape
) => {
file.penpotFile.addGroup({
type: Symbol.for('group'),
name: node.name
file.addGroup({
type: GROUP_TYPE,
...rest
});
for (const child of node.children) {
createPenpotItem(file, child, baseX, baseY);
for (const child of children) {
createPenpotItem(file, child);
}
file.penpotFile.closeGroup();
file.closeGroup();
};

View file

@ -1,22 +1,11 @@
import { ExportFile, NodeData } from '../../common/interfaces';
import { PenpotFile } from '../lib/penpot';
import { IMAGE_TYPE } from '../lib/types/image/imageAttributes';
import { ImageShape } from '../lib/types/image/imageShape';
export const createPenpotImage = (
file: ExportFile,
node: NodeData,
baseX: number,
baseY: number
) => {
file.penpotFile.createImage({
type: Symbol.for('image'),
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
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const createPenpotImage = (file: PenpotFile, { type, ...rest }: ImageShape) => {
file.createImage({
type: IMAGE_TYPE,
...rest
});
};

View file

@ -1,45 +1,27 @@
import {
createPenpotBoard,
createPenpotArtboard,
createPenpotCircle,
createPenpotGroup,
createPenpotImage,
createPenpotPage,
createPenpotRectangle,
createPenpotText
} from '.';
import { ExportFile, NodeData, TextData } from '../../common/interfaces';
import { calculateAdjustment } from '../utils';
import { PenpotFile } from '../lib/penpot';
import { PenpotNode } from '../lib/types/penpotNode';
export const createPenpotItem = (
file: ExportFile,
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);
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 'text':
return createPenpotText(file, node);
}
};

View file

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

View file

@ -1,19 +1,13 @@
import { ExportFile, NodeData } from '../../common/interfaces';
import { translateFills } from '../translators';
import { PenpotFile } from '../lib/penpot';
import { RECT_TYPE } from '../lib/types/rect/rectAttributes';
import { RectShape } from '../lib/types/rect/rectShape';
import { translateFillGradients } from '../translators';
export const createPenpotRectangle = (
file: ExportFile,
node: NodeData,
baseX: number,
baseY: number
) => {
file.penpotFile.createRect({
type: Symbol.for('rect'),
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)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const createPenpotRectangle = (file: PenpotFile, { type, fills, ...rest }: RectShape) => {
file.createRect({
type: RECT_TYPE,
fills: translateFillGradients(fills),
...rest
});
};

View file

@ -1,73 +1,14 @@
// import slugify from 'slugify';
import { ExportFile, TextData } from '../../common/interfaces';
import { TextNode } from '../lib/types/text/textContent';
import {
translateFills, // translateFontStyle,
// translateHorizontalAlign,
translateTextDecoration,
translateTextTransform // translateVerticalAlign
} from '../translators';
import { PenpotFile } from '../lib/penpot';
import { TEXT_TYPE } from '../lib/types/text/textAttributes';
import { TextShape } from '../lib/types/text/textShape';
export const createPenpotText = (
file: ExportFile,
node: TextData,
baseX: number,
baseY: number
file: PenpotFile,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
{ type, ...rest }: TextShape
) => {
const children = node.children.map(val => {
file.fontNames.add(val.fontName);
return {
text: val.characters,
fills: translateFills(val.fills, node.width, node.height),
fontFamily: val.fontName.family,
fontSize: val.fontSize.toString(),
fontStyle: val.fontName.style,
fontWeight: val.fontWeight.toString(),
textDecoration: translateTextDecoration(val),
textTransform: translateTextTransform(val)
// lineHeight: val.lineHeight,
// textAlign: translateHorizontalAlign(node.textAlignHorizontal),
// fontId: 'gfont-' + slugify(val.fontName.family.toLowerCase()),
// fontVariantId: translateFontStyle(val.fontName.style),
// letterSpacing: val.letterSpacing,
} as TextNode;
});
file.fontNames.add(node.fontName);
file.penpotFile.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: [
{
type: 'paragraph',
fills: translateFills(node.fills, node.width, node.height),
fontFamily: node.fontName.family,
fontSize: node.fontSize.toString(),
fontStyle: node.fontName.style,
fontWeight: node.fontWeight.toString(),
textDecoration: translateTextDecoration(node),
textTransform: translateTextTransform(node),
children: children
// lineHeight: node.lineHeight,
// textAlign: translateHorizontalAlign(node.textAlignHorizontal),
// fontId: 'gfont-' + slugify(node.fontName.family.toLowerCase()),
// letterSpacing: node.letterSpacing,
}
]
}
]
}
file.createText({
type: TEXT_TYPE,
...rest
});
};

View file

@ -1,4 +1,4 @@
export * from './createPenpotBoard';
export * from './createPenpotArtboard';
export * from './createPenpotCircle';
export * from './createPenpotFile';
export * from './createPenpotGroup';

View file

@ -1,6 +0,0 @@
import { Uuid } from '../utils/uuid';
export type CircleAttributes = {
id?: Uuid;
type: symbol;
};

View file

@ -0,0 +1,8 @@
import { Uuid } from '../utils/uuid';
export const CIRCLE_TYPE: unique symbol = Symbol.for('circle');
export type CircleAttributes = {
type: 'circle' | typeof CIRCLE_TYPE;
id?: Uuid;
};

View file

@ -1,8 +1,10 @@
import { Uuid } from '../utils/uuid';
export const FRAME_TYPE: unique symbol = Symbol.for('frame');
export type FrameAttributes = {
type: 'frame' | typeof FRAME_TYPE;
id?: Uuid;
type: symbol;
shapes?: Uuid[];
fileThumbnail?: boolean;
hideFillOnExport?: boolean;

View file

@ -1,4 +1,5 @@
import { Shape } from '../shape';
import { Children } from '../utils/children';
import { FrameAttributes } from './frameAttributes';
export type FrameShape = Shape & FrameAttributes;
export type FrameShape = Shape & FrameAttributes & Children;

View file

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

View file

@ -1,4 +1,5 @@
import { Shape } from '../shape';
import { Children } from '../utils/children';
import { GroupAttributes } from './groupAttributes';
export type GroupShape = Shape & GroupAttributes;
export type GroupShape = Shape & GroupAttributes & Children;

View file

@ -1,9 +1,10 @@
import { Uuid } from '../utils/uuid';
export const IMAGE_TYPE: unique symbol = Symbol.for('image');
export type ImageAttributes = {
id?: Uuid;
type: symbol;
// TODO: Investigate where it comes from
type: 'image' | typeof IMAGE_TYPE;
dataUri?: string;
metadata: {
width: number;

6
src/ui/lib/types/penpotDocument.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
import { PenpotPage } from './penpotPage';
export type PenpotDocument = {
name: string;
children?: PenpotPage[];
};

8
src/ui/lib/types/penpotNode.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
import { CircleShape } from './circle/circleShape';
import { FrameShape } from './frame/frameShape';
import { GroupShape } from './group/groupShape';
import { ImageShape } from './image/imageShape';
import { RectShape } from './rect/rectShape';
import { TextShape } from './text/textShape';
export type PenpotNode = FrameShape | GroupShape | RectShape | CircleShape | TextShape | ImageShape;

6
src/ui/lib/types/penpotPage.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
import { PenpotNode } from './penpotNode';
export type PenpotPage = {
name: string;
children?: PenpotNode[];
};

View file

@ -1,6 +0,0 @@
import { Uuid } from '../utils/uuid';
export type RectAttributes = {
id?: Uuid;
type: symbol;
};

View file

@ -0,0 +1,8 @@
import { Uuid } from '../utils/uuid';
export const RECT_TYPE: unique symbol = Symbol.for('rect');
export type RectAttributes = {
type: 'rect' | typeof RECT_TYPE;
id?: Uuid;
};

View file

@ -1,8 +1,10 @@
import { Uuid } from '../utils/uuid';
import { TextContent } from './textContent';
export const TEXT_TYPE: unique symbol = Symbol.for('text');
export type TextAttributes = {
id?: Uuid;
type: symbol;
type: 'text' | typeof TEXT_TYPE;
content?: TextContent;
};

3
src/ui/lib/types/utils/children.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
import { PenpotNode } from '../penpotNode';
export type Children = { children?: PenpotNode[] };

View file

@ -1,5 +1,8 @@
export const LINEAR_TYPE: unique symbol = Symbol.for('linear');
export const RADIAL_TYPE: unique symbol = Symbol.for('radial');
export type Gradient = {
type: symbol; // linear or radial
type: 'linear' | 'radial' | typeof LINEAR_TYPE | typeof RADIAL_TYPE; // symbol
startX: number;
startY: number;
endX: number;

View file

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

View file

@ -0,0 +1,13 @@
import { createGradientFill } from '../converters/createGradientFill';
import { Fill } from '../lib/types/utils/fill';
export const translateFillGradients = (fills?: Fill[]): Fill[] | undefined => {
if (!fills) return fills;
return fills.map(fill => {
if (fill.fillColorGradient) {
fill.fillColorGradient = createGradientFill(fill.fillColorGradient);
}
return fill;
});
};

View file

@ -1,26 +0,0 @@
import { translateGradientLinearFill, translateSolidFill } from '.';
import { Fill } from '../lib/types/utils/fill';
const translateFill = (fill: Paint, width: number, height: number): Fill | null => {
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): Fill[] => {
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

@ -1,12 +0,0 @@
import { TextData, TextDataChildren } from '../../common/interfaces';
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

@ -1,15 +0,0 @@
import { TextData, TextDataChildren } from '../../common/interfaces';
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

@ -1,17 +0,0 @@
import { NodeData } from '../../common/interfaces';
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];
};

View file

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

View file

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