mirror of
https://github.com/penpot/penpot-exporter-figma-plugin.git
synced 2024-12-22 05:33:02 -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:
parent
4b711b3526
commit
3094f05e98
45 changed files with 324 additions and 223 deletions
5
.changeset/pretty-plants-kick.md
Normal file
5
.changeset/pretty-plants-kick.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"penpot-exporter": minor
|
||||
---
|
||||
|
||||
Optimize images before generating zip 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -31,13 +31,12 @@ 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
|
||||
): PathShape[] => {
|
||||
const pathShapes = node.vectorPaths
|
||||
.filter((vectorPath, index) => {
|
||||
return (
|
||||
nodeHasFills(node, vectorPath, (node.vectorNetwork.regions ?? [])[index]) ||
|
||||
|
@ -45,26 +44,17 @@ export const transformVectorPaths = async (
|
|||
);
|
||||
})
|
||||
.map((vectorPath, index) =>
|
||||
transformVectorPath(
|
||||
node,
|
||||
vectorPath,
|
||||
(node.vectorNetwork.regions ?? [])[index],
|
||||
baseX,
|
||||
baseY
|
||||
)
|
||||
)
|
||||
transformVectorPath(node, vectorPath, (node.vectorNetwork.regions ?? [])[index], baseX, baseY)
|
||||
);
|
||||
|
||||
const geometryShapes = await Promise.all(
|
||||
node.fillGeometry
|
||||
const geometryShapes = node.fillGeometry
|
||||
.filter(
|
||||
geometry =>
|
||||
!node.vectorPaths.find(
|
||||
vectorPath => normalizePath(vectorPath.data) === normalizePath(geometry.data)
|
||||
)
|
||||
)
|
||||
.map(geometry => transformVectorPath(node, geometry, undefined, baseX, baseY))
|
||||
);
|
||||
.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),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
): 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)
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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
|
||||
});
|
||||
};
|
||||
|
|
|
@ -2,3 +2,4 @@ export * from './symbolBlendMode';
|
|||
export * from './symbolBoolType';
|
||||
export * from './symbolFills';
|
||||
export * from './symbolPathContent';
|
||||
export * from './symbolStrokes';
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
15
ui-src/parser/creators/symbols/symbolStrokes.ts
Normal file
15
ui-src/parser/creators/symbols/symbolStrokes.ts
Normal 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;
|
||||
});
|
||||
};
|
|
@ -1,3 +1,4 @@
|
|||
export * from './IdLibrary';
|
||||
export * from './parse';
|
||||
export * from './parseImage';
|
||||
export * from './parseFigmaId';
|
||||
|
|
23
ui-src/parser/libraries/UiImages.ts
Normal file
23
ui-src/parser/libraries/UiImages.ts
Normal 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();
|
|
@ -1 +1,2 @@
|
|||
export * from './UiComponents';
|
||||
export * from './UiImages';
|
||||
|
|
|
@ -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();
|
||||
|
|
53
ui-src/parser/parseImage.ts
Normal file
53
ui-src/parser/parseImage.ts
Normal 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
|
||||
};
|
||||
}
|
|
@ -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>;
|
||||
};
|
||||
|
|
25
ui-src/utils/detectMimeType.ts
Normal file
25
ui-src/utils/detectMimeType.ts
Normal 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
1
ui-src/utils/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './detectMimeType';
|
Loading…
Reference in a new issue