0
Fork 0
mirror of https://github.com/penpot/penpot-exporter-figma-plugin.git synced 2024-12-21 21:23:06 -05:00

Optimize images before generating zip file (#141)

* Optimize images before generating zip file

* add changelog

* refactor

* fix lint
This commit is contained in:
Jordi Sala Morales 2024-06-05 12:36:49 +02:00 committed by GitHub
parent 4b711b3526
commit 3094f05e98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 324 additions and 223 deletions

View file

@ -0,0 +1,5 @@
---
"penpot-exporter": minor
---
Optimize images before generating zip file

View file

@ -1,21 +1,19 @@
import { ImageColor } from '@ui/lib/types/utils/imageColor';
class ImageLibrary {
private images: Record<string, ImageColor> = {};
private images: Record<string, Image | null> = {};
public register(hash: string, image: ImageColor) {
public register(hash: string, image: Image | null) {
this.images[hash] = image;
}
public get(hash: string): ImageColor | undefined {
public get(hash: string): Image | null | undefined {
return this.images[hash];
}
public all(): Record<string, ImageColor> {
public all(): Record<string, Image | null> {
return this.images;
}
public init(images: Record<string, ImageColor>): void {
public init(images: Record<string, Image | null>): void {
this.images = images;
}
}

View file

@ -2,10 +2,10 @@ import { translateFills } from '@plugin/translators/fills';
import { ShapeAttributes } from '@ui/lib/types/shapes/shape';
export const transformFills = async (
export const transformFills = (
node: MinimalFillsMixin & DimensionAndPositionMixin
): Promise<Pick<ShapeAttributes, 'fills'>> => {
): Pick<ShapeAttributes, 'fills'> => {
return {
fills: await translateFills(node.fills)
fills: translateFills(node.fills)
};
};

View file

@ -13,9 +13,9 @@ const hasFillGeometry = (node: GeometryMixin): boolean => {
return node.fillGeometry.length > 0;
};
export const transformStrokes = async (
export const transformStrokes = (
node: GeometryMixin | (GeometryMixin & IndividualStrokesMixin)
): Promise<Pick<ShapeAttributes, 'strokes'>> => {
): Pick<ShapeAttributes, 'strokes'> => {
const vectorNetwork = isVectorLike(node) ? node.vectorNetwork : undefined;
const strokeCaps = (stroke: Stroke) => {
@ -30,15 +30,15 @@ export const transformStrokes = async (
};
return {
strokes: await translateStrokes(node, strokeCaps)
strokes: translateStrokes(node, strokeCaps)
};
};
export const transformStrokesFromVector = async (
export const transformStrokesFromVector = (
node: VectorNode,
vector: Command[],
vectorRegion: VectorRegion | undefined
): Promise<Pick<ShapeAttributes, 'strokes'>> => {
): Pick<ShapeAttributes, 'strokes'> => {
const strokeCaps = (stroke: Stroke) => {
if (vectorRegion !== undefined) return stroke;
@ -54,7 +54,7 @@ export const transformStrokesFromVector = async (
};
return {
strokes: await translateStrokes(node, strokeCaps)
strokes: translateStrokes(node, strokeCaps)
};
};

View file

@ -4,9 +4,7 @@ import { translateGrowType, translateVerticalAlign } from '@plugin/translators/t
import { TextAttributes, TextShape } from '@ui/lib/types/shapes/textShape';
export const transformText = async (
node: TextNode
): Promise<TextAttributes & Pick<TextShape, 'growType'>> => {
export const transformText = (node: TextNode): TextAttributes & Pick<TextShape, 'growType'> => {
const styledTextSegments = node.getStyledTextSegments([
'fontName',
'fontSize',
@ -31,9 +29,9 @@ export const transformText = async (
children: [
{
type: 'paragraph',
children: await translateStyleTextSegments(node, styledTextSegments),
children: translateStyleTextSegments(node, styledTextSegments),
...transformTextStyle(node, styledTextSegments[0]),
...(await transformFills(node))
...transformFills(node)
}
]
}

View file

@ -31,40 +31,30 @@ export const transformVectorPathsAsContent = (
};
};
export const transformVectorPaths = async (
export const transformVectorPaths = (
node: VectorNode,
baseX: number,
baseY: number
): Promise<PathShape[]> => {
const pathShapes = await Promise.all(
node.vectorPaths
.filter((vectorPath, index) => {
return (
nodeHasFills(node, vectorPath, (node.vectorNetwork.regions ?? [])[index]) ||
node.strokes.length > 0
);
})
.map((vectorPath, index) =>
transformVectorPath(
node,
vectorPath,
(node.vectorNetwork.regions ?? [])[index],
baseX,
baseY
)
)
);
): PathShape[] => {
const pathShapes = node.vectorPaths
.filter((vectorPath, index) => {
return (
nodeHasFills(node, vectorPath, (node.vectorNetwork.regions ?? [])[index]) ||
node.strokes.length > 0
);
})
.map((vectorPath, index) =>
transformVectorPath(node, vectorPath, (node.vectorNetwork.regions ?? [])[index], baseX, baseY)
);
const geometryShapes = await Promise.all(
node.fillGeometry
.filter(
geometry =>
!node.vectorPaths.find(
vectorPath => normalizePath(vectorPath.data) === normalizePath(geometry.data)
)
)
.map(geometry => transformVectorPath(node, geometry, undefined, baseX, baseY))
);
const geometryShapes = node.fillGeometry
.filter(
geometry =>
!node.vectorPaths.find(
vectorPath => normalizePath(vectorPath.data) === normalizePath(geometry.data)
)
)
.map(geometry => transformVectorPath(node, geometry, undefined, baseX, baseY));
return [...geometryShapes, ...pathShapes];
};
@ -96,13 +86,13 @@ const nodeHasFills = (
return !!(vectorPath.windingRule !== 'NONE' && (vectorRegion?.fills || node.fills));
};
const transformVectorPath = async (
const transformVectorPath = (
node: VectorNode,
vectorPath: VectorPath,
vectorRegion: VectorRegion | undefined,
baseX: number,
baseY: number
): Promise<PathShape> => {
): PathShape => {
const normalizedPaths = parseSVG(vectorPath.data);
return {
@ -110,13 +100,11 @@ const transformVectorPath = async (
name: 'svg-path',
content: translateCommandsToSegments(normalizedPaths, baseX + node.x, baseY + node.y),
fills:
vectorPath.windingRule === 'NONE'
? []
: await translateFills(vectorRegion?.fills ?? node.fills),
vectorPath.windingRule === 'NONE' ? [] : translateFills(vectorRegion?.fills ?? node.fills),
svgAttrs: {
fillRule: translateWindingRule(vectorPath.windingRule)
},
...(await transformStrokesFromVector(node, normalizedPaths, vectorRegion)),
...transformStrokesFromVector(node, normalizedPaths, vectorRegion),
...transformEffects(node),
...transformDimensionAndPositionFromVectorPath(vectorPath, baseX, baseY),
...transformSceneNode(node),

View file

@ -24,9 +24,9 @@ export const transformBooleanNode = async (
boolType: translateBoolType(node.booleanOperation),
...transformFigmaIds(node),
...(await transformChildren(node, baseX, baseY)),
...(await transformFills(node)),
...transformFills(node),
...transformEffects(node),
...(await transformStrokes(node)),
...transformStrokes(node),
...transformDimensionAndPosition(node, baseX, baseY),
...transformSceneNode(node),
...transformBlend(node),

View file

@ -24,9 +24,9 @@ export const transformComponentNode = async (
name: node.name,
path: node.parent?.type === 'COMPONENT_SET' ? node.parent.name : '',
...transformFigmaIds(node),
...(await transformFills(node)),
...transformFills(node),
...transformEffects(node),
...(await transformStrokes(node)),
...transformStrokes(node),
...transformSceneNode(node),
...transformBlend(node),
...transformProportion(node),

View file

@ -28,10 +28,20 @@ export const transformDocumentNode = async (node: DocumentNode): Promise<PenpotD
await sleep(0);
}
const images: Record<string, Uint8Array> = {};
for (const [key, image] of Object.entries(imagesLibrary.all())) {
const bytes = await image?.getBytesAsync();
if (!bytes) continue;
images[key] = bytes;
}
return {
name: node.name,
children,
components: componentsLibrary.all(),
images: imagesLibrary.all()
images
};
};

View file

@ -12,18 +12,18 @@ import {
import { CircleShape } from '@ui/lib/types/shapes/circleShape';
export const transformEllipseNode = async (
export const transformEllipseNode = (
node: EllipseNode,
baseX: number,
baseY: number
): Promise<CircleShape> => {
): CircleShape => {
return {
type: 'circle',
name: node.name,
...transformFigmaIds(node),
...(await transformFills(node)),
...transformFills(node),
...transformEffects(node),
...(await transformStrokes(node)),
...transformStrokes(node),
...transformDimension(node),
...transformRotationAndPosition(node, baseX, baseY),
...transformSceneNode(node),

View file

@ -29,7 +29,7 @@ export const transformFrameNode = async (
// they plan to add it in the future. Refactor this when available.
frameSpecificAttributes = {
// @see: https://forum.figma.com/t/why-are-strokes-not-available-on-section-nodes/41658
...(await transformStrokes(node)),
...transformStrokes(node),
// @see: https://forum.figma.com/t/add-a-blendmode-property-for-sectionnode/58560
...transformBlend(node),
...transformProportion(node),
@ -43,7 +43,7 @@ export const transformFrameNode = async (
name: node.name,
showContent: isSectionNode(node) ? true : !node.clipsContent,
...transformFigmaIds(node),
...(await transformFills(node)),
...transformFills(node),
...frameSpecificAttributes,
...(await transformChildren(node, baseX + node.x, baseY + node.y)),
...transformDimensionAndPosition(node, baseX, baseY),

View file

@ -30,9 +30,9 @@ export const transformInstanceNode = async (
mainComponentFigmaId: mainComponent.id,
isComponentRoot: isComponentRoot(node),
...transformFigmaIds(node),
...(await transformFills(node)),
...transformFills(node),
...transformEffects(node),
...(await transformStrokes(node)),
...transformStrokes(node),
...transformSceneNode(node),
...transformBlend(node),
...transformProportion(node),
@ -60,11 +60,14 @@ const isUnprocessableComponent = (mainComponent: ComponentNode): boolean => {
const isComponentRoot = (node: InstanceNode): boolean => {
let parent = node.parent;
while (parent !== null) {
if (parent.type === 'COMPONENT' || parent.type === 'INSTANCE') {
return false;
}
parent = parent.parent;
}
return true;
};

View file

@ -16,17 +16,17 @@ const hasFillGeometry = (node: StarNode | LineNode | PolygonNode): boolean => {
return 'fillGeometry' in node && node.fillGeometry.length > 0;
};
export const transformPathNode = async (
export const transformPathNode = (
node: StarNode | LineNode | PolygonNode,
baseX: number,
baseY: number
): Promise<PathShape> => {
): PathShape => {
return {
type: 'path',
name: node.name,
...transformFigmaIds(node),
...(hasFillGeometry(node) ? await transformFills(node) : []),
...(await transformStrokes(node)),
...(hasFillGeometry(node) ? transformFills(node) : []),
...transformStrokes(node),
...transformEffects(node),
...transformVectorPathsAsContent(node, baseX, baseY),
...transformDimensionAndPosition(node, baseX, baseY),

View file

@ -13,18 +13,18 @@ import {
import { RectShape } from '@ui/lib/types/shapes/rectShape';
export const transformRectangleNode = async (
export const transformRectangleNode = (
node: RectangleNode,
baseX: number,
baseY: number
): Promise<RectShape> => {
): RectShape => {
return {
type: 'rect',
name: node.name,
...transformFigmaIds(node),
...(await transformFills(node)),
...transformFills(node),
...transformEffects(node),
...(await transformStrokes(node)),
...transformStrokes(node),
...transformDimension(node),
...transformRotationAndPosition(node, baseX, baseY),
...transformSceneNode(node),

View file

@ -27,10 +27,10 @@ export const transformSceneNode = async (
switch (node.type) {
case 'RECTANGLE':
penpotNode = await transformRectangleNode(node, baseX, baseY);
penpotNode = transformRectangleNode(node, baseX, baseY);
break;
case 'ELLIPSE':
penpotNode = await transformEllipseNode(node, baseX, baseY);
penpotNode = transformEllipseNode(node, baseX, baseY);
break;
case 'SECTION':
case 'FRAME':
@ -41,15 +41,15 @@ export const transformSceneNode = async (
penpotNode = await transformGroupNode(node, baseX, baseY);
break;
case 'TEXT':
penpotNode = await transformTextNode(node, baseX, baseY);
penpotNode = transformTextNode(node, baseX, baseY);
break;
case 'VECTOR':
penpotNode = await transformVectorNode(node, baseX, baseY);
penpotNode = transformVectorNode(node, baseX, baseY);
break;
case 'STAR':
case 'POLYGON':
case 'LINE':
penpotNode = await transformPathNode(node, baseX, baseY);
penpotNode = transformPathNode(node, baseX, baseY);
break;
case 'BOOLEAN_OPERATION':
penpotNode = await transformBooleanNode(node, baseX, baseY);

View file

@ -11,21 +11,17 @@ import {
import { TextShape } from '@ui/lib/types/shapes/textShape';
export const transformTextNode = async (
node: TextNode,
baseX: number,
baseY: number
): Promise<TextShape> => {
export const transformTextNode = (node: TextNode, baseX: number, baseY: number): TextShape => {
return {
type: 'text',
name: node.name,
...transformFigmaIds(node),
...(await transformText(node)),
...transformText(node),
...transformDimensionAndPosition(node, baseX, baseY),
...transformEffects(node),
...transformSceneNode(node),
...transformBlend(node),
...transformProportion(node),
...(await transformStrokes(node))
...transformStrokes(node)
};
};

View file

@ -11,12 +11,12 @@ import { transformGroupNodeLike } from '.';
* If there are no regions on the vector network, we treat it like a normal `PathShape`.
* If there are regions, we treat the vector node as a `GroupShape` with multiple `PathShape` children.
*/
export const transformVectorNode = async (
export const transformVectorNode = (
node: VectorNode,
baseX: number,
baseY: number
): Promise<GroupShape | PathShape> => {
const children = await transformVectorPaths(node, baseX, baseY);
): GroupShape | PathShape => {
const children = transformVectorPaths(node, baseX, baseY);
if (children.length === 1) {
return {

View file

@ -7,7 +7,7 @@ import { rgbToHex } from '@plugin/utils';
import { Fill } from '@ui/lib/types/utils/fill';
export const translateFill = async (fill: Paint): Promise<Fill | undefined> => {
export const translateFill = (fill: Paint): Fill | undefined => {
switch (fill.type) {
case 'SOLID':
return translateSolidFill(fill);
@ -16,21 +16,22 @@ export const translateFill = async (fill: Paint): Promise<Fill | undefined> => {
case 'GRADIENT_RADIAL':
return translateGradientRadialFill(fill);
case 'IMAGE':
return await translateImageFill(fill);
return translateImageFill(fill);
}
console.error(`Unsupported fill type: ${fill.type}`);
};
export const translateFills = async (
export const translateFills = (
fills: readonly Paint[] | typeof figma.mixed | undefined
): Promise<Fill[]> => {
): Fill[] => {
if (fills === undefined || fills === figma.mixed) return [];
const penpotFills: Fill[] = [];
for (const fill of fills) {
const penpotFill = await translateFill(fill);
const penpotFill = translateFill(fill);
if (penpotFill) {
// fills are applied in reverse order in Figma, that's why we unshift
penpotFills.unshift(penpotFill);

View file

@ -1,13 +1,10 @@
import { fromByteArray } from 'base64-js';
import { imagesLibrary } from '@plugin/ImageLibrary';
import { detectMimeType } from '@plugin/utils';
import { Fill } from '@ui/lib/types/utils/fill';
import { ImageColor } from '@ui/lib/types/utils/imageColor';
import { PartialImageColor } from '@ui/lib/types/utils/imageColor';
export const translateImageFill = async (fill: ImagePaint): Promise<Fill | undefined> => {
const fillImage = await translateImage(fill.imageHash);
export const translateImageFill = (fill: ImagePaint): Fill | undefined => {
const fillImage = translateImage(fill.imageHash);
if (!fillImage) return;
return {
@ -16,42 +13,14 @@ export const translateImageFill = async (fill: ImagePaint): Promise<Fill | undef
};
};
const translateImage = async (imageHash: string | null): Promise<ImageColor | undefined> => {
const translateImage = (imageHash: string | null): PartialImageColor | undefined => {
if (!imageHash) return;
const imageColor = imagesLibrary.get(imageHash) ?? (await generateAndRegister(imageHash));
if (!imageColor) return;
const { dataUri, ...rest } = imageColor;
if (imagesLibrary.get(imageHash) === undefined) {
imagesLibrary.register(imageHash, figma.getImageByHash(imageHash));
}
return {
...rest,
imageHash
};
};
const generateAndRegister = async (imageHash: string) => {
const image = figma.getImageByHash(imageHash);
if (!image) return;
const bytes = await image.getBytesAsync();
const { width, height } = await image.getSizeAsync();
const b64 = fromByteArray(bytes);
const mtype = detectMimeType(b64);
const dataUri = `data:${mtype};base64,${b64}`;
const imageColor: ImageColor = {
width,
height,
mtype,
dataUri,
keepAspectRatio: true,
id: '00000000-0000-0000-0000-000000000000'
};
imagesLibrary.register(imageHash, imageColor);
return imageColor;
};

View file

@ -12,16 +12,14 @@ import {
import { TextNode as PenpotTextNode, TextStyle } from '@ui/lib/types/shapes/textShape';
export const translateStyleTextSegments = async (
export const translateStyleTextSegments = (
node: TextNode,
segments: StyleTextSegment[]
): Promise<PenpotTextNode[]> => {
const partials = await Promise.all(
segments.map(async segment => ({
textNode: await translateStyleTextSegment(node, segment),
segment
}))
);
): PenpotTextNode[] => {
const partials = segments.map(segment => ({
textNode: translateStyleTextSegment(node, segment),
segment
}));
return translateParagraphProperties(node, partials);
};
@ -41,12 +39,9 @@ export const transformTextStyle = (node: TextNode, segment: StyleTextSegment): T
};
};
const translateStyleTextSegment = async (
node: TextNode,
segment: StyleTextSegment
): Promise<PenpotTextNode> => {
const translateStyleTextSegment = (node: TextNode, segment: StyleTextSegment): PenpotTextNode => {
return {
fills: await translateFills(segment.fills),
fills: translateFills(segment.fills),
text: segment.characters,
...transformTextStyle(node, segment)
};

View file

@ -2,31 +2,28 @@ import { translateFill } from '@plugin/translators/fills';
import { Stroke, StrokeAlignment, StrokeCaps } from '@ui/lib/types/utils/stroke';
export const translateStrokes = async (
export const translateStrokes = (
node: MinimalStrokesMixin | (MinimalStrokesMixin & IndividualStrokesMixin),
strokeCaps: (stroke: Stroke) => Stroke = stroke => stroke
): Promise<Stroke[]> => {
): Stroke[] => {
const sharedStrokeProperties: Stroke = {
strokeWidth: translateStrokeWeight(node),
strokeAlignment: translateStrokeAlignment(node.strokeAlign),
strokeStyle: node.dashPattern.length ? 'dashed' : 'solid'
};
return await Promise.all(
node.strokes.map(
async (paint, index) =>
await translateStroke(paint, sharedStrokeProperties, strokeCaps, index === 0)
)
return node.strokes.map((paint, index) =>
translateStroke(paint, sharedStrokeProperties, strokeCaps, index === 0)
);
};
export const translateStroke = async (
export const translateStroke = (
paint: Paint,
sharedStrokeProperties: Stroke,
strokeCaps: (stroke: Stroke) => Stroke,
firstStroke: boolean
): Promise<Stroke> => {
const fill = await translateFill(paint);
): Stroke => {
const fill = translateFill(paint);
let stroke: Stroke = {
strokeColor: fill?.fillColor,

View file

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

View file

@ -2,7 +2,6 @@ export * from './applyMatrixToPoint';
export * from './calculateAdjustment';
export * from './calculateLinearGradient';
export * from './calculateRadialGradient';
export * from './detectMimeType';
export * from './getBoundingBox';
export * from './matrixInvert';
export * from './rgbToHex';

View file

@ -69,7 +69,7 @@ export const useFigma = (): UseFigmaHook => {
parent.postMessage({ pluginMessage: { type, data } }, '*');
};
const onMessage = (event: MessageEvent<{ pluginMessage?: PluginMessage }>) => {
const onMessage = async (event: MessageEvent<{ pluginMessage?: PluginMessage }>) => {
if (!event.data.pluginMessage) return;
const { pluginMessage } = event.data;
@ -78,7 +78,7 @@ export const useFigma = (): UseFigmaHook => {
case 'PENPOT_DOCUMENT': {
setDownloading(true);
const file = parse(pluginMessage.data);
const file = await parse(pluginMessage.data);
file.export();
break;

View file

@ -1,5 +1,5 @@
import { Gradient } from './gradient';
import { ImageColor } from './imageColor';
import { ImageColor, PartialImageColor } from './imageColor';
import { Uuid } from './uuid';
export type Fill = {
@ -8,5 +8,5 @@ export type Fill = {
fillColorGradient?: Gradient;
fillColorRefFile?: Uuid;
fillColorRefId?: Uuid;
fillImage?: ImageColor;
fillImage?: ImageColor | PartialImageColor; // @TODO: move to any other place
};

View file

@ -8,5 +8,9 @@ export type ImageColor = {
id?: Uuid;
keepAspectRatio?: boolean;
dataUri?: string;
imageHash?: string; // @TODO: move to any other place
};
// @TODO: move to any other place
export type PartialImageColor = {
imageHash: string;
};

View file

@ -1,5 +1,5 @@
import { Gradient } from './gradient';
import { ImageColor } from './imageColor';
import { ImageColor, PartialImageColor } from './imageColor';
import { Uuid } from './uuid';
export type Stroke = {
@ -13,7 +13,7 @@ export type Stroke = {
strokeCapStart?: StrokeCaps;
strokeCapEnd?: StrokeCaps;
strokeColorGradient?: Gradient;
strokeImage?: ImageColor;
strokeImage?: ImageColor | PartialImageColor;
};
export type StrokeAlignment = 'center' | 'inner' | 'outer';

View file

@ -2,13 +2,23 @@ import { PenpotFile } from '@ui/lib/types/penpotFile';
import { FrameShape } from '@ui/lib/types/shapes/frameShape';
import { Uuid } from '@ui/lib/types/utils/uuid';
import { parseFigmaId } from '@ui/parser';
import { symbolBlendMode, symbolFills } from '@ui/parser/creators/symbols';
import { symbolBlendMode, symbolFills, symbolStrokes } from '@ui/parser/creators/symbols';
import { createItems } from '.';
export const createArtboard = (
file: PenpotFile,
{ type, fills, blendMode, children = [], figmaId, figmaRelatedId, shapeRef, ...rest }: FrameShape
{
type,
fills,
strokes,
blendMode,
children = [],
figmaId,
figmaRelatedId,
shapeRef,
...rest
}: FrameShape
): Uuid | undefined => {
const id = parseFigmaId(file, figmaId);
@ -16,6 +26,7 @@ export const createArtboard = (
id,
shapeRef: shapeRef ?? parseFigmaId(file, figmaRelatedId, true),
fills: symbolFills(fills),
strokes: symbolStrokes(strokes),
blendMode: symbolBlendMode(blendMode),
...rest
});

View file

@ -1,18 +1,34 @@
import { PenpotFile } from '@ui/lib/types/penpotFile';
import { BoolShape } from '@ui/lib/types/shapes/boolShape';
import { parseFigmaId } from '@ui/parser';
import { symbolBlendMode, symbolBoolType, symbolFills } from '@ui/parser/creators/symbols';
import {
symbolBlendMode,
symbolBoolType,
symbolFills,
symbolStrokes
} from '@ui/parser/creators/symbols';
import { createItems } from '.';
export const createBool = (
file: PenpotFile,
{ type, fills, boolType, blendMode, figmaId, figmaRelatedId, children = [], ...rest }: BoolShape
{
type,
fills,
strokes,
boolType,
blendMode,
figmaId,
figmaRelatedId,
children = [],
...rest
}: BoolShape
) => {
file.addBool({
id: parseFigmaId(file, figmaId),
shapeRef: parseFigmaId(file, figmaRelatedId, true),
fills: symbolFills(fills),
strokes: symbolStrokes(strokes),
blendMode: symbolBlendMode(blendMode),
boolType: symbolBoolType(boolType),
...rest

View file

@ -1,16 +1,17 @@
import { PenpotFile } from '@ui/lib/types/penpotFile';
import { CircleShape } from '@ui/lib/types/shapes/circleShape';
import { parseFigmaId } from '@ui/parser';
import { symbolBlendMode, symbolFills } from '@ui/parser/creators/symbols';
import { symbolBlendMode, symbolFills, symbolStrokes } from '@ui/parser/creators/symbols';
export const createCircle = (
file: PenpotFile,
{ type, fills, blendMode, figmaId, figmaRelatedId, ...rest }: CircleShape
{ type, fills, strokes, blendMode, figmaId, figmaRelatedId, ...rest }: CircleShape
) => {
file.createCircle({
id: parseFigmaId(file, figmaId),
shapeRef: parseFigmaId(file, figmaRelatedId, true),
fills: symbolFills(fills),
strokes: symbolStrokes(strokes),
blendMode: symbolBlendMode(blendMode),
...rest
});

View file

@ -1,7 +1,7 @@
import { componentsLibrary } from '@plugin/ComponentLibrary';
import { PenpotFile } from '@ui/lib/types/penpotFile';
import { symbolBlendMode, symbolFills } from '@ui/parser/creators/symbols';
import { symbolBlendMode, symbolFills, symbolStrokes } from '@ui/parser/creators/symbols';
import { uiComponents } from '@ui/parser/libraries';
import { createItems } from '.';
@ -13,11 +13,12 @@ export const createComponentLibrary = (file: PenpotFile) => {
return;
}
const { children = [], fills, blendMode, ...rest } = component;
const { children = [], fills, strokes, blendMode, ...rest } = component;
file.startComponent({
...rest,
fills: symbolFills(fills),
strokes: symbolStrokes(strokes),
blendMode: symbolBlendMode(blendMode),
id: uiComponent.componentId,
componentId: uiComponent.componentId,

View file

@ -1,16 +1,22 @@
import { PenpotFile } from '@ui/lib/types/penpotFile';
import { PathShape } from '@ui/lib/types/shapes/pathShape';
import { parseFigmaId } from '@ui/parser';
import { symbolBlendMode, symbolFills, symbolPathContent } from '@ui/parser/creators/symbols';
import {
symbolBlendMode,
symbolFills,
symbolPathContent,
symbolStrokes
} from '@ui/parser/creators/symbols';
export const createPath = (
file: PenpotFile,
{ type, fills, blendMode, content, figmaId, figmaRelatedId, ...rest }: PathShape
{ type, fills, strokes, blendMode, content, figmaId, figmaRelatedId, ...rest }: PathShape
) => {
file.createPath({
id: parseFigmaId(file, figmaId),
shapeRef: parseFigmaId(file, figmaRelatedId, true),
fills: symbolFills(fills),
strokes: symbolStrokes(strokes),
blendMode: symbolBlendMode(blendMode),
content: symbolPathContent(content),
...rest

View file

@ -1,16 +1,17 @@
import { PenpotFile } from '@ui/lib/types/penpotFile';
import { RectShape } from '@ui/lib/types/shapes/rectShape';
import { parseFigmaId } from '@ui/parser';
import { symbolBlendMode, symbolFills } from '@ui/parser/creators/symbols';
import { symbolBlendMode, symbolFills, symbolStrokes } from '@ui/parser/creators/symbols';
export const createRectangle = (
file: PenpotFile,
{ type, fills, blendMode, figmaId, figmaRelatedId, ...rest }: RectShape
{ type, fills, strokes, blendMode, figmaId, figmaRelatedId, ...rest }: RectShape
) => {
file.createRect({
id: parseFigmaId(file, figmaId),
shapeRef: parseFigmaId(file, figmaRelatedId, true),
fills: symbolFills(fills),
strokes: symbolStrokes(strokes),
blendMode: symbolBlendMode(blendMode),
...rest
});

View file

@ -1,17 +1,18 @@
import { PenpotFile } from '@ui/lib/types/penpotFile';
import { TextContent, TextShape } from '@ui/lib/types/shapes/textShape';
import { parseFigmaId } from '@ui/parser';
import { symbolBlendMode, symbolFills } from '@ui/parser/creators/symbols';
import { symbolBlendMode, symbolFills, symbolStrokes } from '@ui/parser/creators/symbols';
export const createText = (
file: PenpotFile,
{ type, blendMode, figmaId, content, figmaRelatedId, ...rest }: TextShape
{ type, blendMode, strokes, figmaId, content, figmaRelatedId, ...rest }: TextShape
) => {
file.createText({
id: parseFigmaId(file, figmaId),
shapeRef: parseFigmaId(file, figmaRelatedId, true),
content: parseContent(content),
blendMode: symbolBlendMode(blendMode),
strokes: symbolStrokes(strokes),
...rest
});
};

View file

@ -2,3 +2,4 @@ export * from './symbolBlendMode';
export * from './symbolBoolType';
export * from './symbolFills';
export * from './symbolPathContent';
export * from './symbolStrokes';

View file

@ -1,8 +1,7 @@
import { imagesLibrary } from '@plugin/ImageLibrary';
import { Fill } from '@ui/lib/types/utils/fill';
import { Gradient, LINEAR_TYPE, RADIAL_TYPE } from '@ui/lib/types/utils/gradient';
import { ImageColor } from '@ui/lib/types/utils/imageColor';
import { ImageColor, PartialImageColor } from '@ui/lib/types/utils/imageColor';
import { uiImages } from '@ui/parser/libraries';
export const symbolFills = (fills?: Fill[]): Fill[] | undefined => {
if (!fills) return;
@ -39,19 +38,16 @@ const symbolFillGradient = (fillGradient: Gradient): Gradient => {
}
};
const symbolFillImage = (fillImage: ImageColor): ImageColor | undefined => {
if (fillImage.dataUri) return fillImage;
export const symbolFillImage = (
fillImage: ImageColor | PartialImageColor
): ImageColor | undefined => {
if (!isPartialFillColor(fillImage)) return fillImage;
const { imageHash, ...rest } = fillImage;
if (!imageHash) return;
const imageColor = imagesLibrary.get(imageHash);
if (!imageColor) return;
return {
...rest,
dataUri: imageColor?.dataUri
};
return uiImages.get(fillImage.imageHash);
};
const isPartialFillColor = (
imageColor: ImageColor | PartialImageColor
): imageColor is PartialImageColor => {
return 'imageHash' in imageColor;
};

View file

@ -0,0 +1,15 @@
import { Stroke } from '@ui/lib/types/utils/stroke';
import { symbolFillImage } from '.';
export const symbolStrokes = (strokes?: Stroke[]): Stroke[] | undefined => {
if (!strokes) return;
return strokes.map(stroke => {
if (stroke.strokeImage) {
stroke.strokeImage = symbolFillImage(stroke.strokeImage);
}
return stroke;
});
};

View file

@ -1,3 +1,4 @@
export * from './IdLibrary';
export * from './parse';
export * from './parseImage';
export * from './parseFigmaId';

View file

@ -0,0 +1,23 @@
import { ImageColor } from '@ui/lib/types/utils/imageColor';
class UiImages {
private images: Record<string, ImageColor> = {};
public register(id: string, image: ImageColor) {
this.images[id] = image;
}
public get(id: string): ImageColor | undefined {
return this.images[id];
}
public all(): ImageColor[] {
return Object.values(this.images);
}
public init() {
this.images = {};
}
}
export const uiImages = new UiImages();

View file

@ -1 +1,2 @@
export * from './UiComponents';
export * from './UiImages';

View file

@ -1,16 +1,20 @@
import { componentsLibrary } from '@plugin/ComponentLibrary';
import { imagesLibrary } from '@plugin/ImageLibrary';
import { createFile } from '@ui/lib/penpot';
import { createComponentLibrary, createPage } from '@ui/parser/creators';
import { uiComponents } from '@ui/parser/libraries/UiComponents';
import { uiComponents, uiImages } from '@ui/parser/libraries';
import { PenpotDocument } from '@ui/types';
import { idLibrary } from '.';
import { idLibrary, parseImage } from '.';
export const parse = ({ name, children = [], components, images }: PenpotDocument) => {
export const parse = async ({ name, children = [], components, images }: PenpotDocument) => {
componentsLibrary.init(components);
imagesLibrary.init(images);
for (const [key, bytes] of Object.entries(images)) {
if (!bytes) continue;
uiImages.register(key, await parseImage(bytes));
}
uiComponents.init();
idLibrary.init();

View file

@ -0,0 +1,53 @@
import { ImageColor } from '@ui/lib/types/utils/imageColor';
import { detectMimeType } from '@ui/utils';
const IMAGE_QUALITY = 0.8;
export const parseImage = async (bytes: Uint8Array): Promise<ImageColor> => {
const image = await extractFromBytes(bytes);
return {
width: image.width,
height: image.height,
dataUri: image.dataURL,
keepAspectRatio: true,
id: '00000000-0000-0000-0000-000000000000'
};
};
async function extractFromBytes(bytes: Uint8Array) {
const mymeType = detectMimeType(bytes);
const url = URL.createObjectURL(new Blob([bytes]));
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject();
img.src = url;
});
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not create canvas context');
}
context.drawImage(image, 0, 0);
const dataURL = await canvas
.convertToBlob({ type: mymeType, quality: IMAGE_QUALITY })
.then(blob => {
return new Promise<string>(resolve => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
});
return {
dataURL,
width: image.width,
height: image.height
};
}

View file

@ -1,10 +1,9 @@
import { PenpotPage } from '@ui/lib/types/penpotPage';
import { ComponentShape } from '@ui/lib/types/shapes/componentShape';
import { ImageColor } from '@ui/lib/types/utils/imageColor';
export type PenpotDocument = {
name: string;
children?: PenpotPage[];
components: Record<string, ComponentShape>;
images: Record<string, ImageColor>;
images: Record<string, Uint8Array>;
};

View file

@ -0,0 +1,25 @@
export const detectMimeType = (bytes: Uint8Array): string | undefined => {
const length = 4;
if (bytes.length >= length) {
const signatureArr = new Array(length);
for (let index = 0; index < length; index++) {
signatureArr[index] = bytes[index].toString(16);
}
const signature = signatureArr.join('').toUpperCase();
switch (signature) {
case '89504E47':
return 'image/png';
case '47494638':
return 'image/gif';
case 'FFD8FFDB':
case 'FFD8FFE0':
return 'image/jpeg';
default:
return;
}
}
};

1
ui-src/utils/index.ts Normal file
View file

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