├── .nvmrc ├── ui-src ├── components │ ├── Stack │ │ ├── index.ts │ │ ├── Stack.module.css │ │ └── Stack.tsx │ ├── Wrapper │ │ ├── index.ts │ │ ├── Wrapper.module.css │ │ └── Wrapper.tsx │ ├── AppFooter │ │ ├── index.ts │ │ └── AppFooter.tsx │ ├── ProgressBar │ │ ├── index.ts │ │ ├── ProgressBar.module.css │ │ └── ProgressBar.tsx │ ├── PluginContainer │ │ ├── index.ts │ │ └── PluginContainer.tsx │ ├── ProgressStepper │ │ ├── index.ts │ │ ├── ProgressStepper.module.css │ │ └── ProgressStepper.tsx │ ├── PenpotExporter.tsx │ └── LibraryError.tsx ├── lib │ ├── types │ │ ├── utils │ │ │ ├── uuid.ts │ │ │ ├── point.ts │ │ │ ├── media.ts │ │ │ ├── export.ts │ │ │ ├── children.ts │ │ │ ├── matrix.ts │ │ │ ├── blur.ts │ │ │ ├── selrect.ts │ │ │ ├── typography.ts │ │ │ ├── gradient.ts │ │ │ ├── blendModes.ts │ │ │ ├── imageColor.ts │ │ │ ├── shadow.ts │ │ │ ├── animation.ts │ │ │ ├── color.ts │ │ │ ├── fill.ts │ │ │ ├── stroke.ts │ │ │ ├── grid.ts │ │ │ ├── syncGroups.ts │ │ │ └── interaction.ts │ │ ├── exportOptions.ts │ │ ├── penpotFile.ts │ │ ├── penpotPage.ts │ │ ├── shapes │ │ │ ├── rectShape.ts │ │ │ ├── circleShape.ts │ │ │ ├── variant.ts │ │ │ ├── groupShape.ts │ │ │ ├── pathShape.ts │ │ │ ├── frameShape.ts │ │ │ ├── componentShape.ts │ │ │ ├── boolShape.ts │ │ │ ├── tokens.ts │ │ │ └── textShape.ts │ │ └── penpotContext.ts │ └── penpot.d.ts ├── .env.example ├── parser │ ├── index.ts │ ├── creators │ │ ├── symbols │ │ │ ├── index.ts │ │ │ ├── symbolStrokes.ts │ │ │ ├── symbolFills.ts │ │ │ └── symbolTouched.ts │ │ ├── index.ts │ │ ├── createPage.ts │ │ ├── createPath.ts │ │ ├── createGroup.ts │ │ ├── createCircle.ts │ │ ├── createRectangle.ts │ │ ├── createBool.ts │ │ ├── createArtboard.ts │ │ ├── createComponent.ts │ │ ├── createComponentInstance.ts │ │ ├── createItems.ts │ │ └── createText.ts │ ├── builders │ │ ├── index.ts │ │ ├── buildFile.ts │ │ ├── buildAssets.ts │ │ ├── registerTypographyLibraries.ts │ │ ├── registerColorLibraries.ts │ │ └── buildComponentsLibrary.ts │ ├── libraries.ts │ └── parse.ts ├── main.css ├── types │ ├── index.ts │ ├── penpotDocument.ts │ ├── penpotNode.ts │ ├── component.ts │ └── progressMessages.ts ├── utils │ ├── index.ts │ ├── fileSizeInMB.ts │ ├── formatExportTime.ts │ └── detectMimeType.ts ├── context │ ├── index.ts │ ├── createInMemoryWritable.ts │ ├── FigmaContext.tsx │ ├── createGenericContext.ts │ └── messages.ts ├── vite-env.d.ts ├── index.html ├── reset.css ├── main.tsx ├── metrics │ ├── sentry.ts │ └── mixpanel.ts ├── tsconfig.json └── App.tsx ├── plugin-src ├── translators │ ├── text │ │ ├── font │ │ │ ├── index.ts │ │ │ ├── custom │ │ │ │ ├── index.ts │ │ │ │ ├── translateFontVariantId.ts │ │ │ │ └── translateCustomFont.ts │ │ │ ├── local │ │ │ │ ├── index.ts │ │ │ │ ├── localFont.ts │ │ │ │ ├── translateLocalFont.ts │ │ │ │ ├── translateFontVariantId.ts │ │ │ │ └── localFonts.json │ │ │ ├── gfonts │ │ │ │ ├── index.ts │ │ │ │ ├── googleFont.ts │ │ │ │ ├── translateFontVariantId.ts │ │ │ │ └── translateGoogleFont.ts │ │ │ └── translateFontName.ts │ │ ├── index.ts │ │ ├── paragraph │ │ │ ├── ListType.ts │ │ │ ├── index.ts │ │ │ ├── UnorderedListType.ts │ │ │ ├── ListTypeFactory.ts │ │ │ ├── OrderedListType.ts │ │ │ ├── translateParagraphProperties.ts │ │ │ └── List.ts │ │ └── properties │ │ │ ├── translateFontStyle.ts │ │ │ ├── translateVerticalAlign.ts │ │ │ ├── translateTextDecoration.ts │ │ │ ├── index.ts │ │ │ ├── translateLineHeight.ts │ │ │ ├── translateTextTransform.ts │ │ │ ├── translateLetterSpacing.ts │ │ │ ├── translateHorizontalAlign.ts │ │ │ ├── translateGrowType.ts │ │ │ └── translateFontWeight.ts │ ├── components │ │ ├── index.ts │ │ ├── registerComponentProperties.ts │ │ └── registerVariantProperties.ts │ ├── fills │ │ ├── gradients │ │ │ ├── index.ts │ │ │ ├── translateGradientLinearFill.ts │ │ │ └── translateGradientRadialFill.ts │ │ ├── index.ts │ │ ├── translateSolidFill.ts │ │ ├── translateImageFill.ts │ │ └── translateFills.ts │ ├── styles │ │ ├── index.ts │ │ ├── translateStyleName.ts │ │ ├── translateStylePath.ts │ │ ├── translateTextStyle.ts │ │ └── translatePaintStyle.ts │ ├── vectors │ │ ├── index.ts │ │ ├── translateWindingRule.ts │ │ ├── translateCommands.ts │ │ ├── translateRotatedCommands.ts │ │ └── translateNonRotatedCommands.ts │ ├── tokens │ │ ├── translateAliasValue.ts │ │ ├── translateTheme.ts │ │ ├── index.ts │ │ ├── translateSet.ts │ │ ├── translateScope.ts │ │ ├── translateVariableValues.ts │ │ ├── translateVariable.ts │ │ ├── translateVariableName.ts │ │ ├── translateColorVariable.ts │ │ ├── translateGenericVariable.ts │ │ ├── translateTextVariable.ts │ │ └── translateFloatVariable.ts │ ├── index.ts │ ├── translateBlurEffects.ts │ ├── translateBoolType.ts │ ├── translateConstraints.ts │ ├── translateRotation.ts │ ├── translateShadowEffects.ts │ ├── translateBlendMode.ts │ ├── translateChildren.ts │ └── translateGrids.ts ├── processors │ ├── index.ts │ ├── processImages.ts │ ├── processAssets.ts │ ├── processPages.ts │ ├── processTextStyles.ts │ └── processPaintStyles.ts ├── utils │ ├── applyMatrixToPoint.ts │ ├── index.ts │ ├── rgbToHex.ts │ ├── rgbToString.ts │ ├── calculateLinearGradient.ts │ ├── progress.ts │ ├── generateUuid.ts │ └── calculateRadialGradient.ts ├── transformers │ ├── partials │ │ ├── transformProportion.ts │ │ ├── transformSceneNode.ts │ │ ├── transformGrids.ts │ │ ├── transformVariableConsumptionMap.ts │ │ ├── transformBlend.ts │ │ ├── transformEffects.ts │ │ ├── transformComponentNameAndPath.ts │ │ ├── transformOverrides.ts │ │ ├── transformConstraints.ts │ │ ├── transformDimensionAndPosition.ts │ │ ├── transformChildren.ts │ │ ├── index.ts │ │ ├── transformCornerRadius.ts │ │ ├── transformRotationAndPosition.ts │ │ ├── transformFills.ts │ │ ├── transformText.ts │ │ ├── transformComponentSetStrokesAndCornerRadius.ts │ │ ├── transformVariantNameAndProperties.ts │ │ └── transformIds.ts │ ├── transformPageNode.ts │ ├── index.ts │ ├── transformGroupNode.ts │ ├── transformTextNode.ts │ ├── transformEllipseNode.ts │ ├── transformDocumentNode.ts │ ├── transformRectangleNode.ts │ ├── transformPathNode.ts │ ├── transformVectorNode.ts │ ├── transformLineNode.ts │ ├── transformBooleanNode.ts │ ├── transformComponentSetNode.ts │ └── transformSceneNode.ts ├── tsconfig.json ├── getUserData.ts ├── Cache.ts ├── libraries.ts ├── code.ts └── handleMessage.ts ├── .vscode └── settings.json ├── .gitignore ├── .prettierignore ├── resources ├── penpot-exporter.jpeg └── import-plugin-from-manifest.png ├── stylelint.config.mjs ├── .editorconfig ├── tsconfig.base.json ├── .changeset ├── config.json └── README.md ├── common ├── map.ts └── sleep.ts ├── prettier.config.mjs ├── manifest.json ├── .github └── workflows │ ├── release.yaml │ └── ci.yaml ├── vite.config.ts └── eslint.config.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 24 2 | -------------------------------------------------------------------------------- /ui-src/components/Stack/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Stack'; 2 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | export type Uuid = string; 2 | -------------------------------------------------------------------------------- /ui-src/.env.example: -------------------------------------------------------------------------------- 1 | VITE_SENTRY_DSN= 2 | VITE_MIXPANEL_TOKEN= 3 | -------------------------------------------------------------------------------- /ui-src/components/Wrapper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Wrapper'; 2 | -------------------------------------------------------------------------------- /ui-src/components/AppFooter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AppFooter'; 2 | -------------------------------------------------------------------------------- /ui-src/components/ProgressBar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ProgressBar'; 2 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translateFontName'; 2 | -------------------------------------------------------------------------------- /plugin-src/translators/text/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translateTextSegments'; 2 | -------------------------------------------------------------------------------- /ui-src/components/PluginContainer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './PluginContainer'; 2 | -------------------------------------------------------------------------------- /ui-src/components/ProgressStepper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ProgressStepper'; 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.configPath": "prettier.config.mjs" 3 | } 4 | -------------------------------------------------------------------------------- /ui-src/parser/index.ts: -------------------------------------------------------------------------------- 1 | export * from './libraries'; 2 | export * from './parse'; 3 | -------------------------------------------------------------------------------- /ui-src/components/Wrapper/Wrapper.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | padding: 1.5rem 1rem 1rem; 3 | } 4 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/point.ts: -------------------------------------------------------------------------------- 1 | export type Point = { 2 | x: number; 3 | y: number; 4 | }; 5 | -------------------------------------------------------------------------------- /ui-src/main.css: -------------------------------------------------------------------------------- 1 | body:has(div[data-overflowing='true']) { 2 | overflow-y: visible !important; 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | ui-src/.env 4 | 5 | # Sentry Config File 6 | .env.sentry-build-plugin 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .changeset 2 | node_modules 3 | dist 4 | ui-src/lib/penpot.js 5 | LICENSE 6 | CHANGELOG.md 7 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/media.ts: -------------------------------------------------------------------------------- 1 | export type Media = { 2 | name: string; 3 | width: number; 4 | height: number; 5 | }; 6 | -------------------------------------------------------------------------------- /ui-src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './component'; 2 | export * from './penpotDocument'; 3 | export * from './penpotNode'; 4 | -------------------------------------------------------------------------------- /resources/penpot-exporter.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/penpot/penpot-exporter-figma-plugin/HEAD/resources/penpot-exporter.jpeg -------------------------------------------------------------------------------- /ui-src/lib/types/utils/export.ts: -------------------------------------------------------------------------------- 1 | export type Export = { 2 | type: string; 3 | scale: number; 4 | suffix: string; 5 | }; 6 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/custom/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translateCustomFont'; 2 | export * from './translateFontVariantId'; 3 | -------------------------------------------------------------------------------- /ui-src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './detectMimeType'; 2 | export * from './fileSizeInMB'; 3 | export * from './formatExportTime'; 4 | -------------------------------------------------------------------------------- /plugin-src/translators/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './registerComponentProperties'; 2 | export * from './registerVariantProperties'; 3 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/children.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotNode } from '@ui/types'; 2 | 3 | export type Children = { children?: PenpotNode[] }; 4 | -------------------------------------------------------------------------------- /ui-src/parser/creators/symbols/index.ts: -------------------------------------------------------------------------------- 1 | export * from './symbolFills'; 2 | export * from './symbolStrokes'; 3 | export * from './symbolTouched'; 4 | -------------------------------------------------------------------------------- /plugin-src/translators/fills/gradients/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translateGradientLinearFill'; 2 | export * from './translateGradientRadialFill'; 3 | -------------------------------------------------------------------------------- /resources/import-plugin-from-manifest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/penpot/penpot-exporter-figma-plugin/HEAD/resources/import-plugin-from-manifest.png -------------------------------------------------------------------------------- /ui-src/utils/fileSizeInMB.ts: -------------------------------------------------------------------------------- 1 | export const fileSizeInMB = (size: number): string => { 2 | return (size / (1024 * 1024)).toFixed(2) + ' MB'; 3 | }; 4 | -------------------------------------------------------------------------------- /plugin-src/translators/text/paragraph/ListType.ts: -------------------------------------------------------------------------------- 1 | export interface ListType { 2 | getCurrentSymbol(number: number, indentation: number): string; 3 | } 4 | -------------------------------------------------------------------------------- /ui-src/lib/types/exportOptions.ts: -------------------------------------------------------------------------------- 1 | export type ExportOptions = { 2 | onProgress?: (opts: { item: number; total: number; path: string }) => void; 3 | }; 4 | -------------------------------------------------------------------------------- /plugin-src/translators/fills/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translateFills'; 2 | export * from './translateImageFill'; 3 | export * from './translateSolidFill'; 4 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/local/index.ts: -------------------------------------------------------------------------------- 1 | export * from './localFont'; 2 | export * from './translateFontVariantId'; 3 | export * from './translateLocalFont'; 4 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/matrix.ts: -------------------------------------------------------------------------------- 1 | export type Matrix = { 2 | a: number; 3 | b: number; 4 | c: number; 5 | d: number; 6 | e: number; 7 | f: number; 8 | }; 9 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/gfonts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './googleFont'; 2 | export * from './translateFontVariantId'; 3 | export * from './translateGoogleFont'; 4 | -------------------------------------------------------------------------------- /stylelint.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('stylelint').Config} */ 2 | export default { 3 | extends: ['stylelint-config-recommended', 'stylelint-config-standard'] 4 | }; 5 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/blur.ts: -------------------------------------------------------------------------------- 1 | import type { Uuid } from './uuid'; 2 | 3 | export type Blur = { 4 | id?: Uuid; 5 | type: 'layer-blur'; 6 | value: number; 7 | hidden: boolean; 8 | }; 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | charset = utf-8 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /plugin-src/translators/styles/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translatePaintStyle'; 2 | export * from './translateStyleName'; 3 | export * from './translateStylePath'; 4 | export * from './translateTextStyle'; 5 | -------------------------------------------------------------------------------- /ui-src/context/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createGenericContext'; 2 | export * from './createInMemoryWritable'; 3 | export * from './FigmaContext'; 4 | export * from './messages'; 5 | export * from './useFigma'; 6 | -------------------------------------------------------------------------------- /ui-src/lib/types/penpotFile.ts: -------------------------------------------------------------------------------- 1 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 2 | 3 | export type PenpotFile = { 4 | id?: Uuid; 5 | name: string; 6 | width?: number; 7 | height?: number; 8 | }; 9 | -------------------------------------------------------------------------------- /plugin-src/processors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './processImages'; 2 | export * from './processPaintStyles'; 3 | export * from './processTextStyles'; 4 | export * from './processPages'; 5 | export * from './processTokens'; 6 | -------------------------------------------------------------------------------- /plugin-src/translators/vectors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translateCommands'; 2 | export * from './translateNonRotatedCommands'; 3 | export * from './translateRotatedCommands'; 4 | export * from './translateWindingRule'; 5 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/selrect.ts: -------------------------------------------------------------------------------- 1 | export type Selrect = { 2 | x: number; 3 | y: number; 4 | x1: number; 5 | x2: number; 6 | y1: number; 7 | y2: number; 8 | width: number; 9 | height: number; 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@common/*": ["./common/*"], 6 | "@plugin/*": ["./plugin-src/*"], 7 | "@ui/*": ["./ui-src/*"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ui-src/lib/types/penpotPage.ts: -------------------------------------------------------------------------------- 1 | import type { Children } from '@ui/lib/types/utils/children'; 2 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 3 | 4 | export type PenpotPage = { 5 | id?: Uuid; 6 | name: string; 7 | background?: string; 8 | } & Children; 9 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/typography.ts: -------------------------------------------------------------------------------- 1 | import type { TextTypography } from '@ui/lib/types/shapes/textShape'; 2 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 3 | 4 | export type Typography = TextTypography & { 5 | id?: Uuid; 6 | name?: string; 7 | path?: string; 8 | }; 9 | -------------------------------------------------------------------------------- /ui-src/parser/builders/index.ts: -------------------------------------------------------------------------------- 1 | export * from './buildAssets'; 2 | export * from './buildComponentsLibrary'; 3 | export * from './buildFile'; 4 | export * from './optimizeFileMedias'; 5 | export * from './registerColorLibraries'; 6 | export * from './registerTypographyLibraries'; 7 | -------------------------------------------------------------------------------- /plugin-src/utils/applyMatrixToPoint.ts: -------------------------------------------------------------------------------- 1 | export const applyMatrixToPoint = (matrix: number[][], point: number[]): number[] => { 2 | return [ 3 | point[0] * matrix[0][0] + point[1] * matrix[0][1] + matrix[0][2], 4 | point[0] * matrix[1][0] + point[1] * matrix[1][1] + matrix[1][2] 5 | ]; 6 | }; 7 | -------------------------------------------------------------------------------- /plugin-src/translators/text/paragraph/index.ts: -------------------------------------------------------------------------------- 1 | export * from './List'; 2 | export * from './ListType'; 3 | export * from './ListTypeFactory'; 4 | export * from './OrderedListType'; 5 | export * from './Paragraph'; 6 | export * from './translateParagraphProperties'; 7 | export * from './UnorderedListType'; 8 | -------------------------------------------------------------------------------- /plugin-src/translators/text/paragraph/UnorderedListType.ts: -------------------------------------------------------------------------------- 1 | import type { ListType } from '@plugin/translators/text/paragraph/ListType'; 2 | 3 | export class UnorderedListType implements ListType { 4 | public getCurrentSymbol(_number: number, _indentation: number): string { 5 | return ' • '; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /plugin-src/translators/styles/translateStyleName.ts: -------------------------------------------------------------------------------- 1 | export const translateStyleName = (figmaStyle: BaseStyle): string => { 2 | const splitName = figmaStyle.name.split('/'); 3 | 4 | if (splitName.length > 0) { 5 | return splitName.pop() as string; 6 | } 7 | 8 | return figmaStyle.name; 9 | }; 10 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateAliasValue.ts: -------------------------------------------------------------------------------- 1 | export const isAliasValue = (value: VariableValue): value is VariableAlias => { 2 | return typeof value === 'object' && 'id' in value; 3 | }; 4 | 5 | export const translateAliasValue = (value: VariableAlias): string => { 6 | return '{' + value.id + '}'; 7 | }; 8 | -------------------------------------------------------------------------------- /ui-src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | declare namespace ViteEnv { 4 | interface ImportMetaEnv { 5 | VITE_SENTRY_DSN: string; 6 | VITE_MIXPANEL_TOKEN: string; 7 | } 8 | } 9 | 10 | declare const APP_VERSION: string; 11 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/local/localFont.ts: -------------------------------------------------------------------------------- 1 | export type LocalFont = { 2 | id: string; 3 | name: string; 4 | family: string; 5 | variants?: Variant[]; 6 | }; 7 | 8 | type Variant = { 9 | id: string; 10 | name: string; 11 | weight: string; 12 | style: string; 13 | suffix?: string; 14 | }; 15 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/custom/translateFontVariantId.ts: -------------------------------------------------------------------------------- 1 | export const translateFontVariantId = ( 2 | fontName: FontName | undefined, 3 | fontWeight: string 4 | ): string => { 5 | const style = fontName?.style.toLowerCase().includes('italic') ? 'italic' : 'normal'; 6 | 7 | return `${style}-${fontWeight}`; 8 | }; 9 | -------------------------------------------------------------------------------- /plugin-src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './applyMatrixToPoint'; 2 | export * from './applyRotation'; 3 | export * from './calculateLinearGradient'; 4 | export * from './calculateRadialGradient'; 5 | export * from './generateUuid'; 6 | export * from './progress'; 7 | export * from './matrixInvert'; 8 | export * from './rgbToHex'; 9 | -------------------------------------------------------------------------------- /plugin-src/utils/rgbToHex.ts: -------------------------------------------------------------------------------- 1 | export const rgbToHex = (color: RGB | RGBA): string => { 2 | const r = Math.round(255 * color.r); 3 | const g = Math.round(255 * color.g); 4 | const b = Math.round(255 * color.b); 5 | const rgb = (r << 16) | (g << 8) | (b << 0); 6 | return '#' + (0x1000000 + rgb).toString(16).slice(1); 7 | }; 8 | -------------------------------------------------------------------------------- /plugin-src/translators/text/properties/translateFontStyle.ts: -------------------------------------------------------------------------------- 1 | import type { TextFontStyle } from '@ui/lib/types/shapes/textShape'; 2 | 3 | export const translateFontStyle = (style: string): TextFontStyle => { 4 | if (style.toLowerCase().includes('italic')) { 5 | return 'italic'; 6 | } 7 | 8 | return 'normal'; 9 | }; 10 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/gfonts/googleFont.ts: -------------------------------------------------------------------------------- 1 | export type GoogleFont = { 2 | family: string; 3 | variants?: string[]; 4 | subsets?: string[]; 5 | version: string; 6 | lastModified: string; 7 | files?: { [key: string]: string | undefined }; 8 | category: string; 9 | kind: string; 10 | menu: string; 11 | }; 12 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformProportion.ts: -------------------------------------------------------------------------------- 1 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 2 | 3 | export const transformProportion = ( 4 | node: AspectRatioLockMixin 5 | ): Pick => { 6 | return { 7 | proportionLock: node.targetAspectRatio !== null 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformSceneNode.ts: -------------------------------------------------------------------------------- 1 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 2 | 3 | export const transformSceneNode = ( 4 | node: SceneNodeMixin 5 | ): Pick => { 6 | return { 7 | blocked: node.locked, 8 | hidden: !node.visible 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /plugin-src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "ES2019", 5 | "lib": ["ES2019"], 6 | "strict": true, 7 | "typeRoots": ["../node_modules/@figma"], 8 | "moduleResolution": "Node", 9 | "skipLibCheck": true, 10 | "resolveJsonModule": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/gradient.ts: -------------------------------------------------------------------------------- 1 | export type Gradient = { 2 | type: 'linear' | 'radial'; 3 | startX: number; 4 | startY: number; 5 | endX: number; 6 | endY: number; 7 | width: number; 8 | stops: GradientStop[]; 9 | }; 10 | 11 | type GradientStop = { 12 | color: string; 13 | opacity?: number; 14 | offset: number; 15 | }; 16 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformGrids.ts: -------------------------------------------------------------------------------- 1 | import { translateGrids } from '@plugin/translators'; 2 | 3 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 4 | 5 | export const transformGrids = (node: BaseFrameMixin): Pick => { 6 | return { 7 | grids: translateGrids(node.layoutGrids) 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /plugin-src/translators/fills/translateSolidFill.ts: -------------------------------------------------------------------------------- 1 | import { rgbToHex } from '@plugin/utils'; 2 | 3 | import type { Fill } from '@ui/lib/types/utils/fill'; 4 | 5 | export const translateSolidFill = (fill: SolidPaint): Fill => { 6 | return { 7 | fillColor: rgbToHex(fill.color), 8 | fillOpacity: !fill.visible ? 0 : fill.opacity 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/blendModes.ts: -------------------------------------------------------------------------------- 1 | export type BlendMode = 2 | | 'normal' 3 | | 'darken' 4 | | 'multiply' 5 | | 'color-burn' 6 | | 'lighten' 7 | | 'screen' 8 | | 'color-dodge' 9 | | 'overlay' 10 | | 'soft-light' 11 | | 'hard-light' 12 | | 'difference' 13 | | 'exclusion' 14 | | 'hue' 15 | | 'saturation' 16 | | 'color' 17 | | 'luminosity'; 18 | -------------------------------------------------------------------------------- /plugin-src/translators/vectors/translateWindingRule.ts: -------------------------------------------------------------------------------- 1 | import type { FillRules } from '@ui/lib/types/shapes/pathShape'; 2 | 3 | export const translateWindingRule = (windingRule: WindingRule | 'NONE'): FillRules | undefined => { 4 | switch (windingRule) { 5 | case 'EVENODD': 6 | return 'evenodd'; 7 | case 'NONZERO': 8 | return 'nonzero'; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /ui-src/utils/formatExportTime.ts: -------------------------------------------------------------------------------- 1 | export const formatExportTime = (milliseconds: number): string => { 2 | const seconds = Math.floor(milliseconds / 1000); 3 | const minutes = Math.floor(seconds / 60); 4 | const remainingSeconds = seconds % 60; 5 | 6 | if (minutes > 0) { 7 | return `${minutes}m ${remainingSeconds}s`; 8 | } 9 | 10 | return `${seconds}s`; 11 | }; 12 | -------------------------------------------------------------------------------- /ui-src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Penpot Exporter 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "penpot/penpot-exporter-figma-plugin" }], 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/imageColor.ts: -------------------------------------------------------------------------------- 1 | import type { Uuid } from './uuid'; 2 | 3 | export type ImageColor = { 4 | name?: string; 5 | width: number; 6 | height: number; 7 | mtype?: string; 8 | id?: Uuid; 9 | keepAspectRatio?: boolean; 10 | dataUri?: string; 11 | }; 12 | 13 | // @TODO: move to any other place 14 | export type PartialImageColor = { 15 | imageHash: string; 16 | }; 17 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/shadow.ts: -------------------------------------------------------------------------------- 1 | import type { Color } from './color'; 2 | import type { Uuid } from './uuid'; 3 | 4 | export type ShadowStyle = 'drop-shadow' | 'inner-shadow'; 5 | 6 | export type Shadow = { 7 | id: Uuid | null; 8 | style: ShadowStyle; 9 | offsetX: number; 10 | offsetY: number; 11 | blur: number; 12 | spread: number; 13 | hidden: boolean; 14 | color: Color; 15 | }; 16 | -------------------------------------------------------------------------------- /plugin-src/getUserData.ts: -------------------------------------------------------------------------------- 1 | import { reportProgress } from '@plugin/utils'; 2 | 3 | export const getUserData = (): void => { 4 | const user = figma.currentUser; 5 | 6 | if (!user || !user.id) { 7 | console.warn('Could not get user data'); 8 | 9 | return; 10 | } 11 | 12 | reportProgress({ 13 | type: 'USER_DATA', 14 | data: { 15 | userId: user.id 16 | } 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /ui-src/components/ProgressStepper/ProgressStepper.module.css: -------------------------------------------------------------------------------- 1 | .step-info { 2 | font-size: 0.8125rem; 3 | font-weight: 500; 4 | } 5 | 6 | .step-item { 7 | display: flex; 8 | font-size: 0.8125rem; 9 | align-items: center; 10 | gap: 0.375rem; 11 | } 12 | 13 | .step-item-active { 14 | color: var(--figma-color-text); 15 | font-weight: 500; 16 | } 17 | 18 | .step-item-next { 19 | opacity: 0.7; 20 | } 21 | -------------------------------------------------------------------------------- /plugin-src/utils/rgbToString.ts: -------------------------------------------------------------------------------- 1 | export const rgbToString = (color: RGB | RGBA): string => { 2 | const r = Math.round(255 * color.r); 3 | const g = Math.round(255 * color.g); 4 | const b = Math.round(255 * color.b); 5 | 6 | if ('a' in color && color.a !== 1) { 7 | const a = color.a.toFixed(2); 8 | 9 | return `rgba(${r}, ${g}, ${b}, ${a})`; 10 | } 11 | 12 | return `rgb(${r}, ${g}, ${b})`; 13 | }; 14 | -------------------------------------------------------------------------------- /plugin-src/translators/text/properties/translateVerticalAlign.ts: -------------------------------------------------------------------------------- 1 | import type { TextVerticalAlign } from '@ui/lib/types/shapes/textShape'; 2 | 3 | export const translateVerticalAlign = (align: 'TOP' | 'CENTER' | 'BOTTOM'): TextVerticalAlign => { 4 | switch (align) { 5 | case 'BOTTOM': 6 | return 'bottom'; 7 | case 'CENTER': 8 | return 'center'; 9 | default: 10 | return 'top'; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /plugin-src/translators/text/properties/translateTextDecoration.ts: -------------------------------------------------------------------------------- 1 | export const translateTextDecoration = ( 2 | segment: Pick 3 | ): 'line-through' | 'underline' | 'none' => { 4 | switch (segment.textDecoration) { 5 | case 'STRIKETHROUGH': 6 | return 'line-through'; 7 | case 'UNDERLINE': 8 | return 'underline'; 9 | default: 10 | return 'none'; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /ui-src/parser/creators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createArtboard'; 2 | export * from './createBool'; 3 | export * from './createCircle'; 4 | export * from './createComponent'; 5 | export * from './createComponentInstance'; 6 | export * from './createGroup'; 7 | export * from './createItems'; 8 | export * from './createPage'; 9 | export * from './createPath'; 10 | export * from './createRectangle'; 11 | export * from './createText'; 12 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformVariableConsumptionMap.ts: -------------------------------------------------------------------------------- 1 | import { translateAppliedTokens } from '@plugin/translators'; 2 | 3 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 4 | 5 | export const transformVariableConsumptionMap = ( 6 | node: SceneNode 7 | ): Pick => { 8 | return { 9 | appliedTokens: translateAppliedTokens(node.boundVariables, node) 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /plugin-src/translators/styles/translateStylePath.ts: -------------------------------------------------------------------------------- 1 | export const translateStylePath = (figmaStyle: BaseStyleMixin): string => { 2 | const path = []; 3 | 4 | if (figmaStyle.remote) { 5 | path.push('Remote'); 6 | } 7 | 8 | if (figmaStyle.name.includes('/')) { 9 | const pathParts = figmaStyle.name.split('/'); 10 | pathParts.pop(); 11 | 12 | path.push(...pathParts); 13 | } 14 | 15 | return path.join(' / '); 16 | }; 17 | -------------------------------------------------------------------------------- /plugin-src/translators/text/properties/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translateFontStyle'; 2 | export * from './translateFontWeight'; 3 | export * from './translateGrowType'; 4 | export * from './translateHorizontalAlign'; 5 | export * from './translateLetterSpacing'; 6 | export * from './translateLineHeight'; 7 | export * from './translateTextDecoration'; 8 | export * from './translateTextTransform'; 9 | export * from './translateVerticalAlign'; 10 | -------------------------------------------------------------------------------- /ui-src/components/Wrapper/Wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { type PropsWithChildren, forwardRef } from 'preact/compat'; 2 | 3 | import styles from './Wrapper.module.css'; 4 | 5 | const Wrapper = forwardRef(({ children }, ref) => { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | }); 12 | 13 | Wrapper.displayName = 'Wrapper'; 14 | 15 | export { Wrapper }; 16 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformBlend.ts: -------------------------------------------------------------------------------- 1 | import { translateBlendMode } from '@plugin/translators'; 2 | 3 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 4 | 5 | export const transformBlend = ( 6 | node: SceneNodeMixin & MinimalBlendMixin 7 | ): Pick => { 8 | return { 9 | blendMode: translateBlendMode(node.blendMode), 10 | opacity: node.opacity 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformEffects.ts: -------------------------------------------------------------------------------- 1 | import { translateBlurEffects, translateShadowEffects } from '@plugin/translators'; 2 | 3 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 4 | 5 | export const transformEffects = (node: BlendMixin): Pick => { 6 | return { 7 | shadow: translateShadowEffects(node.effects), 8 | blur: translateBlurEffects(node.effects) 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /plugin-src/translators/text/properties/translateLineHeight.ts: -------------------------------------------------------------------------------- 1 | export const translateLineHeight = ( 2 | segment: Pick 3 | ): string => { 4 | switch (segment.lineHeight.unit) { 5 | case 'PIXELS': 6 | return (segment.lineHeight.value / segment.fontSize).toString(); 7 | case 'PERCENT': 8 | return (segment.lineHeight.value / 100).toString(); 9 | default: 10 | return '1.2'; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /ui-src/components/ProgressBar/ProgressBar.module.css: -------------------------------------------------------------------------------- 1 | .progress-bar { 2 | width: 100%; 3 | } 4 | 5 | .progress-bar-track { 6 | width: 100%; 7 | height: 0.5rem; 8 | background-color: var(--figma-color-bg-secondary); 9 | border-radius: 999px; 10 | overflow: hidden; 11 | } 12 | 13 | .progress-bar-fill { 14 | height: 100%; 15 | background-color: var(--figma-color-bg-brand); 16 | border-radius: 999px; 17 | transition: width 0.25s ease-out; 18 | } 19 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformComponentNameAndPath.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentShape } from '@ui/lib/types/shapes/componentShape'; 2 | 3 | export const transformComponentNameAndPath = ( 4 | node: ComponentNode 5 | ): Pick => { 6 | const name = node.parent?.type === 'COMPONENT_SET' ? node.parent.name : node.name; 7 | 8 | return { 9 | name, 10 | path: name.split(' / ').slice(0, -1).join(` / `) 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /plugin-src/translators/text/properties/translateTextTransform.ts: -------------------------------------------------------------------------------- 1 | export const translateTextTransform = ( 2 | segment: Pick 3 | ): 'uppercase' | 'lowercase' | 'capitalize' | 'none' => { 4 | switch (segment.textCase) { 5 | case 'UPPER': 6 | return 'uppercase'; 7 | case 'LOWER': 8 | return 'lowercase'; 9 | case 'TITLE': 10 | return 'capitalize'; 11 | default: 12 | return 'none'; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /ui-src/lib/types/shapes/rectShape.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutChildAttributes } from '@ui/lib/types/shapes/layout'; 2 | import type { 3 | ShapeAttributes, 4 | ShapeBaseAttributes, 5 | ShapeGeomAttributes 6 | } from '@ui/lib/types/shapes/shape'; 7 | 8 | export type RectShape = ShapeBaseAttributes & 9 | ShapeGeomAttributes & 10 | ShapeAttributes & 11 | RectAttributes & 12 | LayoutChildAttributes; 13 | 14 | type RectAttributes = { 15 | type?: 'rect'; 16 | }; 17 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformOverrides.ts: -------------------------------------------------------------------------------- 1 | import { translateTouched } from '@plugin/translators'; 2 | 3 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 4 | 5 | export const transformOverrides = ( 6 | node: SceneNode 7 | ): Pick => { 8 | return { 9 | touched: translateTouched(node), 10 | componentPropertyReferences: node.componentPropertyReferences 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /plugin-src/translators/text/properties/translateLetterSpacing.ts: -------------------------------------------------------------------------------- 1 | export const translateLetterSpacing = ( 2 | segment: Pick 3 | ): string => { 4 | switch (segment.letterSpacing.unit) { 5 | case 'PIXELS': 6 | return segment.letterSpacing.value.toString(); 7 | case 'PERCENT': 8 | return ((segment.fontSize * segment.letterSpacing.value) / 100).toString(); 9 | default: 10 | return '0'; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /ui-src/lib/types/shapes/circleShape.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutChildAttributes } from '@ui/lib/types/shapes/layout'; 2 | import type { 3 | ShapeAttributes, 4 | ShapeBaseAttributes, 5 | ShapeGeomAttributes 6 | } from '@ui/lib/types/shapes/shape'; 7 | 8 | export type CircleShape = ShapeBaseAttributes & 9 | ShapeGeomAttributes & 10 | ShapeAttributes & 11 | CircleAttributes & 12 | LayoutChildAttributes; 13 | 14 | type CircleAttributes = { 15 | type?: 'circle'; 16 | }; 17 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createPage.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { PenpotPage } from '@ui/lib/types/penpotPage'; 3 | import { createItems } from '@ui/parser/creators'; 4 | 5 | export const createPage = ( 6 | context: PenpotContext, 7 | { name, background, children = [] }: PenpotPage 8 | ): void => { 9 | context.addPage({ name, background }); 10 | 11 | createItems(context, children); 12 | 13 | context.closePage(); 14 | }; 15 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateTheme.ts: -------------------------------------------------------------------------------- 1 | import type { Theme } from '@ui/lib/types/shapes/tokens'; 2 | 3 | export const translateTheme = ( 4 | collection: VariableCollection, 5 | modeName: string, 6 | setName: string 7 | ): Theme => { 8 | const name = modeName; 9 | const group = collection.name; 10 | 11 | return { 12 | name: name, 13 | group: group, 14 | description: '', 15 | isSource: false, 16 | selectedTokenSets: { [setName]: 'enabled' } 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translateAliasValue'; 2 | export * from './translateColorVariable'; 3 | export * from './translateFloatVariable'; 4 | export * from './translateGenericVariable'; 5 | export * from './translateScope'; 6 | export * from './translateSet'; 7 | export * from './translateTextVariable'; 8 | export * from './translateTheme'; 9 | export * from './translateVariable'; 10 | export * from './translateVariableName'; 11 | export * from './translateVariableValues'; 12 | -------------------------------------------------------------------------------- /common/map.ts: -------------------------------------------------------------------------------- 1 | export const toObject = (map: Map): Record => { 2 | const result: Record = {}; 3 | 4 | for (const [key, value] of map) { 5 | result[key] = value; 6 | } 7 | 8 | return result; 9 | }; 10 | 11 | export const init = (map: Map, records: Record): void => { 12 | map.clear(); 13 | 14 | const entries = Object.entries(records); 15 | 16 | for (const [key, value] of entries) { 17 | map.set(key, value); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /plugin-src/translators/text/properties/translateHorizontalAlign.ts: -------------------------------------------------------------------------------- 1 | import type { TextHorizontalAlign } from '@ui/lib/types/shapes/textShape'; 2 | 3 | export const translateHorizontalAlign = ( 4 | align: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED' 5 | ): TextHorizontalAlign => { 6 | switch (align) { 7 | case 'RIGHT': 8 | return 'right'; 9 | case 'CENTER': 10 | return 'center'; 11 | case 'JUSTIFIED': 12 | return 'justify'; 13 | default: 14 | return 'left'; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /ui-src/lib/types/shapes/variant.ts: -------------------------------------------------------------------------------- 1 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 2 | 3 | export type VariantProperty = { 4 | name: string; 5 | value: string; 6 | }; 7 | 8 | export type VariantComponent = { 9 | variantId?: Uuid; 10 | variantProperties?: VariantProperty[]; 11 | }; 12 | 13 | export type VariantShape = { 14 | variantId?: Uuid; 15 | variantName?: string; 16 | variantError?: string; 17 | }; 18 | 19 | export type VariantContainer = { 20 | isVariantContainer?: boolean; 21 | }; 22 | -------------------------------------------------------------------------------- /plugin-src/translators/text/properties/translateGrowType.ts: -------------------------------------------------------------------------------- 1 | import type { GrowType } from '@ui/lib/types/shapes/shape'; 2 | 3 | export const translateGrowType = (node: TextNode): GrowType => { 4 | if (node.leadingTrim === 'CAP_HEIGHT') { 5 | return 'fixed'; 6 | } 7 | 8 | switch (node.textAutoResize) { 9 | case 'WIDTH_AND_HEIGHT': 10 | return 'auto-width'; 11 | case 'HEIGHT': 12 | return 'auto-height'; 13 | case 'TRUNCATE': 14 | default: 15 | return 'fixed'; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformConstraints.ts: -------------------------------------------------------------------------------- 1 | import { translateConstraintH, translateConstraintV } from '@plugin/translators'; 2 | 3 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 4 | 5 | export const transformConstraints = ( 6 | node: ConstraintMixin 7 | ): Pick => { 8 | return { 9 | constraintsH: translateConstraintH(node.constraints.horizontal), 10 | constraintsV: translateConstraintV(node.constraints.vertical) 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /plugin-src/translators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translateAppliedTokens'; 2 | export * from './translateBlendMode'; 3 | export * from './translateBlurEffects'; 4 | export * from './translateBoolType'; 5 | export * from './translateChildren'; 6 | export * from './translateConstraints'; 7 | export * from './translateGrids'; 8 | export * from './translateLayout'; 9 | export * from './translateRotation'; 10 | export * from './translateShadowEffects'; 11 | export * from './translateStrokes'; 12 | export * from './translateTouched'; 13 | -------------------------------------------------------------------------------- /ui-src/reset.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | * { 8 | margin: 0; 9 | } 10 | 11 | body { 12 | line-height: 1.5; 13 | -webkit-font-smoothing: antialiased; 14 | } 15 | 16 | img, 17 | picture, 18 | video, 19 | canvas, 20 | svg { 21 | display: block; 22 | max-width: 100%; 23 | } 24 | 25 | input, 26 | button, 27 | textarea, 28 | select { 29 | font: inherit; 30 | } 31 | 32 | p, 33 | h1, 34 | h2, 35 | h3, 36 | h4, 37 | h5, 38 | h6 { 39 | overflow-wrap: break-word; 40 | } 41 | -------------------------------------------------------------------------------- /ui-src/lib/types/shapes/groupShape.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ShapeAttributes, 3 | ShapeBaseAttributes, 4 | ShapeGeomAttributes 5 | } from '@ui/lib/types/shapes/shape'; 6 | import type { Children } from '@ui/lib/types/utils/children'; 7 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 8 | 9 | export type GroupShape = ShapeBaseAttributes & 10 | ShapeGeomAttributes & 11 | ShapeAttributes & 12 | GroupAttributes & 13 | Children; 14 | 15 | type GroupAttributes = { 16 | type?: 'group'; 17 | shapes?: Uuid[]; 18 | }; 19 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformPageNode.ts: -------------------------------------------------------------------------------- 1 | import { translateChildren } from '@plugin/translators'; 2 | import { translatePageFill } from '@plugin/translators/fills'; 3 | 4 | import type { PenpotPage } from '@ui/lib/types/penpotPage'; 5 | 6 | export const transformPageNode = async (node: PageNode): Promise => { 7 | return { 8 | name: node.name, 9 | background: node.backgrounds.length ? translatePageFill(node.backgrounds[0]) : undefined, 10 | children: await translateChildren(node.children) 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /plugin-src/translators/translateBlurEffects.ts: -------------------------------------------------------------------------------- 1 | import { generateUuid } from '@plugin/utils'; 2 | 3 | import type { Blur } from '@ui/lib/types/utils/blur'; 4 | 5 | export const translateBlurEffects = (effect: readonly Effect[]): Blur | undefined => { 6 | const blur = effect.find(effect => effect.type === 'LAYER_BLUR') as BlurEffectBase; 7 | 8 | if (!blur) { 9 | return; 10 | } 11 | 12 | return { 13 | id: generateUuid(), 14 | type: 'layer-blur', 15 | value: blur.radius, 16 | hidden: !blur.visible 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /ui-src/lib/types/shapes/pathShape.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutChildAttributes } from '@ui/lib/types/shapes/layout'; 2 | import type { ShapeAttributes, ShapeBaseAttributes } from '@ui/lib/types/shapes/shape'; 3 | 4 | export type PathShape = ShapeBaseAttributes & 5 | ShapeAttributes & 6 | PathAttributes & 7 | LayoutChildAttributes; 8 | 9 | export type PathAttributes = { 10 | type?: 'path'; 11 | content: string; 12 | svgAttrs?: { 13 | fillRule?: FillRules; 14 | }; 15 | }; 16 | 17 | export type FillRules = 'evenodd' | 'nonzero'; 18 | -------------------------------------------------------------------------------- /plugin-src/translators/translateBoolType.ts: -------------------------------------------------------------------------------- 1 | import type { BoolOperations } from '@ui/lib/types/shapes/boolShape'; 2 | 3 | type BooleanOperation = 'UNION' | 'INTERSECT' | 'SUBTRACT' | 'EXCLUDE'; 4 | export const translateBoolType = (booleanOperation: BooleanOperation): BoolOperations => { 5 | switch (booleanOperation) { 6 | case 'EXCLUDE': 7 | return 'exclude'; 8 | case 'INTERSECT': 9 | return 'intersection'; 10 | case 'SUBTRACT': 11 | return 'difference'; 12 | case 'UNION': 13 | return 'union'; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /plugin-src/translators/components/registerComponentProperties.ts: -------------------------------------------------------------------------------- 1 | import { componentProperties } from '@plugin/libraries'; 2 | 3 | export const registerComponentProperties = (node: ComponentSetNode | ComponentNode): void => { 4 | try { 5 | Object.entries(node.componentPropertyDefinitions).forEach(([key, value]) => { 6 | if (value.type === 'TEXT' || value.type === 'BOOLEAN') { 7 | componentProperties.set(key, value); 8 | } 9 | }); 10 | } catch (error) { 11 | console.warn('Could not register component properties', node, error); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | arrowParens: 'avoid', 4 | bracketSpacing: true, 5 | printWidth: 100, 6 | proseWrap: 'always', 7 | quoteProps: 'consistent', 8 | semi: true, 9 | singleQuote: true, 10 | tabWidth: 2, 11 | trailingComma: 'none', 12 | useTabs: false, 13 | plugins: ['@trivago/prettier-plugin-sort-imports'], 14 | importOrder: ['^@common/(.*)$', '^@plugin/(.*)$', '^@ui/(.*)$', '^[./]'], 15 | importOrderSeparation: true, 16 | importOrderSortSpecifiers: true 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /ui-src/components/PluginContainer/PluginContainer.tsx: -------------------------------------------------------------------------------- 1 | import { type PropsWithChildren, forwardRef } from 'preact/compat'; 2 | 3 | type PluginContainerProps = PropsWithChildren<{ 4 | overflowing?: boolean; 5 | }>; 6 | 7 | const PluginContainer = forwardRef( 8 | ({ overflowing = false, children }, ref) => { 9 | return ( 10 |
11 | {children} 12 |
13 | ); 14 | } 15 | ); 16 | 17 | PluginContainer.displayName = 'PluginContainer'; 18 | 19 | export { PluginContainer }; 20 | -------------------------------------------------------------------------------- /ui-src/main.tsx: -------------------------------------------------------------------------------- 1 | import 'node_modules/@create-figma-plugin/ui/lib/css/base.css'; 2 | import { StrictMode } from 'preact/compat'; 3 | import { createRoot } from 'react-dom/client'; 4 | 5 | import { App } from '@ui/App'; 6 | import { initializeMixpanel } from '@ui/metrics/mixpanel'; 7 | import { initializeSentry } from '@ui/metrics/sentry'; 8 | 9 | import './main.css'; 10 | import './reset.css'; 11 | 12 | initializeMixpanel(); 13 | initializeSentry(); 14 | 15 | createRoot(document.getElementById('root') as HTMLElement).render( 16 | 17 | 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /ui-src/lib/penpot.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@penpot/library' { 2 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 3 | import type { ExportOptions } from '@ui/lib/types/exportOptions'; 4 | export function createBuildContext(options?: { referer?: string }): PenpotContext; 5 | export async function exportAsBytes( 6 | context: PenpotContext, 7 | options?: ExportOptions 8 | ): Promise>; 9 | export async function exportStream( 10 | context: PenpotContext, 11 | stream: WritableStream, 12 | options?: ExportOptions 13 | ): Promise; 14 | } 15 | -------------------------------------------------------------------------------- /common/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); 2 | 3 | const DEFAULT_YIELD_MS = 32; 4 | 5 | let lastYieldTimestamp = 0; 6 | 7 | export const yieldByTime = async ( 8 | maxElapsedMs: number = DEFAULT_YIELD_MS, 9 | force: boolean = false 10 | ): Promise => { 11 | const now = Date.now(); 12 | const shouldYieldByTime = maxElapsedMs > 0 && now - lastYieldTimestamp >= maxElapsedMs; 13 | 14 | if (!shouldYieldByTime && !force) { 15 | return; 16 | } 17 | 18 | await sleep(0); 19 | 20 | lastYieldTimestamp = now; 21 | }; 22 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Penpot Exporter", 3 | "id": "1219369440655168734", 4 | "api": "1.0.0", 5 | "main": "dist/code.js", 6 | "ui": "dist/index.html", 7 | "editorType": ["figma"], 8 | "networkAccess": { 9 | "allowedDomains": [ 10 | "https://o4508183201316864.ingest.de.sentry.io", 11 | "https://api-js.mixpanel.com" 12 | ], 13 | "reasoning": "We use Sentry and Mixpanel to monitor the performance of the plugin and get information about errors to continue improving the experience." 14 | }, 15 | "permissions": ["currentuser"], 16 | "documentAccess": "dynamic-page" 17 | } 18 | -------------------------------------------------------------------------------- /ui-src/context/createInMemoryWritable.ts: -------------------------------------------------------------------------------- 1 | export const createInMemoryWritable = (): { 2 | writable: WritableStream; 3 | getBlob: (type?: string) => Blob; 4 | } => { 5 | const chunks: Uint8Array[] = []; 6 | 7 | const writable = new WritableStream({ 8 | write(chunk: Uint8Array): void { 9 | chunks.push(chunk); 10 | }, 11 | close(): void {}, 12 | abort(err: Error): void { 13 | console.error('Writable aborted:', err); 14 | } 15 | }); 16 | 17 | return { 18 | writable, 19 | getBlob: (type = 'application/zip') => new Blob(chunks, { type }) 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /ui-src/context/FigmaContext.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'preact'; 2 | import type { PropsWithChildren } from 'preact/compat'; 3 | 4 | import { createGenericContext } from '@ui/context/createGenericContext'; 5 | import { type UseFigmaHook, useFigma } from '@ui/context/useFigma'; 6 | 7 | const [useFigmaContext, StateContextProvider] = createGenericContext(); 8 | 9 | const FigmaProvider = ({ children }: PropsWithChildren): JSX.Element => { 10 | const hook = useFigma(); 11 | 12 | return {children}; 13 | }; 14 | 15 | export { FigmaProvider, useFigmaContext }; 16 | -------------------------------------------------------------------------------- /ui-src/components/ProgressBar/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'preact'; 2 | 3 | import styles from './ProgressBar.module.css'; 4 | 5 | type ProgressBarProps = { 6 | value: number; 7 | }; 8 | 9 | export const ProgressBar = ({ value }: ProgressBarProps): JSX.Element => { 10 | const clampedValue = Math.min(100, Math.max(0, value)); 11 | 12 | return ( 13 |
14 |
15 |
16 |
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /ui-src/components/Stack/Stack.module.css: -------------------------------------------------------------------------------- 1 | .stack { 2 | --direction: column; 3 | --spacing: 1.5rem; 4 | 5 | display: flex; 6 | flex-direction: var(--direction); 7 | gap: var(--spacing); 8 | 9 | .stack-row { 10 | --direction: row; 11 | 12 | & > * { 13 | flex: 1; 14 | } 15 | } 16 | 17 | .stack-small { 18 | --spacing: 1rem; 19 | } 20 | 21 | .stack-xsmall { 22 | --spacing: 0.5rem; 23 | } 24 | 25 | .stack-2xsmall { 26 | --spacing: 0.25rem; 27 | } 28 | 29 | .stack-3xsmall { 30 | --spacing: 0.125rem; 31 | } 32 | 33 | .stack-center { 34 | align-items: center; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformDimensionAndPosition.ts: -------------------------------------------------------------------------------- 1 | import type { ShapeGeomAttributes } from '@ui/lib/types/shapes/shape'; 2 | 3 | export const transformDimension = ( 4 | node: DimensionAndPositionMixin 5 | ): Pick => { 6 | return { 7 | width: node.width, 8 | height: node.height 9 | }; 10 | }; 11 | 12 | export const transformDimensionAndPosition = ( 13 | node: DimensionAndPositionMixin 14 | ): ShapeGeomAttributes => { 15 | return { 16 | x: node.absoluteTransform[0][2], 17 | y: node.absoluteTransform[1][2], 18 | width: node.width, 19 | height: node.height 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /ui-src/parser/creators/symbols/symbolStrokes.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { Stroke } from '@ui/lib/types/utils/stroke'; 3 | import { symbolFillImage } from '@ui/parser/creators/symbols'; 4 | 5 | export const symbolStrokes = (context: PenpotContext, strokes?: Stroke[]): Stroke[] | undefined => { 6 | if (!strokes) return; 7 | 8 | return strokes.map(stroke => { 9 | if (stroke.strokeImage) { 10 | const strokeImage = symbolFillImage(context, stroke.strokeImage); 11 | if (strokeImage) { 12 | stroke.strokeImage = strokeImage; 13 | } 14 | } 15 | 16 | return stroke; 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /plugin-src/transformers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './transformBooleanNode'; 2 | export * from './transformComponentNode'; 3 | export * from './transformComponentSetNode'; 4 | export * from './transformDocumentNode'; 5 | export * from './transformEllipseNode'; 6 | export * from './transformFrameNode'; 7 | export * from './transformGroupNode'; 8 | export * from './transformInstanceNode'; 9 | export * from './transformLineNode'; 10 | export * from './transformPageNode'; 11 | export * from './transformPathNode'; 12 | export * from './transformRectangleNode'; 13 | export * from './transformSceneNode'; 14 | export * from './transformTextNode'; 15 | export * from './transformVectorNode'; 16 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/custom/translateCustomFont.ts: -------------------------------------------------------------------------------- 1 | import { missingFonts } from '@plugin/libraries'; 2 | import { translateFontVariantId } from '@plugin/translators/text/font/custom'; 3 | 4 | import type { TextTypography } from '@ui/lib/types/shapes/textShape'; 5 | 6 | export const translateCustomFont = ( 7 | fontName: FontName | undefined, 8 | fontWeight: string 9 | ): Pick | undefined => { 10 | if (fontName) { 11 | missingFonts.add(fontName.family); 12 | } 13 | 14 | return { 15 | fontId: '', 16 | fontVariantId: translateFontVariantId(fontName, fontWeight), 17 | fontWeight 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /ui-src/context/createGenericContext.ts: -------------------------------------------------------------------------------- 1 | import type { Provider } from 'preact'; 2 | import { createContext } from 'preact/compat'; 3 | import { useContext } from 'preact/hooks'; 4 | 5 | export const createGenericContext = (): [() => K, Provider] => { 6 | const genericContext = createContext(undefined); 7 | 8 | const useGenericContext = (): K => { 9 | const context = useContext(genericContext); 10 | 11 | if (!context) { 12 | throw new Error('useGenericContext must be used within a Provider'); 13 | } 14 | 15 | return context as K; 16 | }; 17 | 18 | return [useGenericContext, genericContext.Provider]; 19 | }; 20 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateSet.ts: -------------------------------------------------------------------------------- 1 | import { translateVariable } from '@plugin/translators/tokens'; 2 | 3 | import type { Set } from '@ui/lib/types/shapes/tokens'; 4 | 5 | export const translateSet = ( 6 | collection: VariableCollection, 7 | modeName: string, 8 | variables: Variable[], 9 | modeId: string 10 | ): [string, Set] => { 11 | const setName = `${collection.name}/${modeName}`; 12 | const set: Set = {}; 13 | 14 | for (const variable of variables) { 15 | const result = translateVariable(variable, modeId); 16 | if (!result) continue; 17 | 18 | const [name, token] = result; 19 | 20 | set[name] = token; 21 | } 22 | 23 | return [setName, set]; 24 | }; 25 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createPath.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { PathShape } from '@ui/lib/types/shapes/pathShape'; 3 | import { symbolFills, symbolStrokes, symbolTouched } from '@ui/parser/creators/symbols'; 4 | 5 | export const createPath = (context: PenpotContext, { type: _type, ...shape }: PathShape): void => { 6 | shape.fills = symbolFills(context, shape.fillStyleId, shape.fills); 7 | shape.strokes = symbolStrokes(context, shape.strokes); 8 | shape.touched = symbolTouched( 9 | !shape.hidden, 10 | undefined, 11 | shape.touched, 12 | shape.componentPropertyReferences 13 | ); 14 | 15 | context.addPath(shape); 16 | }; 17 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformChildren.ts: -------------------------------------------------------------------------------- 1 | import { translateChildren, translateMaskChildren } from '@plugin/translators'; 2 | 3 | import type { Children } from '@ui/lib/types/utils/children'; 4 | 5 | const nodeActsAsMask = (node: SceneNode): boolean => { 6 | return 'isMask' in node && node.isMask; 7 | }; 8 | 9 | export const transformChildren = async (node: ChildrenMixin): Promise => { 10 | const maskIndex = node.children.findIndex(nodeActsAsMask); 11 | const containsMask = maskIndex !== -1; 12 | 13 | return { 14 | children: containsMask 15 | ? await translateMaskChildren(node.children, maskIndex) 16 | : await translateChildren(node.children) 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /ui-src/components/PenpotExporter.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'preact'; 2 | 3 | import { ExportForm } from '@ui/components/ExportForm'; 4 | import { ExportSummary } from '@ui/components/ExportSummary'; 5 | import { ExporterProgress } from '@ui/components/ExporterProgress'; 6 | import { LibraryError } from '@ui/components/LibraryError'; 7 | import { useFigmaContext } from '@ui/context'; 8 | 9 | export const PenpotExporter = (): JSX.Element => { 10 | const { exporting, summary, error } = useFigmaContext(); 11 | 12 | if (exporting) return ; 13 | 14 | if (summary) return ; 15 | 16 | if (error) return ; 17 | 18 | return ; 19 | }; 20 | -------------------------------------------------------------------------------- /ui-src/parser/libraries.ts: -------------------------------------------------------------------------------- 1 | import type { TypographyStyle } from '@ui/lib/types/shapes/textShape'; 2 | import type { FillStyle } from '@ui/lib/types/utils/fill'; 3 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 4 | import type { ComponentProperty, ComponentRoot, UiComponent } from '@ui/types'; 5 | 6 | export const typographies: Map = new Map(); 7 | export const images: Map = new Map(); 8 | export const components: Map = new Map(); 9 | export const componentRoots: Map = new Map(); 10 | export const colors: Map = new Map(); 11 | export const componentProperties: Map = new Map(); 12 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createGroup.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { GroupShape } from '@ui/lib/types/shapes/groupShape'; 3 | import { createItems } from '@ui/parser/creators'; 4 | import { symbolTouched } from '@ui/parser/creators/symbols'; 5 | 6 | export const createGroup = ( 7 | context: PenpotContext, 8 | { type: _type, children = [], ...shape }: GroupShape 9 | ): void => { 10 | shape.touched = symbolTouched( 11 | !shape.hidden, 12 | undefined, 13 | shape.touched, 14 | shape.componentPropertyReferences 15 | ); 16 | 17 | context.addGroup(shape); 18 | 19 | createItems(context, children); 20 | 21 | context.closeGroup(); 22 | }; 23 | -------------------------------------------------------------------------------- /plugin-src/translators/components/registerVariantProperties.ts: -------------------------------------------------------------------------------- 1 | import { variantProperties } from '@plugin/libraries'; 2 | import { transformId } from '@plugin/transformers/partials'; 3 | 4 | export const registerVariantProperties = (node: ComponentSetNode): void => { 5 | const variants = node.children; 6 | 7 | const variantPropertyNames = new Set(); 8 | 9 | for (const variant of variants) { 10 | const properties = variant.name.split(','); 11 | 12 | for (const pair of properties) { 13 | const [name] = pair.split('=').map(s => s.trim()); 14 | 15 | variantPropertyNames.add(name); 16 | } 17 | } 18 | 19 | variantProperties.set(transformId(node), variantPropertyNames); 20 | }; 21 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createCircle.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { CircleShape } from '@ui/lib/types/shapes/circleShape'; 3 | import { symbolFills, symbolStrokes, symbolTouched } from '@ui/parser/creators/symbols'; 4 | 5 | export const createCircle = ( 6 | context: PenpotContext, 7 | { type: _type, ...shape }: CircleShape 8 | ): void => { 9 | shape.fills = symbolFills(context, shape.fillStyleId, shape.fills); 10 | shape.strokes = symbolStrokes(context, shape.strokes); 11 | shape.touched = symbolTouched( 12 | !shape.hidden, 13 | undefined, 14 | shape.touched, 15 | shape.componentPropertyReferences 16 | ); 17 | 18 | context.addCircle(shape); 19 | }; 20 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createRectangle.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { RectShape } from '@ui/lib/types/shapes/rectShape'; 3 | import { symbolFills, symbolStrokes, symbolTouched } from '@ui/parser/creators/symbols'; 4 | 5 | export const createRectangle = ( 6 | context: PenpotContext, 7 | { type: _type, ...shape }: RectShape 8 | ): void => { 9 | shape.fills = symbolFills(context, shape.fillStyleId, shape.fills); 10 | shape.strokes = symbolStrokes(context, shape.strokes); 11 | shape.touched = symbolTouched( 12 | !shape.hidden, 13 | undefined, 14 | shape.touched, 15 | shape.componentPropertyReferences 16 | ); 17 | 18 | context.addRect(shape); 19 | }; 20 | -------------------------------------------------------------------------------- /plugin-src/utils/calculateLinearGradient.ts: -------------------------------------------------------------------------------- 1 | import { applyMatrixToPoint } from '@plugin/utils/applyMatrixToPoint'; 2 | import { matrixInvert } from '@plugin/utils/matrixInvert'; 3 | 4 | export const calculateLinearGradient = (t: Transform): { start: number[]; end: number[] } => { 5 | const transform = t.length === 2 ? [...t, [0, 0, 1]] : [...t]; 6 | const mxInv = matrixInvert(transform); 7 | 8 | if (!mxInv) { 9 | return { 10 | start: [0, 0], 11 | end: [0, 0] 12 | }; 13 | } 14 | 15 | const startEnd = [ 16 | [0, 0.5], 17 | [1, 0.5] 18 | ].map(p => applyMatrixToPoint(mxInv, p)); 19 | 20 | return { 21 | start: [startEnd[0][0], startEnd[0][1]], 22 | end: [startEnd[1][0], startEnd[1][1]] 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /ui-src/utils/detectMimeType.ts: -------------------------------------------------------------------------------- 1 | export const detectMimeType = (bytes: Uint8Array): string => { 2 | const length = 4; 3 | 4 | if (bytes.length >= length) { 5 | const signatureArr = new Array(length); 6 | 7 | for (let index = 0; index < length; index++) { 8 | signatureArr[index] = bytes[index].toString(16); 9 | } 10 | 11 | const signature = signatureArr.join('').toUpperCase(); 12 | 13 | switch (signature) { 14 | case '89504E47': 15 | return 'image/png'; 16 | case '47494638': 17 | return 'image/gif'; 18 | case 'FFD8FFDB': 19 | case 'FFD8FFE0': 20 | return 'image/jpeg'; 21 | default: 22 | return 'image/png'; 23 | } 24 | } 25 | 26 | return 'image/png'; 27 | }; 28 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/animation.ts: -------------------------------------------------------------------------------- 1 | export type Animation = AnimationDissolve | AnimationSlide | AnimationPush; 2 | 3 | type AnimationDissolve = { 4 | animationType: 'dissolve'; 5 | duration: number; 6 | easing: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out'; 7 | }; 8 | 9 | type AnimationSlide = { 10 | animationType: 'slide'; 11 | duration: number; 12 | easing: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out'; 13 | way: 'in' | 'out'; 14 | direction: 'right' | 'left' | 'up' | 'down'; 15 | offsetEffect: boolean; 16 | }; 17 | 18 | type AnimationPush = { 19 | animationType: 'push'; 20 | duration: number; 21 | easing: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out'; 22 | direction: 'right' | 'left' | 'up' | 'down'; 23 | }; 24 | -------------------------------------------------------------------------------- /ui-src/types/penpotDocument.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotPage } from '@ui/lib/types/penpotPage'; 2 | import type { TypographyStyle } from '@ui/lib/types/shapes/textShape'; 3 | import type { Tokens } from '@ui/lib/types/shapes/tokens'; 4 | import type { FillStyle } from '@ui/lib/types/utils/fill'; 5 | import type { ComponentProperty, ComponentRoot } from '@ui/types/component'; 6 | 7 | export type PenpotDocument = { 8 | name: string; 9 | children?: PenpotPage[]; 10 | components: Record; 11 | images: Record>; 12 | paintStyles: Record; 13 | textStyles: Record; 14 | tokens?: Tokens; 15 | componentProperties: Record; 16 | missingFonts: string[]; 17 | }; 18 | -------------------------------------------------------------------------------- /plugin-src/translators/text/paragraph/ListTypeFactory.ts: -------------------------------------------------------------------------------- 1 | import type { ListType } from '@plugin/translators/text/paragraph/ListType'; 2 | import { OrderedListType } from '@plugin/translators/text/paragraph/OrderedListType'; 3 | import { UnorderedListType } from '@plugin/translators/text/paragraph/UnorderedListType'; 4 | 5 | export class ListTypeFactory { 6 | private unorderedList = new UnorderedListType(); 7 | private orderedList = new OrderedListType(); 8 | 9 | public getListType(textListOptions: TextListOptions): ListType { 10 | switch (textListOptions.type) { 11 | case 'ORDERED': 12 | return this.orderedList; 13 | case 'UNORDERED': 14 | return this.unorderedList; 15 | } 16 | 17 | throw new Error('List type not valid'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Publish and release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | permissions: 8 | contents: write 9 | issues: write 10 | pull-requests: write 11 | 12 | concurrency: ${{ github.workflow }}-${{ github.ref }} 13 | 14 | jobs: 15 | publish: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | node: ['24.x'] 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node }} 25 | - uses: bahmutov/npm-install@v1 26 | - uses: changesets/action@v1 27 | with: 28 | title: Release 29 | publish: npx changeset tag 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /plugin-src/translators/vectors/translateCommands.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from 'svg-path-parser'; 2 | 3 | import { translateNonRotatedCommands } from '@plugin/translators/vectors/translateNonRotatedCommands'; 4 | import { translateRotatedCommands } from '@plugin/translators/vectors/translateRotatedCommands'; 5 | import { isTransformed } from '@plugin/utils'; 6 | 7 | export const translateCommands = (node: LayoutMixin, commands: Command[]): string => { 8 | if (node.absoluteBoundingBox && isTransformed(node.absoluteTransform)) { 9 | return translateRotatedCommands(commands, node.absoluteTransform, node.absoluteBoundingBox); 10 | } 11 | 12 | return translateNonRotatedCommands( 13 | commands, 14 | node.absoluteTransform[0][2], 15 | node.absoluteTransform[1][2] 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /plugin-src/translators/fills/translateImageFill.ts: -------------------------------------------------------------------------------- 1 | import { images } from '@plugin/libraries'; 2 | 3 | import type { Fill } from '@ui/lib/types/utils/fill'; 4 | import type { PartialImageColor } from '@ui/lib/types/utils/imageColor'; 5 | 6 | export const translateImageFill = (fill: ImagePaint): Fill | undefined => { 7 | const fillImage = translateImage(fill.imageHash); 8 | if (!fillImage) return; 9 | 10 | return { 11 | fillOpacity: !fill.visible ? 0 : fill.opacity, 12 | fillImage 13 | }; 14 | }; 15 | 16 | const translateImage = (imageHash: string | null): PartialImageColor | undefined => { 17 | if (!imageHash) return; 18 | 19 | if (!images.has(imageHash)) { 20 | images.set(imageHash, figma.getImageByHash(imageHash)); 21 | } 22 | 23 | return { 24 | imageHash 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateScope.ts: -------------------------------------------------------------------------------- 1 | import type { TokenType } from '@ui/lib/types/shapes/tokens'; 2 | 3 | export const translateScope = (scope: VariableScope): TokenType | null => { 4 | switch (scope) { 5 | case 'CORNER_RADIUS': 6 | return 'borderRadius'; 7 | case 'WIDTH_HEIGHT': 8 | return 'sizing'; 9 | case 'GAP': 10 | return 'spacing'; 11 | case 'STROKE_FLOAT': 12 | return 'borderWidth'; 13 | case 'OPACITY': 14 | return 'opacity'; 15 | case 'FONT_STYLE': 16 | case 'FONT_WEIGHT': 17 | return 'fontWeights'; 18 | case 'FONT_SIZE': 19 | return 'fontSizes'; 20 | case 'LETTER_SPACING': 21 | return 'letterSpacing'; 22 | case 'FONT_FAMILY': 23 | return 'fontFamilies'; 24 | default: 25 | return null; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /plugin-src/Cache.ts: -------------------------------------------------------------------------------- 1 | import { LRUCache } from 'lru-cache'; 2 | 3 | const empty: unique symbol = Symbol('noValue'); 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 6 | export class Cache { 7 | private cache: LRUCache; 8 | 9 | public constructor(options: LRUCache.Options) { 10 | this.cache = new LRUCache(options); 11 | } 12 | 13 | public get(key: K, calculate: () => V | undefined): V | undefined { 14 | if (this.cache.has(key)) { 15 | const cacheItem = this.cache.get(key); 16 | 17 | return cacheItem === empty ? undefined : cacheItem; 18 | } 19 | 20 | const calculated = calculate(); 21 | 22 | this.cache.set(key, calculated ?? empty); 23 | 24 | return calculated; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateVariableValues.ts: -------------------------------------------------------------------------------- 1 | import type { Token, TokenType } from '@ui/lib/types/shapes/tokens'; 2 | 3 | export const translateVariableValues = ( 4 | variable: Variable, 5 | modeId: string, 6 | translateScopes: (variable: Variable) => TokenType[], 7 | translateValue: (value: VariableValue, tokenType: TokenType) => Token['$value'] | null 8 | ): Map => { 9 | const value = variable.valuesByMode[modeId]; 10 | 11 | const tokenTypes = translateScopes(variable); 12 | const variableTypes = new Map(); 13 | 14 | for (const tokenType of tokenTypes) { 15 | const $value = translateValue(value, tokenType); 16 | if (!$value) continue; 17 | 18 | variableTypes.set(tokenType, $value); 19 | } 20 | 21 | return variableTypes; 22 | }; 23 | -------------------------------------------------------------------------------- /plugin-src/translators/fills/gradients/translateGradientLinearFill.ts: -------------------------------------------------------------------------------- 1 | import { calculateLinearGradient, rgbToHex } from '@plugin/utils'; 2 | 3 | import type { Fill } from '@ui/lib/types/utils/fill'; 4 | 5 | export const translateGradientLinearFill = (fill: GradientPaint): Fill => { 6 | const points = calculateLinearGradient(fill.gradientTransform); 7 | 8 | return { 9 | fillColorGradient: { 10 | type: 'linear', 11 | startX: points.start[0], 12 | startY: points.start[1], 13 | endX: points.end[0], 14 | endY: points.end[1], 15 | width: 1, 16 | stops: fill.gradientStops.map(stop => ({ 17 | color: rgbToHex(stop.color), 18 | offset: stop.position, 19 | opacity: stop.color.a * (fill.opacity ?? 1) 20 | })) 21 | }, 22 | fillOpacity: !fill.visible ? 0 : fill.opacity 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /plugin-src/translators/fills/gradients/translateGradientRadialFill.ts: -------------------------------------------------------------------------------- 1 | import { calculateRadialGradient, rgbToHex } from '@plugin/utils'; 2 | 3 | import type { Fill } from '@ui/lib/types/utils/fill'; 4 | 5 | export const translateGradientRadialFill = (fill: GradientPaint): Fill => { 6 | const points = calculateRadialGradient(fill.gradientTransform); 7 | 8 | return { 9 | fillColorGradient: { 10 | type: 'radial', 11 | startX: points.start[0], 12 | startY: points.start[1], 13 | endX: points.end[0], 14 | endY: points.end[1], 15 | width: 1, 16 | stops: fill.gradientStops.map(stop => ({ 17 | color: rgbToHex(stop.color), 18 | offset: stop.position, 19 | opacity: stop.color.a * (fill.opacity ?? 1) 20 | })) 21 | }, 22 | fillOpacity: !fill.visible ? 0 : fill.opacity 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/translateFontName.ts: -------------------------------------------------------------------------------- 1 | import { translateCustomFont } from '@plugin/translators/text/font/custom'; 2 | import { translateGoogleFont } from '@plugin/translators/text/font/gfonts'; 3 | import { translateLocalFont } from '@plugin/translators/text/font/local'; 4 | import { translateFontWeight } from '@plugin/translators/text/properties'; 5 | 6 | import type { TextTypography } from '@ui/lib/types/shapes/textShape'; 7 | 8 | export const translateFontName = ( 9 | fontName: FontName | undefined 10 | ): Pick | undefined => { 11 | const fontWeight = translateFontWeight(fontName); 12 | 13 | return ( 14 | translateGoogleFont(fontName, fontWeight) ?? 15 | translateLocalFont(fontName, fontWeight) ?? 16 | translateCustomFont(fontName, fontWeight) 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /ui-src/types/penpotNode.ts: -------------------------------------------------------------------------------- 1 | import type { BoolShape } from '@ui/lib/types/shapes/boolShape'; 2 | import type { CircleShape } from '@ui/lib/types/shapes/circleShape'; 3 | import type { ComponentShape } from '@ui/lib/types/shapes/componentShape'; 4 | import type { FrameShape } from '@ui/lib/types/shapes/frameShape'; 5 | import type { GroupShape } from '@ui/lib/types/shapes/groupShape'; 6 | import type { PathShape } from '@ui/lib/types/shapes/pathShape'; 7 | import type { RectShape } from '@ui/lib/types/shapes/rectShape'; 8 | import type { TextShape } from '@ui/lib/types/shapes/textShape'; 9 | import type { ComponentInstance } from '@ui/types'; 10 | 11 | export type PenpotNode = 12 | | FrameShape 13 | | GroupShape 14 | | PathShape 15 | | RectShape 16 | | CircleShape 17 | | TextShape 18 | | BoolShape 19 | | ComponentInstance 20 | | ComponentShape; 21 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/color.ts: -------------------------------------------------------------------------------- 1 | import type { Gradient } from './gradient'; 2 | import type { ImageColor, PartialImageColor } from './imageColor'; 3 | import type { Uuid } from './uuid'; 4 | 5 | export type Color = FigmaColor | PenpotColor; 6 | 7 | // @TODO: move to any other place 8 | type FigmaColor = { 9 | id?: Uuid; 10 | name?: string; 11 | path?: string; 12 | value?: string; 13 | color?: string; 14 | opacity?: number; 15 | modifiedAt?: string; 16 | refId?: Uuid; 17 | refFile?: Uuid; 18 | gradient?: Gradient; 19 | image?: PartialImageColor; 20 | }; 21 | 22 | type PenpotColor = { 23 | id?: Uuid; 24 | name?: string; 25 | path?: string; 26 | value?: string; 27 | color?: string; 28 | opacity?: number; 29 | modifiedAt?: string; 30 | refId?: Uuid; 31 | refFile?: Uuid; 32 | gradient?: Gradient; 33 | image?: ImageColor; 34 | }; 35 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateVariable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | translateColorVariable, 3 | translateFloatVariable, 4 | translateTextVariable, 5 | translateVariableName 6 | } from '@plugin/translators/tokens'; 7 | 8 | import type { Token } from '@ui/lib/types/shapes/tokens'; 9 | 10 | export const translateVariable = ( 11 | variable: Variable, 12 | modeId: string 13 | ): [string, Token | Record] | null => { 14 | const variableName = translateVariableName(variable); 15 | 16 | switch (variable.resolvedType) { 17 | case 'COLOR': 18 | return translateColorVariable(variable, variableName, modeId); 19 | case 'FLOAT': 20 | return translateFloatVariable(variable, variableName, modeId); 21 | case 'STRING': 22 | return translateTextVariable(variable, variableName, modeId); 23 | default: 24 | return null; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main, dev] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | name: Build ${{ matrix.node }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node: ['24.x'] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node }} 20 | - uses: bahmutov/npm-install@v1 21 | - run: npm run build 22 | lint: 23 | name: Lint ${{ matrix.node }} 24 | runs-on: ubuntu-latest 25 | strategy: 26 | matrix: 27 | node: ['24.x'] 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version: ${{ matrix.node }} 33 | - uses: bahmutov/npm-install@v1 34 | - run: npm run lint 35 | -------------------------------------------------------------------------------- /plugin-src/translators/translateConstraints.ts: -------------------------------------------------------------------------------- 1 | import type { ConstraintH, ConstraintV } from '@ui/lib/types/shapes/shape'; 2 | 3 | export const translateConstraintH = (constraint: ConstraintType): ConstraintH => { 4 | switch (constraint) { 5 | case 'MAX': 6 | return 'right'; 7 | case 'MIN': 8 | return 'left'; 9 | case 'CENTER': 10 | return 'center'; 11 | case 'SCALE': 12 | return 'scale'; 13 | case 'STRETCH': 14 | return 'leftright'; 15 | } 16 | }; 17 | 18 | export const translateConstraintV = (constraint: ConstraintType): ConstraintV => { 19 | switch (constraint) { 20 | case 'MAX': 21 | return 'bottom'; 22 | case 'MIN': 23 | return 'top'; 24 | case 'CENTER': 25 | return 'center'; 26 | case 'SCALE': 27 | return 'scale'; 28 | case 'STRETCH': 29 | return 'topbottom'; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /ui-src/metrics/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/react'; 2 | 3 | export const initializeSentry = (): void => { 4 | if (import.meta.env.VITE_SENTRY_DSN && import.meta.env.PROD) { 5 | Sentry.init({ 6 | dsn: import.meta.env.VITE_SENTRY_DSN, 7 | integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], 8 | release: `penpot-exporter@${APP_VERSION}`, 9 | // Tracing 10 | tracesSampleRate: 1.0, // Capture 100% of the transactions 11 | // Session Replay 12 | replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. 13 | replaysOnErrorSampleRate: 1.0 // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. 14 | }); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/fill.ts: -------------------------------------------------------------------------------- 1 | import type { Color } from '@ui/lib/types/utils/color'; 2 | 3 | import type { Gradient } from './gradient'; 4 | import type { ImageColor, PartialImageColor } from './imageColor'; 5 | import type { Uuid } from './uuid'; 6 | 7 | export type Fill = FigmaFill | PenpotFill; 8 | 9 | // @TODO: move to any other place 10 | type FigmaFill = { 11 | fillColor?: string; 12 | fillOpacity?: number; 13 | fillColorGradient?: Gradient; 14 | fillColorRefFile?: Uuid; 15 | fillColorRefId?: Uuid; 16 | fillImage?: PartialImageColor; 17 | }; 18 | 19 | type PenpotFill = { 20 | fillColor?: string; 21 | fillOpacity?: number; 22 | fillColorGradient?: Gradient; 23 | fillColorRefFile?: Uuid; 24 | fillColorRefId?: Uuid; 25 | fillImage?: ImageColor; 26 | }; 27 | 28 | export type FillStyle = { 29 | name: string; 30 | fills: Fill[]; 31 | colors: Color[]; 32 | }; 33 | -------------------------------------------------------------------------------- /ui-src/lib/types/shapes/frameShape.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutAttributes, LayoutChildAttributes } from '@ui/lib/types/shapes/layout'; 2 | import type { 3 | ShapeAttributes, 4 | ShapeBaseAttributes, 5 | ShapeGeomAttributes 6 | } from '@ui/lib/types/shapes/shape'; 7 | import type { VariantContainer, VariantShape } from '@ui/lib/types/shapes/variant'; 8 | import type { Children } from '@ui/lib/types/utils/children'; 9 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 10 | 11 | export type FrameShape = ShapeBaseAttributes & 12 | ShapeAttributes & 13 | ShapeGeomAttributes & 14 | FrameAttributes & 15 | LayoutAttributes & 16 | LayoutChildAttributes & 17 | VariantShape & 18 | VariantContainer & 19 | Children; 20 | 21 | type FrameAttributes = { 22 | type?: 'frame'; 23 | shapes?: Uuid[]; 24 | hideFillOnExport?: boolean; 25 | showContent?: boolean; 26 | hideInViewer?: boolean; 27 | }; 28 | -------------------------------------------------------------------------------- /ui-src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "paths": { 8 | "@common/*": ["./common/*"], 9 | "@ui/*": ["./ui-src/*"], 10 | "react": ["./node_modules/preact/compat/"], 11 | "react-dom": ["./node_modules/preact/compat/"], 12 | "react-dom/client": ["./node_modules/preact/compat/client"] 13 | }, 14 | "allowJs": false, 15 | "skipLibCheck": true, 16 | "esModuleInterop": false, 17 | "strict": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "module": "ESNext", 20 | "moduleResolution": "Node", 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "jsx": "react-jsx", 24 | "jsxImportSource": "preact", 25 | "allowSyntheticDefaultImports": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateVariableName.ts: -------------------------------------------------------------------------------- 1 | import { uniqueVariableNames, variableNames } from '@plugin/libraries'; 2 | 3 | export const translateVariableName = (variable: Variable): string => { 4 | if (variableNames.has(variable.id)) { 5 | return variableNames.get(variable.id)!; 6 | } 7 | 8 | let name = variable.name 9 | .replace(/\//g, '.') 10 | .replace(/[^a-zA-Z0-9\-$_.]/g, '') 11 | .replace(/^\$/, 'S') 12 | .replace(/^\./, 'D') 13 | .replace(/\.$/, 'D') 14 | .replace(/\.{2,}/g, '.'); 15 | 16 | if (name === '') { 17 | name = 'unnamed'; 18 | } 19 | 20 | if (uniqueVariableNames.has(name)) { 21 | let i = 1; 22 | 23 | while (uniqueVariableNames.has(`${name}-${i}`)) { 24 | i++; 25 | } 26 | 27 | name = `${name}-${i}`; 28 | } 29 | 30 | uniqueVariableNames.add(name); 31 | variableNames.set(variable.id, name); 32 | 33 | return name; 34 | }; 35 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/gfonts/translateFontVariantId.ts: -------------------------------------------------------------------------------- 1 | import type { GoogleFont } from '@plugin/translators/text/font/gfonts/googleFont'; 2 | 3 | export const translateFontVariantId = ( 4 | googleFont: GoogleFont, 5 | fontName: FontName, 6 | fontWeight: string 7 | ): string => { 8 | // check match directly by style 9 | const variant = googleFont.variants?.find(variant => variant === fontName.style.toLowerCase()); 10 | 11 | if (variant !== undefined) return variant; 12 | 13 | // check match by style and weight 14 | const italic = fontName.style.toLowerCase().includes('italic') ? 'italic' : ''; 15 | const variantWithWeight = googleFont.variants?.find( 16 | variant => variant === `${fontWeight}${italic}` 17 | ); 18 | 19 | if (variantWithWeight !== undefined) return variantWithWeight; 20 | 21 | // fallback to font weight (it will not be displayed on Penpot, but it will be rendered) 22 | return fontWeight; 23 | }; 24 | -------------------------------------------------------------------------------- /plugin-src/translators/translateRotation.ts: -------------------------------------------------------------------------------- 1 | import type { ShapeBaseAttributes } from '@ui/lib/types/shapes/shape'; 2 | 3 | export const translateRotation = ( 4 | transform: Transform, 5 | rotation: number 6 | ): Pick => { 7 | return { 8 | rotation, 9 | transform: { 10 | a: transform[0][0], 11 | b: transform[1][0], 12 | c: transform[0][1], 13 | d: transform[1][1], 14 | e: 0, 15 | f: 0 16 | }, 17 | transformInverse: { 18 | a: transform[0][0], 19 | b: transform[0][1], 20 | c: transform[1][0], 21 | d: transform[1][1], 22 | e: 0, 23 | f: 0 24 | } 25 | }; 26 | }; 27 | 28 | export const translateZeroRotation = (): Pick< 29 | ShapeBaseAttributes, 30 | 'transform' | 'transformInverse' | 'rotation' 31 | > => ({ 32 | rotation: 0, 33 | transform: undefined, 34 | transformInverse: undefined 35 | }); 36 | -------------------------------------------------------------------------------- /plugin-src/libraries.ts: -------------------------------------------------------------------------------- 1 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 2 | import type { ComponentProperty, ComponentRoot } from '@ui/types'; 3 | 4 | export const identifiers: Map = new Map(); 5 | export const missingFonts: Set = new Set(); 6 | export const textStyles: Map = new Map(); 7 | export const paintStyles: Map = new Map(); 8 | export const overrides: Map = new Map(); 9 | export const images: Map = new Map(); 10 | export const components: Map = new Map(); 11 | export const componentProperties: Map = new Map(); 12 | export const variantProperties: Map> = new Map(); 13 | export const variables: Map = new Map(); 14 | export const variableNames: Map = new Map(); 15 | export const uniqueVariableNames: Set = new Set(); 16 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/index.ts: -------------------------------------------------------------------------------- 1 | export * from './transformBlend'; 2 | export * from './transformChildren'; 3 | export * from './transformComponentNameAndPath'; 4 | export * from './transformComponentSetStrokesAndCornerRadius'; 5 | export * from './transformConstraints'; 6 | export * from './transformCornerRadius'; 7 | export * from './transformDimensionAndPosition'; 8 | export * from './transformEffects'; 9 | export * from './transformFills'; 10 | export * from './transformGrids'; 11 | export * from './transformIds'; 12 | export * from './transformLayout'; 13 | export * from './transformOverrides'; 14 | export * from './transformProportion'; 15 | export * from './transformRotationAndPosition'; 16 | export * from './transformSceneNode'; 17 | export * from './transformStrokes'; 18 | export * from './transformText'; 19 | export * from './transformVariableConsumptionMap'; 20 | export * from './transformVariantNameAndProperties'; 21 | export * from './transformVectorPaths'; 22 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createBool.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { BoolShape } from '@ui/lib/types/shapes/boolShape'; 3 | import { createItems } from '@ui/parser/creators'; 4 | import { symbolFills, symbolStrokes, symbolTouched } from '@ui/parser/creators/symbols'; 5 | 6 | export const createBool = ( 7 | context: PenpotContext, 8 | { type: _type, children = [], boolType, ...shape }: BoolShape 9 | ): void => { 10 | shape.fills = symbolFills(context, shape.fillStyleId, shape.fills); 11 | shape.strokes = symbolStrokes(context, shape.strokes); 12 | shape.touched = symbolTouched( 13 | !shape.hidden, 14 | undefined, 15 | shape.touched, 16 | shape.componentPropertyReferences 17 | ); 18 | 19 | const groupId = context.addGroup(shape); 20 | 21 | createItems(context, children); 22 | 23 | context.closeGroup(); 24 | 25 | context.addBool({ 26 | groupId, 27 | type: boolType 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createArtboard.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { FrameShape } from '@ui/lib/types/shapes/frameShape'; 3 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 4 | import { createItems } from '@ui/parser/creators'; 5 | import { symbolFills, symbolStrokes, symbolTouched } from '@ui/parser/creators/symbols'; 6 | 7 | export const createArtboard = ( 8 | context: PenpotContext, 9 | { type: _type, children = [], ...shape }: FrameShape 10 | ): Uuid | undefined => { 11 | shape.fills = symbolFills(context, shape.fillStyleId, shape.fills); 12 | shape.strokes = symbolStrokes(context, shape.strokes); 13 | shape.touched = symbolTouched( 14 | !shape.hidden, 15 | undefined, 16 | shape.touched, 17 | shape.componentPropertyReferences 18 | ); 19 | 20 | context.addBoard(shape); 21 | 22 | createItems(context, children); 23 | 24 | context.closeBoard(); 25 | 26 | return shape.id; 27 | }; 28 | -------------------------------------------------------------------------------- /plugin-src/translators/vectors/translateRotatedCommands.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from 'svg-path-parser'; 2 | 3 | import { applyBase, translateCommandToPathString } from '@plugin/translators/vectors'; 4 | import { applyInverseRotation, applyRotationToSegment } from '@plugin/utils'; 5 | 6 | export const translateRotatedCommands = ( 7 | commands: Command[], 8 | transform: Transform, 9 | boundingBox: Rect 10 | ): string => { 11 | const referencePoint = applyInverseRotation( 12 | { x: transform[0][2], y: transform[1][2] }, 13 | transform, 14 | boundingBox 15 | ); 16 | 17 | return commands 18 | .reduce((svgPath, command) => { 19 | const pathString = translateCommandToPathString( 20 | applyRotationToSegment( 21 | applyBase(command, referencePoint.x, referencePoint.y), 22 | transform, 23 | boundingBox 24 | ) 25 | ); 26 | 27 | return svgPath + pathString + ' '; 28 | }, '') 29 | .trimEnd(); 30 | }; 31 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/stroke.ts: -------------------------------------------------------------------------------- 1 | import type { Gradient } from './gradient'; 2 | import type { ImageColor, PartialImageColor } from './imageColor'; 3 | import type { Uuid } from './uuid'; 4 | 5 | export type Stroke = { 6 | strokeColor?: string; 7 | strokeColorRefFile?: Uuid; 8 | strokeColorRefId?: Uuid; 9 | strokeOpacity?: number; 10 | strokeStyle?: 'solid' | 'dotted' | 'dashed' | 'mixed' | 'none' | 'svg'; 11 | strokeWidth?: number; 12 | strokeAlignment?: StrokeAlignment; 13 | strokeCapStart?: StrokeCaps; 14 | strokeCapEnd?: StrokeCaps; 15 | strokeColorGradient?: Gradient; 16 | strokeImage?: ImageColor | PartialImageColor; 17 | }; 18 | 19 | export type StrokeAlignment = 'center' | 'inner' | 'outer'; 20 | 21 | type StrokeCapLine = 'round' | 'square'; 22 | type StrokeCapMarker = 23 | | 'line-arrow' 24 | | 'triangle-arrow' 25 | | 'square-marker' 26 | | 'circle-marker' 27 | | 'diamond-marker'; 28 | 29 | export type StrokeCaps = StrokeCapLine | StrokeCapMarker; 30 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformCornerRadius.ts: -------------------------------------------------------------------------------- 1 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 2 | 3 | const isRectangleCornerMixin = ( 4 | node: CornerMixin | (CornerMixin & RectangleCornerMixin) 5 | ): node is CornerMixin & RectangleCornerMixin => { 6 | return 'topLeftRadius' in node && node.cornerRadius === figma.mixed; 7 | }; 8 | 9 | export const transformCornerRadius = ( 10 | node: CornerMixin | (CornerMixin & RectangleCornerMixin) 11 | ): Pick | undefined => { 12 | if (isRectangleCornerMixin(node)) { 13 | return { 14 | r1: node.topLeftRadius, 15 | r2: node.topRightRadius, 16 | r3: node.bottomRightRadius, 17 | r4: node.bottomLeftRadius 18 | }; 19 | } 20 | 21 | if (node.cornerRadius !== figma.mixed) { 22 | return { 23 | r1: node.cornerRadius, 24 | r2: node.cornerRadius, 25 | r3: node.cornerRadius, 26 | r4: node.cornerRadius 27 | }; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/grid.ts: -------------------------------------------------------------------------------- 1 | export type Grid = ColumnGrid | RowGrid | SquareGrid; 2 | 3 | export type GridAlignment = 'stretch' | 'left' | 'center' | 'right'; 4 | 5 | export type SavedGrids = { 6 | square?: SquareParams; 7 | row?: ColumnParams; 8 | column?: ColumnParams; 9 | }; 10 | 11 | export type ColumnGrid = { 12 | type: 'column'; 13 | display: boolean; 14 | params: ColumnParams; 15 | }; 16 | 17 | export type RowGrid = { 18 | type: 'row'; 19 | display: boolean; 20 | params: ColumnParams; 21 | }; 22 | 23 | export type SquareGrid = { 24 | type: 'square'; 25 | display: boolean; 26 | params: SquareParams; 27 | }; 28 | 29 | type ColumnParams = { 30 | color: GridColor; 31 | type?: GridAlignment; 32 | size?: number; 33 | margin?: number; 34 | itemLength?: number; 35 | gutter?: number; 36 | }; 37 | 38 | type SquareParams = { 39 | size: number; 40 | color: GridColor; 41 | }; 42 | 43 | type GridColor = { 44 | color: string; 45 | opacity: number; 46 | }; 47 | -------------------------------------------------------------------------------- /plugin-src/processors/processImages.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { images } from '@plugin/libraries'; 4 | import { flushProgress, reportProgress } from '@plugin/utils'; 5 | 6 | export const processImages = async ( 7 | currentAsset: number 8 | ): Promise>> => { 9 | const processedImages: Record> = {}; 10 | 11 | if (images.size === 0) return processedImages; 12 | 13 | let currentImage = currentAsset; 14 | 15 | for (const [key, image] of images.entries()) { 16 | const bytes = await image?.getBytesAsync(); 17 | 18 | if (bytes) { 19 | processedImages[key] = bytes as Uint8Array; 20 | } 21 | 22 | reportProgress({ 23 | type: 'PROGRESS_PROCESSED_ITEMS', 24 | data: currentImage++ 25 | }); 26 | 27 | await yieldByTime(); 28 | } 29 | 30 | flushProgress(); 31 | 32 | await yieldByTime(undefined, true); 33 | 34 | return processedImages; 35 | }; 36 | -------------------------------------------------------------------------------- /ui-src/metrics/mixpanel.ts: -------------------------------------------------------------------------------- 1 | import mixpanel from 'mixpanel-figma'; 2 | 3 | export const track = (name: string, opts = {}): void => { 4 | if (import.meta.env.VITE_MIXPANEL_TOKEN && import.meta.env.PROD) { 5 | opts = { 6 | ...opts, 7 | 'Plugin Version': APP_VERSION 8 | }; 9 | mixpanel.track(name, opts); 10 | } 11 | }; 12 | 13 | export const identify = ({ userId }: { userId: string }): void => { 14 | if (import.meta.env.VITE_MIXPANEL_TOKEN && import.meta.env.PROD) { 15 | mixpanel.identify(userId); 16 | 17 | mixpanel.people.set({ 18 | 'USER_ID': userId, 19 | 'Plugin Version': APP_VERSION 20 | }); 21 | } 22 | }; 23 | 24 | export const initializeMixpanel = (): void => { 25 | if (import.meta.env.VITE_MIXPANEL_TOKEN && import.meta.env.PROD) { 26 | mixpanel.init(import.meta.env.VITE_MIXPANEL_TOKEN, { 27 | disable_cookie: true, 28 | disable_persistence: true, 29 | opt_out_tracking_by_default: true, 30 | ip: false, 31 | track_pageview: true 32 | }); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/local/translateLocalFont.ts: -------------------------------------------------------------------------------- 1 | import type { LocalFont } from '@plugin/translators/text/font/local'; 2 | import { translateFontVariantId } from '@plugin/translators/text/font/local'; 3 | 4 | import type { TextTypography } from '@ui/lib/types/shapes/textShape'; 5 | 6 | import { items as localFonts } from './localFonts.json'; 7 | 8 | export const translateLocalFont = ( 9 | fontName: FontName | undefined, 10 | fontWeight: string 11 | ): Pick | undefined => { 12 | if (!fontName) return; 13 | 14 | const localFont = getLocalFont(fontName); 15 | 16 | if (localFont === undefined) return; 17 | 18 | return { 19 | fontId: localFont.id, 20 | fontVariantId: translateFontVariantId(localFont, fontName, fontWeight), 21 | fontWeight 22 | }; 23 | }; 24 | 25 | const getLocalFont = (fontName: FontName | undefined): LocalFont | undefined => { 26 | if (!fontName) return; 27 | 28 | return localFonts.find(localFont => localFont.name === fontName.family); 29 | }; 30 | -------------------------------------------------------------------------------- /ui-src/context/messages.ts: -------------------------------------------------------------------------------- 1 | import { createMessageBuffer } from '@common/messageBuffer'; 2 | 3 | import { BUFFERED_PROGRESS_TYPES, type PluginMessage } from '@ui/types/progressMessages'; 4 | 5 | export type MessageData = { pluginMessage?: PluginMessage }; 6 | 7 | const BUFFERED_TYPES = new Set(BUFFERED_PROGRESS_TYPES); 8 | 9 | const emitMessage = (pluginMessage: PluginMessage): void => { 10 | window.dispatchEvent( 11 | new MessageEvent('message', { 12 | data: { 13 | pluginMessage 14 | } 15 | }) 16 | ); 17 | }; 18 | 19 | const messageBuffer = createMessageBuffer({ 20 | bufferedTypes: BUFFERED_TYPES as Set, 21 | flushInterval: 500, 22 | sendMessage: emitMessage, 23 | setTimeout: window.setTimeout.bind(window), 24 | clearTimeout: window.clearTimeout.bind(window) 25 | }); 26 | 27 | export const sendMessage = (pluginMessage: PluginMessage): void => { 28 | messageBuffer.send(pluginMessage); 29 | }; 30 | 31 | export const flushMessageQueue = (): void => { 32 | messageBuffer.flush(); 33 | }; 34 | -------------------------------------------------------------------------------- /ui-src/parser/builders/buildFile.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { flushMessageQueue, sendMessage } from '@ui/context'; 4 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 5 | import type { PenpotPage } from '@ui/lib/types/penpotPage'; 6 | import { components } from '@ui/parser'; 7 | import { createPage } from '@ui/parser/creators'; 8 | 9 | export const buildFile = async (context: PenpotContext, children: PenpotPage[]): Promise => { 10 | let pagesBuilt = 1; 11 | 12 | components.clear(); 13 | 14 | sendMessage({ 15 | type: 'PROGRESS_STEP', 16 | data: { 17 | step: 'building', 18 | total: children.length 19 | } 20 | }); 21 | 22 | await yieldByTime(undefined, true); 23 | 24 | for (const page of children) { 25 | createPage(context, page); 26 | 27 | sendMessage({ 28 | type: 'PROGRESS_PROCESSED_ITEMS', 29 | data: pagesBuilt++ 30 | }); 31 | 32 | await yieldByTime(undefined, true); 33 | } 34 | 35 | flushMessageQueue(); 36 | 37 | await yieldByTime(undefined, true); 38 | }; 39 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createComponent.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { ComponentShape } from '@ui/lib/types/shapes/componentShape'; 3 | import { componentRoots, components } from '@ui/parser'; 4 | import { createArtboard } from '@ui/parser/creators'; 5 | import type { UiComponent } from '@ui/types'; 6 | 7 | export const createComponent = ( 8 | context: PenpotContext, 9 | { type: _type, path, variantProperties, ...shape }: ComponentShape 10 | ): void => { 11 | const componentRoot = componentRoots.get(shape.id); 12 | 13 | if (!componentRoot) { 14 | return; 15 | } 16 | 17 | const { componentId, frameId, name, variantId } = componentRoot; 18 | 19 | const component: UiComponent = { 20 | componentId, 21 | frameId, 22 | name, 23 | variantId, 24 | path, 25 | pageId: context.currentPageId, 26 | fileId: context.currentFileId, 27 | variantProperties 28 | }; 29 | 30 | components.set(shape.id, component); 31 | 32 | shape.componentFile = context.currentFileId; 33 | 34 | createArtboard(context, shape); 35 | }; 36 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createComponentInstance.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 3 | import { componentRoots } from '@ui/parser'; 4 | import { createArtboard } from '@ui/parser/creators'; 5 | import type { ComponentInstance } from '@ui/types'; 6 | 7 | let remoteFileId: Uuid | undefined = undefined; 8 | 9 | export const createComponentInstance = ( 10 | context: PenpotContext, 11 | { type: _type, mainComponentId, ...shape }: ComponentInstance 12 | ): void => { 13 | const componentRoot = componentRoots.get(mainComponentId); 14 | 15 | if (!shape.shapeRef) { 16 | shape.shapeRef = mainComponentId; 17 | } 18 | 19 | shape.componentFile = componentRoot ? context.currentFileId : getRemoteFileId(context); 20 | shape.componentId = componentRoot ? componentRoot.componentId : context.genId(); 21 | 22 | createArtboard(context, shape); 23 | }; 24 | 25 | const getRemoteFileId = (context: PenpotContext): Uuid => { 26 | if (!remoteFileId) { 27 | remoteFileId = context.genId(); 28 | } 29 | 30 | return remoteFileId; 31 | }; 32 | -------------------------------------------------------------------------------- /ui-src/components/Stack/Stack.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import type { CSSProperties, JSX } from 'preact'; 3 | import type { PropsWithChildren } from 'preact/compat'; 4 | 5 | import styles from './Stack.module.css'; 6 | 7 | type StackProps = PropsWithChildren<{ 8 | space?: 'medium' | 'small' | 'xsmall' | '2xsmall' | '3xsmall'; 9 | direction?: 'column' | 'row'; 10 | horizontalAlign?: 'start' | 'center'; 11 | style?: CSSProperties; 12 | as?: 'div' | 'ol'; 13 | }>; 14 | 15 | export const Stack = ({ 16 | space = 'medium', 17 | direction = 'column', 18 | horizontalAlign = 'start', 19 | style, 20 | as = 'div', 21 | children 22 | }: StackProps): JSX.Element => { 23 | const Tag = as; 24 | 25 | return ( 26 | 35 | {children} 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /plugin-src/code.ts: -------------------------------------------------------------------------------- 1 | import { getUserData } from '@plugin/getUserData'; 2 | import { handleExportMessage, handleRetryMessage } from '@plugin/handleMessage'; 3 | 4 | import type { ExportScope } from '@ui/types/progressMessages'; 5 | 6 | const BASE_HEIGHT = 500; 7 | const BASE_WIDTH = 290; 8 | 9 | type ExportMessage = { 10 | type: 'export'; 11 | data: { 12 | scope: ExportScope; 13 | }; 14 | }; 15 | 16 | const onMessage: MessageEventHandler = message => { 17 | if (message.type === 'ready') { 18 | getUserData(); 19 | } 20 | 21 | if (message.type === 'retry') { 22 | handleRetryMessage(); 23 | } 24 | 25 | if (message.type === 'export') { 26 | const exportMessage = message as ExportMessage; 27 | const scope = exportMessage.data?.scope ?? 'all'; 28 | handleExportMessage(scope); 29 | } 30 | 31 | if (message.type === 'cancel') { 32 | figma.closePlugin(); 33 | } 34 | 35 | if (message.type === 'resize') { 36 | figma.ui.resize(BASE_WIDTH, message.height); 37 | } 38 | }; 39 | 40 | figma.showUI(__html__, { themeColors: true, width: BASE_WIDTH, height: BASE_HEIGHT }); 41 | figma.ui.onmessage = onMessage; 42 | -------------------------------------------------------------------------------- /plugin-src/utils/progress.ts: -------------------------------------------------------------------------------- 1 | import { createMessageBuffer } from '@common/messageBuffer'; 2 | 3 | import { BUFFERED_PROGRESS_TYPES, type PluginMessage } from '@ui/types/progressMessages'; 4 | 5 | const BUFFERED_TYPES = new Set(BUFFERED_PROGRESS_TYPES); 6 | 7 | let lastSentCurrentItem: string | undefined; 8 | 9 | const messageBuffer = createMessageBuffer({ 10 | bufferedTypes: BUFFERED_TYPES, 11 | flushInterval: 500, 12 | sendMessage: message => { 13 | if (message.type === 'PROGRESS_CURRENT_ITEM') { 14 | lastSentCurrentItem = message.data; 15 | } 16 | figma.ui.postMessage(message); 17 | } 18 | }); 19 | 20 | export const flushProgress = (): void => { 21 | messageBuffer.flush(); 22 | }; 23 | 24 | export const resetProgress = (): void => { 25 | lastSentCurrentItem = undefined; 26 | }; 27 | 28 | export const reportProgress = (message: PluginMessage): void => { 29 | // Skip sending PROGRESS_CURRENT_ITEM if it's the same as the last sent value 30 | if (message.type === 'PROGRESS_CURRENT_ITEM' && message.data === lastSentCurrentItem) { 31 | return; 32 | } 33 | 34 | messageBuffer.send(message); 35 | }; 36 | -------------------------------------------------------------------------------- /plugin-src/handleMessage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | componentProperties, 3 | components, 4 | images, 5 | missingFonts, 6 | overrides, 7 | paintStyles, 8 | textStyles, 9 | variantProperties 10 | } from '@plugin/libraries'; 11 | import { transformDocumentNode } from '@plugin/transformers'; 12 | import { flushProgress, reportProgress, resetProgress } from '@plugin/utils'; 13 | 14 | import type { ExportScope } from '@ui/types/progressMessages'; 15 | 16 | export const handleExportMessage = async (scope: ExportScope): Promise => { 17 | resetProgress(); 18 | const document = await transformDocumentNode(figma.root, scope); 19 | 20 | flushProgress(); 21 | 22 | reportProgress({ 23 | type: 'PENPOT_DOCUMENT', 24 | data: document 25 | }); 26 | }; 27 | 28 | export const handleRetryMessage = async (): Promise => { 29 | resetProgress(); 30 | missingFonts.clear(); 31 | textStyles.clear(); 32 | paintStyles.clear(); 33 | overrides.clear(); 34 | images.clear(); 35 | components.clear(); 36 | componentProperties.clear(); 37 | variantProperties.clear(); 38 | 39 | reportProgress({ 40 | type: 'RELOAD' 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /plugin-src/translators/text/properties/translateFontWeight.ts: -------------------------------------------------------------------------------- 1 | export const translateFontWeight = (fontName: FontName | undefined): string => { 2 | if (!fontName) return '400'; 3 | 4 | switch (fontName.style) { 5 | case 'Thin': 6 | case 'Thin Italic': 7 | return '100'; 8 | case 'Extra Light': 9 | case 'ExtraLight': 10 | case 'Extra Light Italic': 11 | case 'ExtraLight Italic': 12 | return '200'; 13 | case 'Light': 14 | case 'Light Italic': 15 | return '300'; 16 | case 'Regular': 17 | case 'Italic': 18 | return '400'; 19 | case 'Medium': 20 | case 'Medium Italic': 21 | return '500'; 22 | case 'Semi Bold': 23 | case 'SemiBold': 24 | case 'Semi Bold Italic': 25 | case 'SemiBold Italic': 26 | return '600'; 27 | case 'Bold': 28 | case 'Bold Italic': 29 | return '700'; 30 | case 'ExtraBold': 31 | case 'Extra Bold': 32 | case 'ExtraBold Italic': 33 | case 'Extra Bold Italic': 34 | return '800'; 35 | case 'Black': 36 | case 'Black Italic': 37 | return '900'; 38 | default: 39 | return '400'; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformGroupNode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | transformBlend, 3 | transformDimension, 4 | transformEffects, 5 | transformIds, 6 | transformOverrides, 7 | transformRotationAndPosition, 8 | transformSceneNode, 9 | transformVariableConsumptionMap 10 | } from '@plugin/transformers/partials'; 11 | import { transformChildren } from '@plugin/transformers/partials'; 12 | 13 | import type { GroupShape } from '@ui/lib/types/shapes/groupShape'; 14 | 15 | export const transformGroupNode = async (node: GroupNode): Promise => { 16 | return { 17 | ...transformIds(node), 18 | ...transformGroupNodeLike(node), 19 | ...transformEffects(node), 20 | ...transformBlend(node), 21 | ...transformVariableConsumptionMap(node), 22 | ...(await transformChildren(node)), 23 | ...transformOverrides(node) 24 | }; 25 | }; 26 | 27 | export const transformGroupNodeLike = (node: SceneNode): Omit => { 28 | return { 29 | type: 'group', 30 | name: node.name, 31 | ...transformDimension(node), 32 | ...transformRotationAndPosition(node), 33 | ...transformSceneNode(node) 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformTextNode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | transformBlend, 3 | transformConstraints, 4 | transformDimension, 5 | transformEffects, 6 | transformIds, 7 | transformLayoutAttributes, 8 | transformOverrides, 9 | transformProportion, 10 | transformRotationAndPosition, 11 | transformSceneNode, 12 | transformStrokes, 13 | transformText, 14 | transformVariableConsumptionMap 15 | } from '@plugin/transformers/partials'; 16 | 17 | import type { TextShape } from '@ui/lib/types/shapes/textShape'; 18 | 19 | export const transformTextNode = (node: TextNode): TextShape => { 20 | return { 21 | type: 'text', 22 | name: node.name, 23 | ...transformIds(node), 24 | ...transformText(node), 25 | ...transformDimension(node), 26 | ...transformRotationAndPosition(node), 27 | ...transformEffects(node), 28 | ...transformSceneNode(node), 29 | ...transformBlend(node), 30 | ...transformProportion(node), 31 | ...transformLayoutAttributes(node, false, true), 32 | ...transformStrokes(node), 33 | ...transformConstraints(node), 34 | ...transformVariableConsumptionMap(node), 35 | ...transformOverrides(node) 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /ui-src/lib/types/shapes/componentShape.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutAttributes, LayoutChildAttributes } from '@ui/lib/types/shapes/layout'; 2 | import type { 3 | ShapeAttributes, 4 | ShapeBaseAttributes, 5 | ShapeGeomAttributes 6 | } from '@ui/lib/types/shapes/shape'; 7 | import type { VariantComponent, VariantProperty, VariantShape } from '@ui/lib/types/shapes/variant'; 8 | import type { Children } from '@ui/lib/types/utils/children'; 9 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 10 | 11 | export type ComponentShape = ShapeBaseAttributes & 12 | ShapeAttributes & 13 | ShapeGeomAttributes & 14 | ComponentAttributes & 15 | LayoutAttributes & 16 | LayoutChildAttributes & 17 | VariantShape & 18 | VariantComponent & 19 | Children; 20 | 21 | type ComponentAttributes = { 22 | type?: 'component'; 23 | path: string; 24 | showContent?: boolean; 25 | mainInstanceId?: Uuid; 26 | mainInstancePage?: Uuid; 27 | }; 28 | 29 | export type PenpotComponent = { 30 | componentId: Uuid; 31 | fileId?: Uuid; 32 | name?: string; 33 | path?: string; 34 | frameId?: Uuid; 35 | pageId?: Uuid; 36 | variantId?: Uuid; 37 | variantProperties?: VariantProperty[]; 38 | }; 39 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformEllipseNode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | transformBlend, 3 | transformConstraints, 4 | transformDimension, 5 | transformEffects, 6 | transformFills, 7 | transformIds, 8 | transformLayoutAttributes, 9 | transformOverrides, 10 | transformProportion, 11 | transformRotationAndPosition, 12 | transformSceneNode, 13 | transformStrokes, 14 | transformVariableConsumptionMap 15 | } from '@plugin/transformers/partials'; 16 | 17 | import type { CircleShape } from '@ui/lib/types/shapes/circleShape'; 18 | 19 | export const transformEllipseNode = (node: EllipseNode): CircleShape => { 20 | return { 21 | type: 'circle', 22 | name: node.name, 23 | ...transformIds(node), 24 | ...transformFills(node), 25 | ...transformEffects(node), 26 | ...transformStrokes(node), 27 | ...transformDimension(node), 28 | ...transformRotationAndPosition(node), 29 | ...transformSceneNode(node), 30 | ...transformBlend(node), 31 | ...transformProportion(node), 32 | ...transformLayoutAttributes(node), 33 | ...transformConstraints(node), 34 | ...transformVariableConsumptionMap(node), 35 | ...transformOverrides(node) 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformDocumentNode.ts: -------------------------------------------------------------------------------- 1 | import { toObject } from '@common/map'; 2 | 3 | import { componentProperties, components, missingFonts } from '@plugin/libraries'; 4 | import { 5 | processPages, 6 | processTokens, 7 | registerPaintStyles, 8 | registerTextStyles 9 | } from '@plugin/processors'; 10 | import { processAssets } from '@plugin/processors/processAssets'; 11 | 12 | import type { PenpotDocument } from '@ui/types'; 13 | import type { ExportScope } from '@ui/types/progressMessages'; 14 | 15 | export const transformDocumentNode = async ( 16 | node: DocumentNode, 17 | scope: ExportScope 18 | ): Promise => { 19 | const tokens = await processTokens(); 20 | 21 | await registerPaintStyles(); 22 | await registerTextStyles(); 23 | 24 | const children = await processPages(node, scope); 25 | const [images, paintStyles, textStyles] = await processAssets(); 26 | 27 | return { 28 | name: node.name, 29 | children, 30 | images, 31 | paintStyles, 32 | textStyles, 33 | tokens, 34 | components: toObject(components), 35 | componentProperties: toObject(componentProperties), 36 | missingFonts: Array.from(missingFonts) 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /plugin-src/translators/text/paragraph/OrderedListType.ts: -------------------------------------------------------------------------------- 1 | import * as romans from 'romans'; 2 | 3 | import type { ListType } from '@plugin/translators/text/paragraph/ListType'; 4 | 5 | export class OrderedListType implements ListType { 6 | public getCurrentSymbol(number: number, indentation: number): string { 7 | let symbol = '. '; 8 | switch (indentation % 3) { 9 | case 0: 10 | symbol = romans.romanize(number).toLowerCase() + symbol; 11 | break; 12 | case 2: 13 | symbol = this.letterOrderedList(number) + symbol; 14 | break; 15 | case 1: 16 | default: 17 | symbol = number.toString() + symbol; 18 | break; 19 | } 20 | 21 | return symbol; 22 | } 23 | 24 | private letterOrderedList(number: number): string { 25 | let result = ''; 26 | 27 | while (number > 0) { 28 | let letterCode = number % 26; 29 | 30 | if (letterCode === 0) { 31 | letterCode = 26; 32 | number = Math.floor(number / 26) - 1; 33 | } else { 34 | number = Math.floor(number / 26); 35 | } 36 | 37 | result = String.fromCharCode(letterCode + 96) + result; 38 | } 39 | 40 | return result; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /plugin-src/processors/processAssets.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { images, paintStyles, textStyles } from '@plugin/libraries'; 4 | import { processImages, processPaintStyles, processTextStyles } from '@plugin/processors'; 5 | import { reportProgress } from '@plugin/utils'; 6 | 7 | import type { TypographyStyle } from '@ui/lib/types/shapes/textShape'; 8 | import type { FillStyle } from '@ui/lib/types/utils/fill'; 9 | 10 | export const processAssets = async (): Promise< 11 | [ 12 | Record>, 13 | Record, 14 | Record 15 | ] 16 | > => { 17 | const total = images.size + paintStyles.size + textStyles.size; 18 | 19 | reportProgress({ 20 | type: 'PROGRESS_STEP', 21 | data: { 22 | step: 'processAssets', 23 | total 24 | } 25 | }); 26 | 27 | await yieldByTime(undefined, true); 28 | 29 | const processedPaintStyles = await processPaintStyles(1); 30 | const processedTextStyles = await processTextStyles(paintStyles.size + 1); 31 | const processedImages = await processImages(paintStyles.size + textStyles.size + 1); 32 | 33 | return [processedImages, processedPaintStyles, processedTextStyles]; 34 | }; 35 | -------------------------------------------------------------------------------- /ui-src/components/AppFooter/AppFooter.tsx: -------------------------------------------------------------------------------- 1 | import { Divider, Muted, Text } from '@create-figma-plugin/ui'; 2 | import type { JSX } from 'preact'; 3 | 4 | declare const APP_VERSION: string; 5 | declare const __DEV__: boolean; 6 | 7 | const AppFooter = (): JSX.Element => { 8 | return ( 9 | <> 10 | 11 |
19 | {__DEV__ ? ( 20 | 31 | DEV 32 | 33 | ) : ( 34 | 35 | )} 36 | 37 | 38 | v{APP_VERSION} 39 | 40 |
41 | 42 | ); 43 | }; 44 | 45 | export { AppFooter }; 46 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformRectangleNode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | transformBlend, 3 | transformConstraints, 4 | transformCornerRadius, 5 | transformDimension, 6 | transformEffects, 7 | transformFills, 8 | transformIds, 9 | transformLayoutAttributes, 10 | transformOverrides, 11 | transformProportion, 12 | transformRotationAndPosition, 13 | transformSceneNode, 14 | transformStrokes, 15 | transformVariableConsumptionMap 16 | } from '@plugin/transformers/partials'; 17 | 18 | import type { RectShape } from '@ui/lib/types/shapes/rectShape'; 19 | 20 | export const transformRectangleNode = (node: RectangleNode): RectShape => { 21 | return { 22 | type: 'rect', 23 | name: node.name, 24 | ...transformIds(node), 25 | ...transformFills(node), 26 | ...transformEffects(node), 27 | ...transformStrokes(node), 28 | ...transformDimension(node), 29 | ...transformRotationAndPosition(node), 30 | ...transformSceneNode(node), 31 | ...transformBlend(node), 32 | ...transformProportion(node), 33 | ...transformLayoutAttributes(node), 34 | ...transformCornerRadius(node), 35 | ...transformConstraints(node), 36 | ...transformVariableConsumptionMap(node), 37 | ...transformOverrides(node) 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/syncGroups.ts: -------------------------------------------------------------------------------- 1 | export type SyncGroups = 2 | | 'name-group' 3 | | 'fill-group' 4 | | 'content-group' 5 | | 'visibility-group' 6 | | 'modifiable-group' 7 | | 'text-font-group' 8 | | 'text-display-group' 9 | | 'stroke-group' 10 | | 'radius-group' 11 | | 'geometry-group' 12 | | 'layer-effects-group' 13 | | 'shadow-group' 14 | | 'blur-group' 15 | | 'mask-group' 16 | | 'constraints-group' 17 | | 'exports-group' 18 | | 'grids-group' 19 | | 'show-content' 20 | | 'layout-container' 21 | | 'layout-align-content' 22 | | 'layout-align-items' 23 | | 'layout-flex-dir' 24 | | 'layout-gap' 25 | | 'layout-justify-content' 26 | | 'layout-justify-items' 27 | | 'layout-wrap-type' 28 | | 'layout-padding' 29 | | 'layout-grid-dir' 30 | | 'layout-grid-rows' 31 | | 'layout-grid-columns' 32 | | 'layout-grid-cells' 33 | | 'layout-item-margin' 34 | | 'layout-item-h-sizing' 35 | | 'layout-item-v-sizing' 36 | | 'layout-item-max-h' 37 | | 'layout-item-min-h' 38 | | 'layout-item-max-w' 39 | | 'layout-item-min-w' 40 | | 'layout-item-absolute' 41 | | 'layout-item-z-index' 42 | | 'layout-item-align-self' 43 | | 'text-content-text' 44 | | 'text-content-attribute' 45 | | 'text-content-structure'; 46 | -------------------------------------------------------------------------------- /ui-src/parser/parse.ts: -------------------------------------------------------------------------------- 1 | import { createBuildContext } from '@penpot/library'; 2 | 3 | import { init } from '@common/map'; 4 | 5 | import { flushMessageQueue } from '@ui/context'; 6 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 7 | import { componentProperties, componentRoots } from '@ui/parser'; 8 | import { buildAssets, buildComponentsLibrary, buildFile } from '@ui/parser/builders'; 9 | import type { PenpotDocument } from '@ui/types'; 10 | 11 | export const parse = async (document: PenpotDocument): Promise => { 12 | const { 13 | name, 14 | children = [], 15 | components, 16 | tokens, 17 | componentProperties: recordComponentProperties 18 | } = document; 19 | 20 | init(componentRoots, components); 21 | init(componentProperties, recordComponentProperties); 22 | 23 | const context = createBuildContext({ referer: `penpot-exporter-figma-plugin/${APP_VERSION}` }); 24 | context.addFile({ name }); 25 | 26 | await buildAssets(context, document); 27 | await buildFile(context, children); 28 | await buildComponentsLibrary(context); 29 | 30 | if (tokens) { 31 | context.addTokensLib(tokens); 32 | } 33 | 34 | context.closeFile(); 35 | 36 | flushMessageQueue(); 37 | 38 | return context; 39 | }; 40 | -------------------------------------------------------------------------------- /plugin-src/translators/styles/translateTextStyle.ts: -------------------------------------------------------------------------------- 1 | import { translateStyleName, translateStylePath } from '@plugin/translators/styles'; 2 | import { translateFontName } from '@plugin/translators/text/font'; 3 | import { 4 | translateFontStyle, 5 | translateLetterSpacing, 6 | translateLineHeight, 7 | translateTextDecoration, 8 | translateTextTransform 9 | } from '@plugin/translators/text/properties'; 10 | 11 | import type { TypographyStyle } from '@ui/lib/types/shapes/textShape'; 12 | 13 | export const translateTextStyle = (figmaStyle: TextStyle): TypographyStyle => { 14 | return { 15 | name: translateStyleName(figmaStyle), 16 | textStyle: { 17 | ...translateFontName(figmaStyle.fontName), 18 | fontFamily: figmaStyle.fontName.family, 19 | fontSize: figmaStyle.fontSize.toString(), 20 | fontStyle: translateFontStyle(figmaStyle.fontName.style), 21 | textDecoration: translateTextDecoration(figmaStyle), 22 | letterSpacing: translateLetterSpacing(figmaStyle), 23 | textTransform: translateTextTransform(figmaStyle), 24 | lineHeight: translateLineHeight(figmaStyle) 25 | }, 26 | typography: { 27 | path: translateStylePath(figmaStyle), 28 | name: translateStyleName(figmaStyle) 29 | } 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /ui-src/lib/types/shapes/boolShape.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutChildAttributes } from '@ui/lib/types/shapes/layout'; 2 | import type { 3 | ShapeAttributes, 4 | ShapeBaseAttributes, 5 | ShapeGeomAttributes 6 | } from '@ui/lib/types/shapes/shape'; 7 | import type { Children } from '@ui/lib/types/utils/children'; 8 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 9 | 10 | export const BOOL_DIFFERENCE: unique symbol = Symbol.for('difference'); 11 | export const BOOL_UNION: unique symbol = Symbol.for('union'); 12 | export const BOOL_INTERSECTION: unique symbol = Symbol.for('intersection'); 13 | export const BOOL_EXCLUDE: unique symbol = Symbol.for('exclude'); 14 | 15 | export type BoolOperations = 16 | | 'difference' 17 | | 'union' 18 | | 'intersection' 19 | | 'exclude' 20 | | typeof BOOL_DIFFERENCE 21 | | typeof BOOL_UNION 22 | | typeof BOOL_INTERSECTION 23 | | typeof BOOL_EXCLUDE; 24 | 25 | export type BoolShape = ShapeBaseAttributes & 26 | ShapeGeomAttributes & 27 | ShapeAttributes & 28 | BoolAttributes & 29 | LayoutChildAttributes & 30 | Children; 31 | 32 | export type PenpotBool = { 33 | groupId: Uuid; 34 | type: BoolOperations; 35 | }; 36 | 37 | type BoolAttributes = { 38 | type?: 'bool'; 39 | shapes?: Uuid[]; 40 | boolType: BoolOperations; 41 | }; 42 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/local/translateFontVariantId.ts: -------------------------------------------------------------------------------- 1 | import type { LocalFont } from '@plugin/translators/text/font/local/localFont'; 2 | 3 | export const translateFontVariantId = ( 4 | localFont: LocalFont, 5 | fontName: FontName, 6 | fontWeight: string 7 | ): string => { 8 | // check match by style and weight 9 | const italic = fontName.style.toLowerCase().includes('italic'); 10 | const variantWithStyleWeight = localFont.variants?.find( 11 | variant => variant.weight === fontWeight && variant.style === (italic ? 'italic' : 'normal') 12 | ); 13 | 14 | if (variantWithStyleWeight !== undefined) return variantWithStyleWeight.id; 15 | 16 | // check match directly by suffix if exists 17 | const variant = localFont.variants?.find( 18 | variant => variant.suffix === fontName.style.toLowerCase().replace(/\s/g, '') 19 | ); 20 | 21 | if (variant !== undefined) return variant.id; 22 | 23 | // check match directly by id 24 | const variantById = localFont.variants?.find( 25 | variant => variant.id === fontName.style.toLowerCase().replace(/\s/g, '') 26 | ); 27 | 28 | if (variantById !== undefined) return variantById.id; 29 | 30 | // fallback to font weight (it will not be displayed on Penpot, but it will be rendered) 31 | return fontWeight; 32 | }; 33 | -------------------------------------------------------------------------------- /plugin-src/processors/processPages.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { transformPageNode } from '@plugin/transformers'; 4 | import { flushProgress, reportProgress } from '@plugin/utils'; 5 | 6 | import type { PenpotPage } from '@ui/lib/types/penpotPage'; 7 | import type { ExportScope } from '@ui/types/progressMessages'; 8 | 9 | export const processPages = async ( 10 | node: DocumentNode, 11 | scope: ExportScope 12 | ): Promise => { 13 | const children = []; 14 | let currentPage = 1; 15 | 16 | // Get pages to process based on scope 17 | const pagesToProcess = scope === 'current' ? [figma.currentPage] : node.children; 18 | 19 | reportProgress({ 20 | type: 'PROGRESS_STEP', 21 | data: { 22 | step: 'processing', 23 | total: pagesToProcess.length 24 | } 25 | }); 26 | 27 | await yieldByTime(undefined, true); 28 | 29 | for (const page of pagesToProcess) { 30 | await page.loadAsync(); 31 | 32 | children.push(await transformPageNode(page)); 33 | 34 | reportProgress({ 35 | type: 'PROGRESS_PROCESSED_ITEMS', 36 | data: currentPage++ 37 | }); 38 | 39 | await yieldByTime(); 40 | } 41 | 42 | flushProgress(); 43 | 44 | await yieldByTime(undefined, true); 45 | 46 | return children; 47 | }; 48 | -------------------------------------------------------------------------------- /plugin-src/utils/generateUuid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This implementation uses the uuid library with a custom random number generator 3 | * based on Math.random() since crypto.getRandomValues() is not available in the 4 | * Figma plugin sandbox environment. 5 | * 6 | * Note: This is not cryptographically secure, but is sufficient for generating unique 7 | * identifiers in the Figma plugin context. 8 | */ 9 | import { v4 as uuidv4 } from 'uuid'; 10 | 11 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 12 | 13 | const randomBuffer = new Uint8Array(16); 14 | const randomView = new DataView(randomBuffer.buffer); 15 | 16 | /** 17 | * Custom random number generator for uuid library 18 | * Uses Math.random() instead of crypto.getRandomValues() 19 | * Reuses the same buffer to avoid memory allocation on each call 20 | */ 21 | const customRandom = (): Uint8Array => { 22 | randomView.setUint32(0, (Math.random() * 0x100000000) >>> 0, true); 23 | randomView.setUint32(4, (Math.random() * 0x100000000) >>> 0, true); 24 | randomView.setUint32(8, (Math.random() * 0x100000000) >>> 0, true); 25 | randomView.setUint32(12, (Math.random() * 0x100000000) >>> 0, true); 26 | 27 | return randomBuffer; 28 | }; 29 | 30 | export const generateUuid = (): Uuid => { 31 | return uuidv4({ random: customRandom() }); 32 | }; 33 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/gfonts/translateGoogleFont.ts: -------------------------------------------------------------------------------- 1 | import slugify from 'slugify'; 2 | 3 | import { Cache } from '@plugin/Cache'; 4 | import { translateFontVariantId } from '@plugin/translators/text/font/gfonts'; 5 | import type { GoogleFont } from '@plugin/translators/text/font/gfonts/googleFont'; 6 | 7 | import type { TextTypography } from '@ui/lib/types/shapes/textShape'; 8 | 9 | import { items as gfonts } from './gfonts.json'; 10 | 11 | const fontsCache = new Cache({ max: 30 }); 12 | 13 | export const translateGoogleFont = ( 14 | fontName: FontName | undefined, 15 | fontWeight: string 16 | ): Pick | undefined => { 17 | if (!fontName) return; 18 | 19 | const googleFont = getGoogleFont(fontName); 20 | 21 | if (googleFont === undefined) return; 22 | 23 | return { 24 | fontId: `gfont-${slugify(fontName.family.toLowerCase())}`, 25 | fontVariantId: translateFontVariantId(googleFont, fontName, fontWeight), 26 | fontWeight 27 | }; 28 | }; 29 | 30 | const getGoogleFont = (fontName: FontName | undefined): GoogleFont | undefined => { 31 | if (!fontName) return; 32 | 33 | return fontsCache.get(fontName.family, () => 34 | gfonts.find(font => font.family === fontName.family) 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /ui-src/components/LibraryError.tsx: -------------------------------------------------------------------------------- 1 | import { Banner, Button, Link } from '@create-figma-plugin/ui'; 2 | import { CircleAlert } from 'lucide-react'; 3 | import type { JSX } from 'preact'; 4 | 5 | import { Stack } from '@ui/components/Stack'; 6 | import { useFigmaContext } from '@ui/context'; 7 | 8 | export const LibraryError = (): JSX.Element => { 9 | const { retry, cancel } = useFigmaContext(); 10 | 11 | return ( 12 | 13 | 14 | } variant="warning"> 15 | Oops! It looks like there was an error generating the export file. 16 | 17 | 18 | Please open an issue in our{' '} 19 | 23 | Github repository → 24 | 25 | , and we'll be happy to assist you! 26 | 27 | 28 | 31 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createItems.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import { 3 | createArtboard, 4 | createBool, 5 | createCircle, 6 | createComponent, 7 | createComponentInstance, 8 | createGroup, 9 | createPath, 10 | createRectangle, 11 | createText 12 | } from '@ui/parser/creators'; 13 | import type { PenpotNode } from '@ui/types'; 14 | 15 | export const createItems = (context: PenpotContext, nodes: PenpotNode[]): void => { 16 | for (const node of nodes) { 17 | createItem(context, node); 18 | } 19 | }; 20 | 21 | const createItem = (context: PenpotContext, node: PenpotNode): void => { 22 | switch (node.type) { 23 | case 'rect': 24 | return createRectangle(context, node); 25 | case 'circle': 26 | return createCircle(context, node); 27 | case 'frame': 28 | createArtboard(context, node); 29 | 30 | return; 31 | case 'group': 32 | return createGroup(context, node); 33 | case 'path': 34 | return createPath(context, node); 35 | case 'text': 36 | return createText(context, node); 37 | case 'bool': 38 | return createBool(context, node); 39 | case 'component': 40 | return createComponent(context, node); 41 | case 'instance': 42 | return createComponentInstance(context, node); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateColorVariable.ts: -------------------------------------------------------------------------------- 1 | import { variables } from '@plugin/libraries'; 2 | import { isAliasValue, translateAliasValue } from '@plugin/translators/tokens/translateAliasValue'; 3 | import { rgbToString } from '@plugin/utils/rgbToString'; 4 | 5 | import type { Token } from '@ui/lib/types/shapes/tokens'; 6 | 7 | const isColorValue = (value: VariableValue): value is RGB | RGBA => { 8 | return typeof value === 'object' && 'r' in value && 'g' in value && 'b' in value; 9 | }; 10 | 11 | const translateColorValue = (value: VariableValue): Token['$value'] | null => { 12 | if (isAliasValue(value)) { 13 | return translateAliasValue(value); 14 | } 15 | 16 | if (!isColorValue(value)) { 17 | return null; 18 | } 19 | 20 | return rgbToString(value); 21 | }; 22 | 23 | export const translateColorVariable = ( 24 | variable: Variable, 25 | variableName: string, 26 | modeId: string 27 | ): [string, Token | Record] | null => { 28 | const value = variable.valuesByMode[modeId]; 29 | 30 | const $value = translateColorValue(value); 31 | if (!$value) return null; 32 | 33 | variables.set(`${variable.id}.color`, variableName); 34 | 35 | return [ 36 | variableName, 37 | { 38 | $value, 39 | $type: 'color', 40 | $description: variable.description 41 | } 42 | ]; 43 | }; 44 | -------------------------------------------------------------------------------- /ui-src/parser/creators/symbols/symbolFills.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { Fill } from '@ui/lib/types/utils/fill'; 3 | import type { ImageColor, PartialImageColor } from '@ui/lib/types/utils/imageColor'; 4 | import { colors, images } from '@ui/parser'; 5 | 6 | export const symbolFills = ( 7 | context: PenpotContext, 8 | fillStyleId?: string, 9 | fills?: Fill[] 10 | ): Fill[] | undefined => { 11 | const nodeFills = fillStyleId ? colors.get(fillStyleId)?.fills : fills; 12 | 13 | if (!nodeFills) return; 14 | 15 | return nodeFills.map(fill => { 16 | if (fill.fillImage) { 17 | fill.fillImage = symbolFillImage(context, fill.fillImage); 18 | } 19 | 20 | return fill; 21 | }); 22 | }; 23 | 24 | export const symbolFillImage = ( 25 | context: PenpotContext, 26 | fillImage: ImageColor | PartialImageColor 27 | ): ImageColor | undefined => { 28 | if (!isPartialFillColor(fillImage)) return fillImage; 29 | const mediaId = images.get(fillImage.imageHash); 30 | if (!mediaId) return; 31 | 32 | return { 33 | ...context.getMediaAsImage(mediaId), 34 | keepAspectRatio: true 35 | }; 36 | }; 37 | 38 | const isPartialFillColor = ( 39 | imageColor: ImageColor | PartialImageColor 40 | ): imageColor is PartialImageColor => { 41 | return 'imageHash' in imageColor; 42 | }; 43 | -------------------------------------------------------------------------------- /ui-src/parser/builders/buildAssets.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { sendMessage } from '@ui/context'; 4 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 5 | import { 6 | optimizeFileMedias, 7 | registerColorLibraries, 8 | registerTypographyLibraries 9 | } from '@ui/parser/builders'; 10 | import type { PenpotDocument } from '@ui/types'; 11 | 12 | export const buildAssets = async ( 13 | context: PenpotContext, 14 | document: PenpotDocument 15 | ): Promise => { 16 | const { images, paintStyles, textStyles } = document; 17 | 18 | const imagesToOptimize = Object.entries(images); 19 | const paintStylesToRegister = Object.entries(paintStyles); 20 | const textStylesToRegister = Object.entries(textStyles); 21 | 22 | sendMessage({ 23 | type: 'PROGRESS_STEP', 24 | data: { 25 | step: 'buildAssets', 26 | total: imagesToOptimize.length + paintStylesToRegister.length + textStylesToRegister.length 27 | } 28 | }); 29 | 30 | await yieldByTime(undefined, true); 31 | 32 | await optimizeFileMedias(context, imagesToOptimize, 1); 33 | await registerColorLibraries(context, paintStylesToRegister, imagesToOptimize.length + 1); 34 | await registerTypographyLibraries( 35 | context, 36 | textStylesToRegister, 37 | imagesToOptimize.length + paintStylesToRegister.length + 1 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformPathNode.ts: -------------------------------------------------------------------------------- 1 | import { parseSVG } from 'svg-path-parser'; 2 | 3 | import { 4 | transformBlend, 5 | transformConstraints, 6 | transformEffects, 7 | transformFills, 8 | transformIds, 9 | transformLayoutAttributes, 10 | transformOverrides, 11 | transformProportion, 12 | transformRotation, 13 | transformSceneNode, 14 | transformStrokes, 15 | transformVariableConsumptionMap 16 | } from '@plugin/transformers/partials'; 17 | import { translateCommands } from '@plugin/translators/vectors'; 18 | 19 | import type { PathShape } from '@ui/lib/types/shapes/pathShape'; 20 | 21 | export const transformPathNode = (node: StarNode | PolygonNode): PathShape => { 22 | return { 23 | type: 'path', 24 | name: node.name, 25 | content: translatePathNode(node), 26 | ...transformIds(node), 27 | ...transformFills(node), 28 | ...transformStrokes(node), 29 | ...transformEffects(node), 30 | ...transformSceneNode(node), 31 | ...transformBlend(node), 32 | ...transformProportion(node), 33 | ...transformRotation(node), 34 | ...transformLayoutAttributes(node), 35 | ...transformConstraints(node), 36 | ...transformVariableConsumptionMap(node), 37 | ...transformOverrides(node) 38 | }; 39 | }; 40 | 41 | const translatePathNode = (node: StarNode | PolygonNode): string => 42 | translateCommands(node, parseSVG(node.fillGeometry[0].data)); 43 | -------------------------------------------------------------------------------- /plugin-src/translators/styles/translatePaintStyle.ts: -------------------------------------------------------------------------------- 1 | import { translateFill } from '@plugin/translators/fills/translateFills'; 2 | import { translateStyleName, translateStylePath } from '@plugin/translators/styles'; 3 | 4 | import type { FillStyle } from '@ui/lib/types/utils/fill'; 5 | 6 | export const translatePaintStyle = (figmaStyle: PaintStyle): FillStyle => { 7 | const fillStyle: FillStyle = { 8 | name: figmaStyle.name, 9 | fills: [], 10 | colors: [] 11 | }; 12 | 13 | const colorName = (figmaStyle: PaintStyle, index: number): string => { 14 | return figmaStyle.paints.length > 1 ? `Color ${index + 1}` : translateStyleName(figmaStyle); 15 | }; 16 | 17 | let index = 0; 18 | const path = translatePaintStylePath(figmaStyle); 19 | 20 | for (const fill of figmaStyle.paints) { 21 | const penpotFill = translateFill(fill); 22 | 23 | if (penpotFill) { 24 | fillStyle.fills.unshift(penpotFill); 25 | fillStyle.colors.unshift({ 26 | path, 27 | name: colorName(figmaStyle, index) 28 | }); 29 | } 30 | index++; 31 | } 32 | 33 | return fillStyle; 34 | }; 35 | 36 | const translatePaintStylePath = (figmaStyle: PaintStyle): string => { 37 | const path = translateStylePath(figmaStyle); 38 | 39 | if (figmaStyle.paints.length <= 1) { 40 | return path; 41 | } 42 | 43 | return path + (path !== '' ? ' / ' : '') + translateStyleName(figmaStyle); 44 | }; 45 | -------------------------------------------------------------------------------- /plugin-src/translators/translateShadowEffects.ts: -------------------------------------------------------------------------------- 1 | import { generateUuid, rgbToHex } from '@plugin/utils'; 2 | 3 | import type { Shadow, ShadowStyle } from '@ui/lib/types/utils/shadow'; 4 | 5 | export const translateShadowEffect = (effect: Effect): Shadow | undefined => { 6 | if (effect.type !== 'DROP_SHADOW' && effect.type !== 'INNER_SHADOW') { 7 | return; 8 | } 9 | 10 | return { 11 | id: generateUuid(), 12 | style: translateShadowType(effect), 13 | offsetX: effect.offset.x, 14 | offsetY: effect.offset.y, 15 | blur: effect.radius, 16 | spread: effect.spread ?? 0, 17 | hidden: !effect.visible, 18 | color: { 19 | color: rgbToHex(effect.color), 20 | opacity: effect.color.a 21 | } 22 | }; 23 | }; 24 | 25 | export const translateShadowEffects = (effects: readonly Effect[]): Shadow[] => { 26 | const shadows: Shadow[] = []; 27 | 28 | for (const effect of effects) { 29 | const shadow = translateShadowEffect(effect); 30 | if (shadow) { 31 | // effects are applied in reverse order in Figma, that's why we unshift 32 | shadows.unshift(shadow); 33 | } 34 | } 35 | 36 | return shadows; 37 | }; 38 | 39 | const translateShadowType = (effect: DropShadowEffect | InnerShadowEffect): ShadowStyle => { 40 | switch (effect.type) { 41 | case 'DROP_SHADOW': 42 | return 'drop-shadow'; 43 | case 'INNER_SHADOW': 44 | return 'inner-shadow'; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sentryVitePlugin } from '@sentry/vite-plugin'; 2 | import react from '@vitejs/plugin-react-swc'; 3 | import * as process from 'node:process'; 4 | import { type UserConfig, defineConfig } from 'vite'; 5 | import { viteSingleFile } from 'vite-plugin-singlefile'; 6 | import svgr from 'vite-plugin-svgr'; 7 | import tsconfigPaths from 'vite-tsconfig-paths'; 8 | 9 | export default ({ mode }): UserConfig => { 10 | return defineConfig({ 11 | root: './ui-src', 12 | plugins: [ 13 | svgr(), 14 | react(), 15 | viteSingleFile({ removeViteModuleLoader: true }), 16 | tsconfigPaths(), 17 | sentryVitePlugin({ 18 | org: 'runroom-sl', 19 | project: 'penpot-exporter', 20 | disable: mode === 'development' 21 | }) 22 | ], 23 | resolve: { 24 | alias: { 25 | 'react': 'preact/compat', 26 | 'react-dom': 'preact/compat', 27 | '!../css/base.css': '../css/base.css' 28 | } 29 | }, 30 | build: { 31 | emptyOutDir: false, 32 | target: 'esnext', 33 | reportCompressedSize: false, 34 | outDir: '../dist', 35 | 36 | rollupOptions: { 37 | external: ['!../css/base.css'] 38 | }, 39 | sourcemap: true 40 | }, 41 | define: { 42 | APP_VERSION: JSON.stringify(process.env.npm_package_version), 43 | __DEV__: JSON.stringify(mode === 'development') 44 | } 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformRotationAndPosition.ts: -------------------------------------------------------------------------------- 1 | import { translateRotation, translateZeroRotation } from '@plugin/translators'; 2 | import { applyInverseRotation, getRotation, isTransformed } from '@plugin/utils'; 3 | 4 | import type { ShapeBaseAttributes, ShapeGeomAttributes } from '@ui/lib/types/shapes/shape'; 5 | 6 | export const transformRotation = ( 7 | node: LayoutMixin 8 | ): Pick => { 9 | const rotation = getRotation(node.absoluteTransform); 10 | 11 | return translateRotation(node.absoluteTransform, rotation); 12 | }; 13 | 14 | export const transformRotationAndPosition = ( 15 | node: SceneNode 16 | ): Pick & 17 | Pick => { 18 | const x = node.absoluteTransform[0][2]; 19 | const y = node.absoluteTransform[1][2]; 20 | const rotation = getRotation(node.absoluteTransform); 21 | 22 | if (!node.absoluteBoundingBox || !isTransformed(node.absoluteTransform)) { 23 | return { 24 | x, 25 | y, 26 | ...translateZeroRotation() 27 | }; 28 | } 29 | 30 | const referencePoint = applyInverseRotation( 31 | { x, y }, 32 | node.absoluteTransform, 33 | node.absoluteBoundingBox 34 | ); 35 | 36 | return { 37 | ...referencePoint, 38 | ...translateRotation(node.absoluteTransform, rotation) 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateGenericVariable.ts: -------------------------------------------------------------------------------- 1 | import { variables } from '@plugin/libraries'; 2 | 3 | import type { Token, TokenType } from '@ui/lib/types/shapes/tokens'; 4 | 5 | export const translateGenericVariable = ( 6 | variable: Variable, 7 | variableName: string, 8 | modeId: string, 9 | translateVariableValues: (variable: Variable, modeId: string) => Map 10 | ): [string, Token | Record] | null => { 11 | const variableValues = translateVariableValues(variable, modeId); 12 | 13 | if (variableValues.size === 0) { 14 | return null; 15 | } 16 | 17 | const variableValuesIterator = variableValues.entries(); 18 | 19 | if (variableValues.size === 1) { 20 | const [$type, $value] = variableValuesIterator.next().value as [TokenType, string]; 21 | 22 | variables.set(variable.id, variableName); 23 | variables.set(`${variable.id}.${$type}`, variableName); 24 | 25 | return [variableName, { $value, $type, $description: variable.description }]; 26 | } 27 | 28 | const tokens: Record = {}; 29 | 30 | for (const [$type, $value] of variableValuesIterator) { 31 | tokens[$type] = { $value, $type, $description: variable.description }; 32 | 33 | variables.set(variable.id, `${variableName}.${$type}`); 34 | variables.set(`${variable.id}.${$type}`, `${variableName}.${$type}`); 35 | } 36 | 37 | return [variableName, tokens]; 38 | }; 39 | -------------------------------------------------------------------------------- /plugin-src/translators/translateBlendMode.ts: -------------------------------------------------------------------------------- 1 | import type { BlendMode as PenpotBlendMode } from '@ui/lib/types/utils/blendModes'; 2 | 3 | export const translateBlendMode = (blendMode: BlendMode): PenpotBlendMode => { 4 | switch (blendMode) { 5 | //@TODO: is not translatable in penpot, this is the closest one 6 | case 'PASS_THROUGH': 7 | case 'NORMAL': 8 | return 'normal'; 9 | //@TODO: is not translatable in penpot, this is the closest one 10 | case 'LINEAR_BURN': 11 | case 'DARKEN': 12 | return 'darken'; 13 | case 'MULTIPLY': 14 | return 'multiply'; 15 | case 'COLOR_BURN': 16 | return 'color-burn'; 17 | case 'LIGHTEN': 18 | return 'lighten'; 19 | case 'SCREEN': 20 | return 'screen'; 21 | //@TODO: is not translatable in penpot, this is the closest one 22 | case 'LINEAR_DODGE': 23 | case 'COLOR_DODGE': 24 | return 'color-dodge'; 25 | case 'OVERLAY': 26 | return 'overlay'; 27 | case 'SOFT_LIGHT': 28 | return 'soft-light'; 29 | case 'HARD_LIGHT': 30 | return 'hard-light'; 31 | case 'DIFFERENCE': 32 | return 'difference'; 33 | case 'EXCLUSION': 34 | return 'exclusion'; 35 | case 'HUE': 36 | return 'hue'; 37 | case 'SATURATION': 38 | return 'saturation'; 39 | case 'COLOR': 40 | return 'color'; 41 | case 'LUMINOSITY': 42 | return 'luminosity'; 43 | default: 44 | return 'normal'; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /ui-src/parser/creators/symbols/symbolTouched.ts: -------------------------------------------------------------------------------- 1 | import type { SyncGroups } from '@ui/lib/types/utils/syncGroups'; 2 | import { componentProperties } from '@ui/parser'; 3 | import type { ComponentPropertyReference } from '@ui/types'; 4 | 5 | export const symbolTouched = ( 6 | visible: boolean | undefined, 7 | characters: string | undefined, 8 | touched: SyncGroups[] | undefined, 9 | componentPropertyReferences: ComponentPropertyReference | undefined 10 | ): SyncGroups[] | undefined => { 11 | if (!componentPropertyReferences) { 12 | return touched; 13 | } 14 | 15 | const touchedResult = touched ?? []; 16 | const propertyReferenceVisible = componentPropertyReferences.visible; 17 | const propertyReferenceCharacters = componentPropertyReferences.characters; 18 | 19 | if ( 20 | propertyReferenceVisible && 21 | visible !== componentProperties.get(propertyReferenceVisible)?.defaultValue && 22 | !touchedResult.includes('visibility-group') 23 | ) { 24 | touchedResult.push('visibility-group'); 25 | } 26 | 27 | if ( 28 | propertyReferenceCharacters && 29 | characters !== componentProperties.get(propertyReferenceCharacters)?.defaultValue 30 | ) { 31 | if (!touchedResult.includes('content-group')) { 32 | touchedResult.push('content-group'); 33 | } 34 | 35 | if (!touchedResult.includes('text-content-text')) { 36 | touchedResult.push('text-content-text'); 37 | } 38 | } 39 | 40 | return touchedResult; 41 | }; 42 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformVectorNode.ts: -------------------------------------------------------------------------------- 1 | import { transformGroupNodeLike } from '@plugin/transformers'; 2 | import { 3 | transformConstraints, 4 | transformIds, 5 | transformOverrides, 6 | transformVariableConsumptionMap, 7 | transformVectorPaths 8 | } from '@plugin/transformers/partials'; 9 | 10 | import type { GroupShape } from '@ui/lib/types/shapes/groupShape'; 11 | import type { PathShape } from '@ui/lib/types/shapes/pathShape'; 12 | 13 | /* 14 | * Vector nodes can have multiple vector paths, each with its own fills. 15 | * 16 | * If there are no regions on the vector network, we treat it like a normal `PathShape`. 17 | * If there are regions, we treat the vector node as a `GroupShape` with multiple `PathShape` children. 18 | */ 19 | export const transformVectorNode = (node: VectorNode): GroupShape | PathShape | undefined => { 20 | const children = transformVectorPaths(node); 21 | 22 | if (children.length === 0) { 23 | return; 24 | } 25 | 26 | if (children.length === 1) { 27 | return { 28 | ...children[0], 29 | name: node.name, 30 | ...transformIds(node), 31 | ...transformConstraints(node), 32 | ...transformVariableConsumptionMap(node), 33 | ...transformOverrides(node) 34 | }; 35 | } 36 | 37 | return { 38 | ...transformGroupNodeLike(node), 39 | ...transformIds(node), 40 | ...transformConstraints(node), 41 | ...transformVariableConsumptionMap(node), 42 | ...transformOverrides(node), 43 | children 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /plugin-src/processors/processTextStyles.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { textStyles } from '@plugin/libraries'; 4 | import { translateTextStyle } from '@plugin/translators/styles'; 5 | import { flushProgress, reportProgress } from '@plugin/utils'; 6 | 7 | import type { TypographyStyle } from '@ui/lib/types/shapes/textShape'; 8 | 9 | const isTextStyle = (style: BaseStyle): style is TextStyle => { 10 | return style.type === 'TEXT'; 11 | }; 12 | 13 | export const registerTextStyles = async (): Promise => { 14 | const localTextStyles = await figma.getLocalTextStylesAsync(); 15 | localTextStyles.forEach(style => { 16 | textStyles.set(style.id, style); 17 | }); 18 | }; 19 | 20 | export const processTextStyles = async ( 21 | currentAsset: number 22 | ): Promise> => { 23 | const styles: Record = {}; 24 | 25 | if (textStyles.size === 0) return styles; 26 | 27 | let currentStyle = currentAsset; 28 | 29 | for (const [styleId, style] of textStyles.entries()) { 30 | const figmaStyle = style ?? (await figma.getStyleByIdAsync(styleId)); 31 | if (figmaStyle && isTextStyle(figmaStyle)) { 32 | styles[styleId] = translateTextStyle(figmaStyle); 33 | } 34 | 35 | reportProgress({ 36 | type: 'PROGRESS_PROCESSED_ITEMS', 37 | data: currentStyle++ 38 | }); 39 | 40 | await yieldByTime(); 41 | } 42 | 43 | flushProgress(); 44 | 45 | await yieldByTime(undefined, true); 46 | 47 | return styles; 48 | }; 49 | -------------------------------------------------------------------------------- /plugin-src/processors/processPaintStyles.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { paintStyles } from '@plugin/libraries'; 4 | import { translatePaintStyle } from '@plugin/translators/styles'; 5 | import { flushProgress, reportProgress } from '@plugin/utils'; 6 | 7 | import type { FillStyle } from '@ui/lib/types/utils/fill'; 8 | 9 | const isPaintStyle = (style: BaseStyle): style is PaintStyle => { 10 | return style.type === 'PAINT'; 11 | }; 12 | 13 | export const registerPaintStyles = async (): Promise => { 14 | const localPaintStyles = await figma.getLocalPaintStylesAsync(); 15 | localPaintStyles.forEach(style => { 16 | paintStyles.set(style.id, style); 17 | }); 18 | }; 19 | 20 | export const processPaintStyles = async ( 21 | currentAsset: number 22 | ): Promise> => { 23 | const styles: Record = {}; 24 | 25 | if (paintStyles.size === 0) return styles; 26 | 27 | let currentStyle = currentAsset; 28 | 29 | for (const [styleId, paintStyle] of paintStyles.entries()) { 30 | const figmaStyle = paintStyle ?? (await figma.getStyleByIdAsync(styleId)); 31 | if (figmaStyle && isPaintStyle(figmaStyle)) { 32 | styles[styleId] = translatePaintStyle(figmaStyle); 33 | } 34 | 35 | reportProgress({ 36 | type: 'PROGRESS_PROCESSED_ITEMS', 37 | data: currentStyle++ 38 | }); 39 | 40 | await yieldByTime(); 41 | } 42 | 43 | flushProgress(); 44 | 45 | await yieldByTime(undefined, true); 46 | 47 | return styles; 48 | }; 49 | -------------------------------------------------------------------------------- /plugin-src/utils/calculateRadialGradient.ts: -------------------------------------------------------------------------------- 1 | import { applyMatrixToPoint } from '@plugin/utils/applyMatrixToPoint'; 2 | import { matrixInvert } from '@plugin/utils/matrixInvert'; 3 | 4 | export const calculateRadialGradient = (t: Transform): { start: number[]; end: number[] } => { 5 | const transform = t.length === 2 ? [...t, [0, 0, 1]] : [...t]; 6 | const mxInv = matrixInvert(transform); 7 | 8 | if (!mxInv) { 9 | return { 10 | start: [0, 0], 11 | end: [0, 0] 12 | }; 13 | } 14 | 15 | const centerPoint = applyMatrixToPoint(mxInv, [0.5, 0.5]); 16 | const rxPoint = applyMatrixToPoint(mxInv, [1, 0.5]); 17 | const ryPoint = applyMatrixToPoint(mxInv, [0.5, 1]); 18 | 19 | const rx = Math.sqrt( 20 | Math.pow(rxPoint[0] - centerPoint[0], 2) + Math.pow(rxPoint[1] - centerPoint[1], 2) 21 | ); 22 | const ry = Math.sqrt( 23 | Math.pow(ryPoint[0] - centerPoint[0], 2) + Math.pow(ryPoint[1] - centerPoint[1], 2) 24 | ); 25 | const angle = 26 | Math.atan((rxPoint[1] - centerPoint[1]) / (rxPoint[0] - centerPoint[0])) * (180 / Math.PI); 27 | 28 | return { 29 | start: centerPoint, 30 | end: calculateRadialGradientEndPoint(angle, centerPoint, [rx, ry]) 31 | }; 32 | }; 33 | 34 | const calculateRadialGradientEndPoint = ( 35 | rotation: number, 36 | center: number[], 37 | radius: number[] 38 | ): [number, number] => { 39 | const angle = rotation * (Math.PI / 180); 40 | const x = center[0] + radius[0] * Math.cos(angle); 41 | const y = center[1] + radius[1] * Math.sin(angle); 42 | return [x, y]; 43 | }; 44 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformFills.ts: -------------------------------------------------------------------------------- 1 | import { translateFillStyleId, translateFills } from '@plugin/translators/fills'; 2 | import type { TextSegment } from '@plugin/translators/text/paragraph'; 3 | 4 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 5 | import type { TextStyle } from '@ui/lib/types/shapes/textShape'; 6 | 7 | export const transformFills = ( 8 | node: (MinimalFillsMixin & DimensionAndPositionMixin) | VectorRegion | VectorNode | TextSegment 9 | ): Pick | Pick => { 10 | if (hasFillStyle(node)) { 11 | return { 12 | fills: [], 13 | fillStyleId: translateFillStyleId(node.fillStyleId) 14 | }; 15 | } 16 | 17 | return { 18 | fills: translateFills(node.fills) 19 | }; 20 | }; 21 | 22 | export const transformVectorFills = ( 23 | node: VectorNode, 24 | vectorPath: VectorPath, 25 | vectorRegion: VectorRegion | undefined 26 | ): Pick => { 27 | if (vectorPath.windingRule === 'NONE') { 28 | return { 29 | fills: [] 30 | }; 31 | } 32 | 33 | const fillsNode = vectorRegion?.fills ? vectorRegion : node; 34 | return transformFills(fillsNode); 35 | }; 36 | 37 | const hasFillStyle = ( 38 | node: (MinimalFillsMixin & DimensionAndPositionMixin) | VectorRegion | VectorNode | TextSegment 39 | ): boolean => { 40 | return ( 41 | node.fillStyleId !== figma.mixed && 42 | node.fillStyleId !== undefined && 43 | node.fillStyleId.length > 0 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformText.ts: -------------------------------------------------------------------------------- 1 | import { transformFills } from '@plugin/transformers/partials'; 2 | import { transformTextStyle, translateTextSegments } from '@plugin/translators/text'; 3 | import { translateGrowType, translateVerticalAlign } from '@plugin/translators/text/properties'; 4 | 5 | import type { TextAttributes, TextShape } from '@ui/lib/types/shapes/textShape'; 6 | 7 | export const transformText = (node: TextNode): TextAttributes & Pick => { 8 | const styledTextSegments = node.getStyledTextSegments([ 9 | 'fontName', 10 | 'fontSize', 11 | 'fontWeight', 12 | 'lineHeight', 13 | 'letterSpacing', 14 | 'textCase', 15 | 'textDecoration', 16 | 'indentation', 17 | 'listOptions', 18 | 'fills', 19 | 'fillStyleId', 20 | 'textStyleId' 21 | ]); 22 | 23 | return { 24 | characters: node.characters, 25 | content: { 26 | type: 'root', 27 | verticalAlign: translateVerticalAlign(node.textAlignVertical), 28 | children: styledTextSegments.length 29 | ? [ 30 | { 31 | type: 'paragraph-set', 32 | children: [ 33 | { 34 | type: 'paragraph', 35 | children: translateTextSegments(node, styledTextSegments), 36 | ...transformTextStyle(node, styledTextSegments[0]), 37 | ...transformFills(node) 38 | } 39 | ] 40 | } 41 | ] 42 | : undefined 43 | }, 44 | growType: translateGrowType(node) 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /plugin-src/translators/vectors/translateNonRotatedCommands.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from 'svg-path-parser'; 2 | 3 | export const translateNonRotatedCommands = ( 4 | commands: Command[], 5 | baseX: number = 0, 6 | baseY: number = 0 7 | ): string => { 8 | return commands 9 | .reduce((svgPath, command) => { 10 | const pathString = translateCommandToPathString(applyBase(command, baseX, baseY)); 11 | 12 | return svgPath + pathString + ' '; 13 | }, '') 14 | .trimEnd(); 15 | }; 16 | 17 | export const translateCommandToPathString = (command: Command): string => { 18 | switch (command.command) { 19 | case 'moveto': 20 | return `M ${command.x} ${command.y}`; 21 | case 'lineto': 22 | return `L ${command.x} ${command.y}`; 23 | case 'curveto': 24 | return `C ${command.x1} ${command.y1}, ${command.x2} ${command.y2}, ${command.x} ${command.y}`; 25 | case 'closepath': 26 | return 'Z'; 27 | default: 28 | return ''; 29 | } 30 | }; 31 | 32 | export const applyBase = (command: Command, baseX: number, baseY: number): Command => { 33 | switch (command.command) { 34 | case 'lineto': 35 | case 'moveto': 36 | return { 37 | ...command, 38 | x: command.x + baseX, 39 | y: command.y + baseY 40 | }; 41 | case 'curveto': 42 | return { 43 | ...command, 44 | x1: command.x1 + baseX, 45 | y1: command.y1 + baseY, 46 | x2: command.x2 + baseX, 47 | y2: command.y2 + baseY, 48 | x: command.x + baseX, 49 | y: command.y + baseY 50 | }; 51 | default: 52 | return command; 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformLineNode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | transformBlend, 3 | transformConstraints, 4 | transformEffects, 5 | transformIds, 6 | transformLayoutAttributes, 7 | transformOverrides, 8 | transformSceneNode, 9 | transformStrokes, 10 | transformVariableConsumptionMap 11 | } from '@plugin/transformers/partials'; 12 | import { translateCommands } from '@plugin/translators/vectors'; 13 | 14 | import type { PathShape } from '@ui/lib/types/shapes/pathShape'; 15 | 16 | /** 17 | * In order to match the normal representation of a line in Penpot, we will assume that 18 | * the line is never rotated, so we calculate its normal position. 19 | * 20 | * To represent the line rotated we do take into account the rotation of the line, but only in its content. 21 | */ 22 | export const transformLineNode = (node: LineNode): PathShape => { 23 | return { 24 | type: 'path', 25 | name: node.name, 26 | content: translateLineNode(node), 27 | ...transformIds(node), 28 | ...transformStrokes(node), 29 | ...transformEffects(node), 30 | ...transformSceneNode(node), 31 | ...transformBlend(node), 32 | ...transformLayoutAttributes(node), 33 | ...transformConstraints(node), 34 | ...transformVariableConsumptionMap(node), 35 | ...transformOverrides(node) 36 | }; 37 | }; 38 | 39 | const translateLineNode = (node: LineNode): string => { 40 | return translateCommands(node, [ 41 | { 42 | x: 0, 43 | y: 0, 44 | command: 'moveto', 45 | code: 'M' 46 | }, 47 | { 48 | x: node.width, 49 | y: 0, 50 | command: 'lineto', 51 | code: 'L' 52 | } 53 | ]); 54 | }; 55 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformBooleanNode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | transformBlend, 3 | transformChildren, 4 | transformDimension, 5 | transformEffects, 6 | transformFills, 7 | transformIds, 8 | transformLayoutAttributes, 9 | transformOverrides, 10 | transformProportion, 11 | transformRotationAndPosition, 12 | transformSceneNode, 13 | transformStrokes, 14 | transformVariableConsumptionMap 15 | } from '@plugin/transformers/partials'; 16 | import { translateBoolType } from '@plugin/translators'; 17 | 18 | import type { BoolShape } from '@ui/lib/types/shapes/boolShape'; 19 | 20 | export const transformBooleanNode = async ( 21 | node: BooleanOperationNode 22 | ): Promise => { 23 | const children = await transformChildren(node); 24 | 25 | if (!children.children || children.children.length === 0) { 26 | // In Penpot, boolean groups without children are not supported. 27 | // In Figma, they are supported, but they do not make a lot of sense 28 | // so we just ignore them. 29 | return; 30 | } 31 | 32 | return { 33 | type: 'bool', 34 | name: node.name, 35 | boolType: translateBoolType(node.booleanOperation), 36 | ...transformIds(node), 37 | ...transformFills(node), 38 | ...transformEffects(node), 39 | ...transformStrokes(node), 40 | ...transformDimension(node), 41 | ...transformRotationAndPosition(node), 42 | ...transformSceneNode(node), 43 | ...transformBlend(node), 44 | ...transformProportion(node), 45 | ...transformLayoutAttributes(node), 46 | ...transformVariableConsumptionMap(node), 47 | ...children, 48 | ...transformOverrides(node) 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 3 | import reactPlugin from 'eslint-plugin-react'; 4 | import globals from 'globals'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | const eslintConfig = [ 8 | eslint.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | reactPlugin.configs.flat.recommended, 11 | reactPlugin.configs.flat['jsx-runtime'], 12 | eslintPluginPrettierRecommended, 13 | { 14 | languageOptions: { 15 | globals: { 16 | ...globals.browser, 17 | ...globals.node 18 | } 19 | }, 20 | settings: { 21 | react: { 22 | version: 'detect' 23 | } 24 | }, 25 | rules: { 26 | '@typescript-eslint/ban-ts-comment': 'warn', 27 | '@typescript-eslint/no-empty-object-type': 'warn', 28 | '@typescript-eslint/no-explicit-any': 'warn', 29 | '@typescript-eslint/consistent-type-imports': 'error', 30 | '@typescript-eslint/explicit-function-return-type': 'error', 31 | '@typescript-eslint/no-unused-vars': [ 32 | 'error', 33 | { 34 | argsIgnorePattern: '^_', 35 | caughtErrorsIgnorePattern: '^_', 36 | destructuredArrayIgnorePattern: '^_', 37 | varsIgnorePattern: '^_' 38 | } 39 | ], 40 | 'no-console': ['error', { allow: ['warn', 'error'] }] 41 | } 42 | }, 43 | { 44 | ignores: ['dist'] 45 | }, 46 | { 47 | files: ['playground/**/*.js'], 48 | rules: { 49 | '@typescript-eslint/explicit-function-return-type': 'off' 50 | } 51 | } 52 | ]; 53 | 54 | export default eslintConfig; 55 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformComponentSetNode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | transformAutoLayout, 3 | transformBlend, 4 | transformChildren, 5 | transformComponentSetStrokesAndCornerRadius, 6 | transformConstraints, 7 | transformDimension, 8 | transformEffects, 9 | transformFills, 10 | transformIds, 11 | transformLayoutAttributes, 12 | transformOverrides, 13 | transformProportion, 14 | transformRotationAndPosition, 15 | transformSceneNode, 16 | transformVariableConsumptionMap 17 | } from '@plugin/transformers/partials'; 18 | import { 19 | registerComponentProperties, 20 | registerVariantProperties 21 | } from '@plugin/translators/components'; 22 | 23 | import type { FrameShape } from '@ui/lib/types/shapes/frameShape'; 24 | 25 | export const transformComponentSetNode = async (node: ComponentSetNode): Promise => { 26 | registerComponentProperties(node); 27 | registerVariantProperties(node); 28 | 29 | return { 30 | type: 'frame', 31 | name: node.name, 32 | showContent: !node.clipsContent, 33 | isVariantContainer: true, 34 | ...transformIds(node), 35 | ...transformFills(node), 36 | ...transformEffects(node), 37 | ...transformDimension(node), 38 | ...transformRotationAndPosition(node), 39 | ...transformSceneNode(node), 40 | ...transformBlend(node), 41 | ...transformProportion(node), 42 | ...transformLayoutAttributes(node, true), 43 | ...transformConstraints(node), 44 | ...transformAutoLayout(node), 45 | ...transformComponentSetStrokesAndCornerRadius(node), 46 | ...transformVariableConsumptionMap(node), 47 | ...(await transformChildren(node)), 48 | ...transformOverrides(node) 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /ui-src/App.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'preact'; 2 | import { useEffect } from 'preact/hooks'; 3 | import useResizeObserver from 'use-resize-observer'; 4 | 5 | import Penpot from '@ui/assets/penpot.svg?react'; 6 | import { AppFooter } from '@ui/components/AppFooter'; 7 | import { PenpotExporter } from '@ui/components/PenpotExporter'; 8 | import { PluginContainer } from '@ui/components/PluginContainer'; 9 | import { Stack } from '@ui/components/Stack'; 10 | import { Wrapper } from '@ui/components/Wrapper'; 11 | import { FigmaProvider } from '@ui/context/FigmaContext'; 12 | 13 | declare const __DEV__: boolean; 14 | 15 | // Safe default value to avoid overflowing from the screen 16 | const MAX_HEIGHT = 800; 17 | 18 | export const App = (): JSX.Element => { 19 | const { ref, height } = useResizeObserver({ box: 'border-box' }); 20 | 21 | useEffect(() => { 22 | if (height === undefined) return; 23 | 24 | const capHeight = Math.min(height, MAX_HEIGHT); 25 | 26 | parent.postMessage({ pluginMessage: { type: 'resize', height: capHeight } }, '*'); 27 | }, [height]); 28 | 29 | return ( 30 | 31 | MAX_HEIGHT}> 32 | 33 | 34 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /ui-src/parser/builders/registerTypographyLibraries.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { flushMessageQueue, sendMessage } from '@ui/context'; 4 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 5 | import type { TypographyStyle } from '@ui/lib/types/shapes/textShape'; 6 | import { typographies } from '@ui/parser'; 7 | 8 | export const registerTypographyLibraries = async ( 9 | context: PenpotContext, 10 | stylesToRegister: [string, TypographyStyle][], 11 | currentAsset: number 12 | ): Promise => { 13 | if (stylesToRegister.length === 0) return; 14 | 15 | let stylesRegistered = currentAsset; 16 | 17 | for (const [key, style] of stylesToRegister) { 18 | const typography = style.typography; 19 | const textStyle = style.textStyle; 20 | 21 | const typographyId = context.addLibraryTypography({ 22 | ...typography, 23 | fontId: textStyle.fontId, 24 | fontVariantId: textStyle.fontVariantId, 25 | letterSpacing: textStyle.letterSpacing, 26 | fontWeight: textStyle.fontWeight, 27 | fontStyle: textStyle.fontStyle, 28 | fontFamily: textStyle.fontFamily, 29 | fontSize: textStyle.fontSize, 30 | textTransform: textStyle.textTransform, 31 | lineHeight: textStyle.lineHeight 32 | }); 33 | 34 | style.textStyle.typographyRefId = typographyId; 35 | style.textStyle.typographyRefFile = context.currentFileId; 36 | style.typography.id = typographyId; 37 | 38 | typographies.set(key, style); 39 | 40 | sendMessage({ 41 | type: 'PROGRESS_PROCESSED_ITEMS', 42 | data: stylesRegistered++ 43 | }); 44 | 45 | await yieldByTime(); 46 | } 47 | 48 | flushMessageQueue(); 49 | 50 | await yieldByTime(undefined, true); 51 | }; 52 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformComponentSetStrokesAndCornerRadius.ts: -------------------------------------------------------------------------------- 1 | import { transformCornerRadius, transformStrokes } from '@plugin/transformers/partials'; 2 | 3 | import type { ShapeAttributes } from '@ui/lib/types/shapes/shape'; 4 | 5 | const isComponentSetDefaultStyle = (node: ComponentSetNode): boolean => { 6 | return ( 7 | node.cornerRadius === 5 && 8 | node.strokeAlign === 'INSIDE' && 9 | node.dashPattern.length === 2 && 10 | node.dashPattern[0] === 10 && 11 | node.dashPattern[1] === 5 && 12 | node.strokeWeight === 1 && 13 | node.strokes.length === 1 && 14 | node.strokes[0].blendMode === 'NORMAL' && 15 | node.strokes[0].opacity === 1 && 16 | node.strokes[0].type === 'SOLID' && 17 | node.strokes[0].color.r === 0.5921568870544434 && 18 | node.strokes[0].color.g === 0.27843138575553894 && 19 | node.strokes[0].color.b === 1 20 | ); 21 | }; 22 | 23 | const transformPenpotDefaultStrokesAndCornerRadius = (): Pick< 24 | ShapeAttributes, 25 | 'strokes' | 'r1' | 'r2' | 'r3' | 'r4' 26 | > => { 27 | return { 28 | strokes: [ 29 | { 30 | strokeWidth: 2, 31 | strokeAlignment: 'inner', 32 | strokeColor: '#bb97d8', 33 | strokeOpacity: 1, 34 | strokeStyle: 'solid' 35 | } 36 | ], 37 | r1: 20, 38 | r2: 20, 39 | r3: 20, 40 | r4: 20 41 | }; 42 | }; 43 | 44 | export const transformComponentSetStrokesAndCornerRadius = ( 45 | node: ComponentSetNode 46 | ): Pick => { 47 | if (isComponentSetDefaultStyle(node)) { 48 | return transformPenpotDefaultStrokesAndCornerRadius(); 49 | } 50 | 51 | return { 52 | ...transformStrokes(node), 53 | ...transformCornerRadius(node) 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /ui-src/parser/builders/registerColorLibraries.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { flushMessageQueue, sendMessage } from '@ui/context'; 4 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 5 | import type { FillStyle } from '@ui/lib/types/utils/fill'; 6 | import { colors } from '@ui/parser'; 7 | import { symbolFillImage } from '@ui/parser/creators/symbols/symbolFills'; 8 | 9 | export const registerColorLibraries = async ( 10 | context: PenpotContext, 11 | stylesToRegister: [string, FillStyle][], 12 | currentAsset: number 13 | ): Promise => { 14 | if (stylesToRegister.length === 0) return; 15 | 16 | let stylesRegistered = currentAsset; 17 | 18 | for (const [key, fillStyle] of stylesToRegister) { 19 | for (let index = 0; index < fillStyle.fills.length; index++) { 20 | const fill = fillStyle.fills[index]; 21 | const color = fillStyle.colors[index]; 22 | 23 | const colorId = context.addLibraryColor({ 24 | ...color, 25 | color: fill.fillColor, 26 | opacity: fill.fillOpacity, 27 | image: fill.fillImage ? symbolFillImage(context, fill.fillImage) : undefined, 28 | gradient: fill.fillColorGradient 29 | }); 30 | 31 | fillStyle.fills[index].fillColorRefId = colorId; 32 | fillStyle.fills[index].fillColorRefFile = context.currentFileId; 33 | fillStyle.colors[index].id = colorId; 34 | fillStyle.colors[index].refFile = context.currentFileId; 35 | } 36 | 37 | colors.set(key, fillStyle); 38 | 39 | sendMessage({ 40 | type: 'PROGRESS_PROCESSED_ITEMS', 41 | data: stylesRegistered++ 42 | }); 43 | 44 | await yieldByTime(); 45 | } 46 | 47 | flushMessageQueue(); 48 | 49 | await yieldByTime(undefined, true); 50 | }; 51 | -------------------------------------------------------------------------------- /ui-src/lib/types/shapes/tokens.ts: -------------------------------------------------------------------------------- 1 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 2 | 3 | export type Tokens = { 4 | $metadata: Metadata; 5 | $themes: Theme[]; 6 | }; 7 | 8 | export type TokenSets = Record; 9 | 10 | export type Set = { 11 | [key: string]: Token | Record; 12 | }; 13 | 14 | export type TokenType = 15 | | 'color' 16 | | 'number' 17 | | 'dimension' 18 | | 'rotation' 19 | | 'spacing' 20 | | 'opacity' 21 | | 'sizing' 22 | | 'borderRadius' 23 | | 'borderWidth' 24 | | 'fontFamilies' 25 | | 'fontSizes' 26 | | 'fontWeights' 27 | | 'textDecoration' 28 | | 'letterSpacing' 29 | | 'textCase'; 30 | 31 | export type Token = { 32 | $value: string | string[]; 33 | $type: TokenType; 34 | $description: string; 35 | }; 36 | 37 | export type Metadata = { 38 | tokenSetOrder: string[]; 39 | activeThemes: string[]; 40 | activeSets: string[]; 41 | }; 42 | 43 | export type Theme = { 44 | id?: Uuid; 45 | name: string; 46 | group: string; 47 | description: string; 48 | isSource: boolean; 49 | selectedTokenSets: Record; 50 | }; 51 | 52 | export type TokenProperties = 53 | | 'r1' 54 | | 'r2' 55 | | 'r3' 56 | | 'r4' 57 | | 'width' 58 | | 'height' 59 | | 'layoutItemMinW' 60 | | 'layoutItemMaxW' 61 | | 'layoutItemMinH' 62 | | 'layoutItemMaxH' 63 | | 'rowGap' 64 | | 'columnGap' 65 | | 'p1' 66 | | 'p2' 67 | | 'p3' 68 | | 'p4' 69 | | 'm1' 70 | | 'm2' 71 | | 'm3' 72 | | 'm4' 73 | | 'rotation' 74 | | 'lineHeight' 75 | | 'fontSize' 76 | | 'letterSpacing' 77 | | 'fontFamily' 78 | | 'fontWeight' 79 | | 'textCase' 80 | | 'textDecoration' 81 | | 'typography' 82 | | 'strokeWidth' 83 | | 'fill' 84 | | 'strokeColor' 85 | | 'opacity' 86 | | 'x' 87 | | 'y'; 88 | -------------------------------------------------------------------------------- /ui-src/types/component.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutAttributes, LayoutChildAttributes } from '@ui/lib/types/shapes/layout'; 2 | import type { 3 | ShapeAttributes, 4 | ShapeBaseAttributes, 5 | ShapeGeomAttributes 6 | } from '@ui/lib/types/shapes/shape'; 7 | import type { VariantProperty } from '@ui/lib/types/shapes/variant'; 8 | import type { Children } from '@ui/lib/types/utils/children'; 9 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 10 | 11 | export type ComponentRoot = { 12 | name: string; 13 | componentId: Uuid; 14 | frameId: Uuid; 15 | variantId?: Uuid; 16 | }; 17 | 18 | export type ComponentTextPropertyOverride = { 19 | id: string; 20 | type: 'TEXT'; 21 | value: string; 22 | defaultValue: string; 23 | }; 24 | 25 | export type ComponentInstance = ShapeBaseAttributes & 26 | ShapeAttributes & 27 | ShapeGeomAttributes & 28 | LayoutAttributes & 29 | LayoutChildAttributes & 30 | Children & { 31 | mainComponentId: Uuid; 32 | componentRoot: boolean; 33 | showContent?: boolean; 34 | isOrphan: boolean; 35 | type: 'instance'; 36 | }; 37 | 38 | export type UiComponent = { 39 | componentId: Uuid; 40 | name: string; 41 | pageId?: Uuid; 42 | fileId?: Uuid; 43 | frameId: Uuid; 44 | variantId?: Uuid; 45 | variantProperties?: VariantProperty[]; 46 | path?: string; 47 | }; 48 | 49 | export type ComponentProperty = { 50 | type: 'BOOLEAN' | 'TEXT' | 'INSTANCE_SWAP' | 'VARIANT'; 51 | defaultValue: string | boolean; 52 | preferredValues?: { 53 | type: 'COMPONENT' | 'COMPONENT_SET'; 54 | key: string; 55 | }[]; 56 | variantOptions?: string[]; 57 | }; 58 | 59 | // This type comes directly from Figma. We have it here because we need to reference it from the UI 60 | export type ComponentPropertyReference = 61 | | { 62 | [nodeProperty in 'visible' | 'characters' | 'mainComponent']?: string; 63 | } 64 | | null; 65 | -------------------------------------------------------------------------------- /ui-src/parser/builders/buildComponentsLibrary.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { flushMessageQueue, sendMessage } from '@ui/context'; 4 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 5 | import type { PenpotComponent } from '@ui/lib/types/shapes/componentShape'; 6 | import { componentRoots, components } from '@ui/parser'; 7 | import type { UiComponent } from '@ui/types'; 8 | 9 | export const buildComponentsLibrary = async (context: PenpotContext): Promise => { 10 | let componentsBuilt = 1; 11 | 12 | sendMessage({ 13 | type: 'PROGRESS_STEP', 14 | data: { 15 | step: 'components', 16 | total: components.size 17 | } 18 | }); 19 | 20 | await yieldByTime(undefined, true); 21 | 22 | for (const [_, component] of components.entries()) { 23 | createComponentLibrary(context, component); 24 | 25 | sendMessage({ 26 | type: 'PROGRESS_PROCESSED_ITEMS', 27 | data: componentsBuilt++ 28 | }); 29 | 30 | await yieldByTime(); 31 | } 32 | 33 | flushMessageQueue(); 34 | 35 | await yieldByTime(undefined, true); 36 | }; 37 | 38 | const createComponentLibrary = (context: PenpotContext, component: UiComponent): void => { 39 | const componentRoot = componentRoots.get(component.frameId); 40 | 41 | if (!componentRoot) { 42 | return; 43 | } 44 | 45 | const penpotComponent: PenpotComponent = { 46 | componentId: component.componentId, 47 | fileId: context.currentFileId, 48 | name: component.name, 49 | frameId: component.frameId, 50 | pageId: component.pageId, 51 | path: component.path 52 | }; 53 | 54 | if (component.variantId) { 55 | penpotComponent.variantId = component.variantId; 56 | } 57 | 58 | if (component.variantProperties) { 59 | penpotComponent.variantProperties = component.variantProperties; 60 | } 61 | 62 | context.addComponent(penpotComponent); 63 | }; 64 | -------------------------------------------------------------------------------- /plugin-src/translators/text/paragraph/translateParagraphProperties.ts: -------------------------------------------------------------------------------- 1 | import { Paragraph } from '@plugin/translators/text/paragraph/Paragraph'; 2 | 3 | import type { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape'; 4 | 5 | export type TextSegment = Pick< 6 | StyledTextSegment, 7 | | 'characters' 8 | | 'start' 9 | | 'end' 10 | | 'fontName' 11 | | 'fontSize' 12 | | 'fontWeight' 13 | | 'lineHeight' 14 | | 'letterSpacing' 15 | | 'textCase' 16 | | 'textDecoration' 17 | | 'indentation' 18 | | 'listOptions' 19 | | 'fills' 20 | | 'fillStyleId' 21 | | 'textStyleId' 22 | >; 23 | 24 | type PartialTranslation = { 25 | textNodes: PenpotTextNode[]; 26 | segment: TextSegment; 27 | }; 28 | 29 | export const translateParagraphProperties = ( 30 | node: TextNode, 31 | partials: { textNode: PenpotTextNode; segment: TextSegment }[] 32 | ): PenpotTextNode[] => { 33 | const splitSegments: PartialTranslation[] = []; 34 | 35 | partials.forEach(({ textNode, segment }) => { 36 | splitSegments.push({ 37 | textNodes: splitTextNodeByEOL(textNode), 38 | segment 39 | }); 40 | }); 41 | 42 | return addParagraphProperties(node, splitSegments); 43 | }; 44 | 45 | const splitTextNodeByEOL = (node: PenpotTextNode): PenpotTextNode[] => { 46 | const split = node.text.split(/(\n)/).filter(text => text !== ''); 47 | 48 | return split.map(text => ({ 49 | ...node, 50 | text: text.replace(/\u2028/g, '\n') 51 | })); 52 | }; 53 | 54 | const addParagraphProperties = ( 55 | node: TextNode, 56 | partials: PartialTranslation[] 57 | ): PenpotTextNode[] => { 58 | const formattedParagraphs: PenpotTextNode[] = []; 59 | const paragraph = new Paragraph(); 60 | 61 | partials.forEach(({ textNodes, segment }) => 62 | textNodes.forEach(textNode => { 63 | formattedParagraphs.push(...paragraph.format(node, textNode, segment)); 64 | }) 65 | ); 66 | 67 | return formattedParagraphs; 68 | }; 69 | -------------------------------------------------------------------------------- /ui-src/parser/creators/createText.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotContext } from '@ui/lib/types/penpotContext'; 2 | import type { Paragraph, TextContent, TextNode, TextShape } from '@ui/lib/types/shapes/textShape'; 3 | import { typographies } from '@ui/parser'; 4 | import { symbolFills, symbolStrokes, symbolTouched } from '@ui/parser/creators/symbols'; 5 | 6 | export const createText = ( 7 | context: PenpotContext, 8 | { type: _type, characters, ...shape }: TextShape 9 | ): void => { 10 | shape.content = parseContent(context, shape.content); 11 | shape.strokes = symbolStrokes(context, shape.strokes); 12 | shape.touched = symbolTouched( 13 | !shape.hidden, 14 | characters, 15 | shape.touched, 16 | shape.componentPropertyReferences 17 | ); 18 | 19 | context.addText(shape); 20 | }; 21 | 22 | const parseContent = ( 23 | context: PenpotContext, 24 | content: TextContent | undefined 25 | ): TextContent | undefined => { 26 | if (!content) return; 27 | 28 | content.children = content.children?.map(paragraphSet => { 29 | paragraphSet.children = paragraphSet.children.map(paragraph => { 30 | paragraph.children = paragraph.children.map(textNode => { 31 | return parseTextStyle(context, textNode, textNode.textStyleId) as TextNode; 32 | }); 33 | return parseTextStyle(context, paragraph, paragraph.textStyleId) as Paragraph; 34 | }); 35 | return paragraphSet; 36 | }); 37 | 38 | return content; 39 | }; 40 | 41 | const parseTextStyle = ( 42 | context: PenpotContext, 43 | text: Paragraph | TextNode, 44 | textStyleId?: string 45 | ): Paragraph | TextNode => { 46 | let textStyle = text; 47 | textStyle.fills = symbolFills(context, text.fillStyleId, text.fills); 48 | 49 | const libraryStyle = textStyleId ? typographies.get(textStyleId) : undefined; 50 | 51 | if (libraryStyle) { 52 | textStyle = { 53 | ...libraryStyle.textStyle, 54 | ...textStyle 55 | }; 56 | } 57 | 58 | return textStyle; 59 | }; 60 | -------------------------------------------------------------------------------- /plugin-src/translators/text/font/local/localFonts.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": "sourcesanspro", 5 | "name": "Source Sans Pro", 6 | "family": "sourcesanspro", 7 | "variants": [ 8 | { 9 | "id": "200", 10 | "name": "200", 11 | "weight": "200", 12 | "style": "normal", 13 | "suffix": "extralight" 14 | }, 15 | { 16 | "id": "200italic", 17 | "name": "200 (italic)", 18 | "weight": "200", 19 | "style": "italic", 20 | "suffix": "extralightitalic" 21 | }, 22 | { 23 | "id": "300", 24 | "name": "300", 25 | "weight": "300", 26 | "style": "normal", 27 | "suffix": "light" 28 | }, 29 | { 30 | "id": "300italic", 31 | "name": "300 (italic)", 32 | "weight": "300", 33 | "style": "italic", 34 | "suffix": "lightitalic" 35 | }, 36 | { 37 | "id": "regular", 38 | "name": "regular", 39 | "weight": "400", 40 | "style": "normal" 41 | }, 42 | { 43 | "id": "italic", 44 | "name": "italic", 45 | "weight": "400", 46 | "style": "italic" 47 | }, 48 | { 49 | "id": "bold", 50 | "name": "bold", 51 | "weight": "bold", 52 | "style": "normal" 53 | }, 54 | { 55 | "id": "bolditalic", 56 | "name": "bold (italic)", 57 | "weight": "bold", 58 | "style": "italic" 59 | }, 60 | { 61 | "id": "black", 62 | "name": "black", 63 | "weight": "900", 64 | "style": "normal" 65 | }, 66 | { 67 | "id": "blackitalic", 68 | "name": "black (italic)", 69 | "weight": "900", 70 | "style": "italic" 71 | } 72 | ] 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformVariantNameAndProperties.ts: -------------------------------------------------------------------------------- 1 | import { variantProperties } from '@plugin/libraries'; 2 | 3 | import type { VariantComponent, VariantShape } from '@ui/lib/types/shapes/variant'; 4 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 5 | 6 | type VariantProperty = { name: string; value: string }; 7 | 8 | const parseNodeName = (nodeName: string): Map => { 9 | const properties = new Map(); 10 | 11 | for (const pair of nodeName.split(',')) { 12 | const [name, value] = pair.split('=').map(s => s.trim()); 13 | properties.set(name, value); 14 | } 15 | 16 | return properties; 17 | }; 18 | 19 | const getSortedPropertyNames = ( 20 | parsedMap: Map, 21 | registeredNames: Set | undefined 22 | ): string[] => { 23 | const names = registeredNames ?? parsedMap.keys(); 24 | return Array.from(names).sort(); 25 | }; 26 | 27 | const buildVariantName = (parsedMap: Map, sortedNames: string[]): string => { 28 | return sortedNames 29 | .map(name => parsedMap.get(name)) 30 | .filter((value): value is string => value !== undefined) 31 | .join(', '); 32 | }; 33 | 34 | const buildVariantProperties = ( 35 | parsedMap: Map, 36 | sortedNames: string[] 37 | ): VariantProperty[] => { 38 | return sortedNames.map(name => ({ 39 | name, 40 | value: parsedMap.get(name) ?? '' 41 | })); 42 | }; 43 | 44 | export const transformVariantNameAndProperties = ( 45 | node: ComponentNode, 46 | variantId: Uuid 47 | ): Pick & Pick => { 48 | const registeredNames = variantProperties.get(variantId); 49 | const parsedMap = parseNodeName(node.name); 50 | const sortedNames = getSortedPropertyNames(parsedMap, registeredNames); 51 | 52 | return { 53 | variantName: buildVariantName(parsedMap, sortedNames), 54 | variantProperties: buildVariantProperties(parsedMap, sortedNames) 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /plugin-src/translators/fills/translateFills.ts: -------------------------------------------------------------------------------- 1 | import { paintStyles } from '@plugin/libraries'; 2 | import { translateImageFill, translateSolidFill } from '@plugin/translators/fills'; 3 | import { 4 | translateGradientLinearFill, 5 | translateGradientRadialFill 6 | } from '@plugin/translators/fills/gradients'; 7 | import { rgbToHex } from '@plugin/utils'; 8 | 9 | import type { Fill } from '@ui/lib/types/utils/fill'; 10 | 11 | export const translateFill = (fill: Paint): Fill | undefined => { 12 | switch (fill.type) { 13 | case 'SOLID': 14 | return translateSolidFill(fill); 15 | case 'GRADIENT_LINEAR': 16 | return translateGradientLinearFill(fill); 17 | case 'GRADIENT_RADIAL': 18 | return translateGradientRadialFill(fill); 19 | case 'IMAGE': 20 | return translateImageFill(fill); 21 | } 22 | 23 | console.warn(`Unsupported fill type: ${fill.type}`); 24 | }; 25 | 26 | export const translateFills = ( 27 | fills: readonly Paint[] | typeof figma.mixed | undefined 28 | ): Fill[] => { 29 | if (fills === undefined || fills === figma.mixed) return []; 30 | 31 | const penpotFills: Fill[] = []; 32 | 33 | for (const fill of fills) { 34 | const penpotFill = translateFill(fill); 35 | 36 | if (penpotFill) { 37 | penpotFills.push(penpotFill); 38 | } 39 | } 40 | 41 | // fills are applied in reverse order in Figma 42 | return penpotFills.reverse(); 43 | }; 44 | 45 | export const translateFillStyleId = ( 46 | fillStyleId: string | typeof figma.mixed | undefined 47 | ): string | undefined => { 48 | if (fillStyleId === figma.mixed || fillStyleId === undefined) return; 49 | 50 | if (!paintStyles.has(fillStyleId)) { 51 | paintStyles.set(fillStyleId, undefined); 52 | } 53 | 54 | return fillStyleId; 55 | }; 56 | 57 | export const translatePageFill = (fill: Paint): string | undefined => { 58 | switch (fill.type) { 59 | case 'SOLID': 60 | return rgbToHex(fill.color); 61 | } 62 | 63 | console.warn(`Unsupported page fill type: ${fill.type}`); 64 | }; 65 | -------------------------------------------------------------------------------- /ui-src/lib/types/penpotContext.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotFile } from '@ui/lib/types/penpotFile'; 2 | import type { PenpotPage } from '@ui/lib/types/penpotPage'; 3 | import type { PenpotBool } from '@ui/lib/types/shapes/boolShape'; 4 | import type { CircleShape } from '@ui/lib/types/shapes/circleShape'; 5 | import type { PenpotComponent } from '@ui/lib/types/shapes/componentShape'; 6 | import type { FrameShape } from '@ui/lib/types/shapes/frameShape'; 7 | import type { GroupShape } from '@ui/lib/types/shapes/groupShape'; 8 | import type { PathShape } from '@ui/lib/types/shapes/pathShape'; 9 | import type { RectShape } from '@ui/lib/types/shapes/rectShape'; 10 | import type { TextShape } from '@ui/lib/types/shapes/textShape'; 11 | import type { Tokens } from '@ui/lib/types/shapes/tokens'; 12 | import type { Color } from '@ui/lib/types/utils/color'; 13 | import type { ImageColor } from '@ui/lib/types/utils/imageColor'; 14 | import type { Media } from '@ui/lib/types/utils/media'; 15 | import type { Typography } from '@ui/lib/types/utils/typography'; 16 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 17 | 18 | export interface PenpotContext { 19 | currentFileId: Uuid; 20 | currentFrameId: Uuid; 21 | currentPageId: Uuid; 22 | lastId: Uuid; 23 | addFile(file: PenpotFile): Uuid; 24 | closeFile(): void; 25 | addPage(page: PenpotPage): Uuid; 26 | closePage(): void; 27 | addBoard(artboard: FrameShape): Uuid; 28 | closeBoard(): void; 29 | addGroup(group: GroupShape): Uuid; 30 | closeGroup(): void; 31 | addBool(bool: PenpotBool): Uuid; 32 | addRect(rect: RectShape): Uuid; 33 | addCircle(circle: CircleShape): Uuid; 34 | addPath(path: PathShape): Uuid; 35 | addText(options: TextShape): Uuid; 36 | addLibraryColor(color: Color): Uuid; 37 | addLibraryTypography(typography: Typography): Uuid; 38 | addComponent(component: PenpotComponent): Uuid; 39 | addFileMedia(media: Media, blob: Blob): Uuid; 40 | getMediaAsImage(mediaId: Uuid): ImageColor; 41 | addTokensLib(tokens: Tokens): void; 42 | genId(): Uuid; 43 | } 44 | -------------------------------------------------------------------------------- /ui-src/lib/types/utils/interaction.ts: -------------------------------------------------------------------------------- 1 | import type { Animation } from './animation'; 2 | import type { Point } from './point'; 3 | import type { Uuid } from './uuid'; 4 | 5 | export type Interaction = 6 | | InteractionNavigate 7 | | InteractionOpenOverlay 8 | | InteractionToggleOverlay 9 | | InteractionCloseOverlay 10 | | InteractionPrevScreen 11 | | InteractionOpenUrl; 12 | 13 | type EventType = 14 | | 'click' 15 | | 'mouse-press' 16 | | 'mouse-over' 17 | | 'mouse-enter' 18 | | 'mouse-leave' 19 | | 'after-delay'; 20 | 21 | type OverlayPosType = 22 | | 'manual' 23 | | 'center' 24 | | 'top-left' 25 | | 'top-right' 26 | | 'top-center' 27 | | 'bottom-left' 28 | | 'bottom-right' 29 | | 'bottom-center'; 30 | 31 | type InteractionNavigate = { 32 | actionType: 'navigate'; 33 | eventType: EventType; 34 | destination?: Uuid; 35 | preserveScroll?: boolean; 36 | animation?: Animation; 37 | }; 38 | 39 | type InteractionOpenOverlay = { 40 | actionType: 'open-overlay'; 41 | eventType: EventType; 42 | overlayPosition?: Point; 43 | overlayPosType?: OverlayPosType; 44 | destination?: Uuid; 45 | closeClickOutside?: boolean; 46 | backgroundOverlay?: boolean; 47 | animation?: Animation; 48 | positionRelativeTo?: Uuid; 49 | }; 50 | 51 | type InteractionToggleOverlay = { 52 | actionType: 'toggle-overlay'; 53 | eventType: EventType; 54 | overlayPosition?: Point; 55 | overlayPosType?: OverlayPosType; 56 | destination?: Uuid; 57 | closeClickOutside?: boolean; 58 | backgroundOverlay?: boolean; 59 | animation?: Animation; 60 | positionRelativeTo?: Uuid; 61 | }; 62 | 63 | type InteractionCloseOverlay = { 64 | actionType: 'close-overlay'; 65 | eventType: EventType; 66 | destination?: Uuid; 67 | animation?: Animation; 68 | positionRelativeTo?: Uuid; 69 | }; 70 | 71 | type InteractionPrevScreen = { 72 | actionType: 'prev-screen'; 73 | eventType: EventType; 74 | }; 75 | 76 | type InteractionOpenUrl = { 77 | actionType: 'open-url'; 78 | eventType: EventType; 79 | url: string; 80 | }; 81 | -------------------------------------------------------------------------------- /ui-src/types/progressMessages.ts: -------------------------------------------------------------------------------- 1 | import type { PenpotDocument } from '@ui/types/penpotDocument'; 2 | 3 | export type ExportScope = 'all' | 'current'; 4 | 5 | export type Steps = 6 | | 'processing' 7 | | 'processAssets' 8 | | 'buildAssets' 9 | | 'building' 10 | | 'components' 11 | | 'exporting'; 12 | 13 | export const PROGRESS_STEPS: Steps[] = [ 14 | 'processing', 15 | 'processAssets', 16 | 'buildAssets', 17 | 'building', 18 | 'components', 19 | 'exporting' 20 | ]; 21 | 22 | export type PenpotDocumentMessage = { 23 | type: 'PENPOT_DOCUMENT'; 24 | data: PenpotDocument; 25 | }; 26 | 27 | export type ProgressStepMessage = { 28 | type: 'PROGRESS_STEP'; 29 | data: { 30 | step: Steps; 31 | total: number; 32 | }; 33 | }; 34 | 35 | export type ProgressProcessedItemsMessage = { 36 | type: 'PROGRESS_PROCESSED_ITEMS'; 37 | data: number; 38 | }; 39 | 40 | export type ProgressCurrentItemMessage = { 41 | type: 'PROGRESS_CURRENT_ITEM'; 42 | data: string; 43 | }; 44 | 45 | export type ProgressExportMessage = { 46 | type: 'PROGRESS_EXPORT'; 47 | data: { 48 | current: number; 49 | total: number; 50 | }; 51 | }; 52 | 53 | export type ReloadMessage = { 54 | type: 'RELOAD'; 55 | }; 56 | 57 | export type ErrorMessage = { 58 | type: 'ERROR'; 59 | data: string; 60 | }; 61 | 62 | export type UserDataMessage = { 63 | type: 'USER_DATA'; 64 | data: { 65 | userId: string; 66 | }; 67 | }; 68 | 69 | export type PluginMessage = 70 | | PenpotDocumentMessage 71 | | ProgressStepMessage 72 | | ProgressProcessedItemsMessage 73 | | ProgressCurrentItemMessage 74 | | ProgressExportMessage 75 | | ReloadMessage 76 | | ErrorMessage 77 | | UserDataMessage; 78 | 79 | /** 80 | * Types that should be buffered (only the latest message of each type is kept) 81 | */ 82 | export const BUFFERED_PROGRESS_TYPES = [ 83 | 'PROGRESS_PROCESSED_ITEMS', 84 | 'PROGRESS_CURRENT_ITEM', 85 | 'PROGRESS_EXPORT' 86 | ] as const; 87 | 88 | export type BufferedProgressType = (typeof BUFFERED_PROGRESS_TYPES)[number]; 89 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateTextVariable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isAliasValue, 3 | translateAliasValue, 4 | translateGenericVariable, 5 | translateScope, 6 | translateVariableValues 7 | } from '@plugin/translators/tokens'; 8 | 9 | import type { Token, TokenType } from '@ui/lib/types/shapes/tokens'; 10 | 11 | const VALID_FONT_WEIGHT_VALUES = [ 12 | 'thin', 13 | 'thinitalic', 14 | 'extralight', 15 | 'extralightitalic', 16 | 'light', 17 | 'lightitalic', 18 | 'regular', 19 | 'italic', 20 | 'medium', 21 | 'mediumitalic', 22 | 'semibold', 23 | 'semibolditalic', 24 | 'bold', 25 | 'bolditalic', 26 | 'extrabold', 27 | 'extrabolditalic', 28 | 'black', 29 | 'blackitalic' 30 | ]; 31 | 32 | const isValidFontWeightValue = (value: string): boolean => { 33 | return VALID_FONT_WEIGHT_VALUES.includes(value.toLowerCase()); 34 | }; 35 | 36 | const isStringValue = (value: VariableValue): value is string => { 37 | return typeof value === 'string'; 38 | }; 39 | 40 | const translateValue = (value: VariableValue, tokenType: TokenType): Token['$value'] | null => { 41 | if (isAliasValue(value)) { 42 | return translateAliasValue(value); 43 | } 44 | 45 | if (isStringValue(value)) { 46 | if (tokenType === 'fontFamilies') { 47 | return [value]; 48 | } 49 | 50 | if (tokenType === 'fontWeights') { 51 | return isValidFontWeightValue(value) ? value : null; 52 | } 53 | 54 | return value; 55 | } 56 | 57 | return null; 58 | }; 59 | 60 | const translateScopes = (variable: Variable): TokenType[] => { 61 | if (variable.scopes[0] === 'ALL_SCOPES') { 62 | return ['fontWeights', 'fontFamilies']; 63 | } 64 | 65 | return variable.scopes.map(scope => translateScope(scope)).filter(scope => scope !== null); 66 | }; 67 | 68 | export const translateTextVariable = ( 69 | variable: Variable, 70 | variableName: string, 71 | modeId: string 72 | ): [string, Token | Record] | null => { 73 | return translateGenericVariable(variable, variableName, modeId, (variable, modeId) => 74 | translateVariableValues(variable, modeId, translateScopes, translateValue) 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /plugin-src/transformers/transformSceneNode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | transformBooleanNode, 3 | transformComponentNode, 4 | transformComponentSetNode, 5 | transformEllipseNode, 6 | transformFrameNode, 7 | transformGroupNode, 8 | transformInstanceNode, 9 | transformLineNode, 10 | transformPathNode, 11 | transformRectangleNode, 12 | transformTextNode, 13 | transformVectorNode 14 | } from '@plugin/transformers'; 15 | import { reportProgress } from '@plugin/utils'; 16 | 17 | import type { PenpotNode } from '@ui/types'; 18 | 19 | export const transformSceneNode = async (node: SceneNode): Promise => { 20 | let penpotNode: PenpotNode | undefined; 21 | 22 | reportProgress({ 23 | type: 'PROGRESS_CURRENT_ITEM', 24 | data: node.name 25 | }); 26 | 27 | switch (node.type) { 28 | case 'RECTANGLE': 29 | penpotNode = transformRectangleNode(node); 30 | break; 31 | case 'ELLIPSE': 32 | penpotNode = transformEllipseNode(node); 33 | break; 34 | case 'COMPONENT_SET': 35 | penpotNode = await transformComponentSetNode(node); 36 | break; 37 | case 'SECTION': 38 | case 'FRAME': 39 | penpotNode = await transformFrameNode(node); 40 | break; 41 | case 'GROUP': 42 | penpotNode = await transformGroupNode(node); 43 | break; 44 | case 'TEXT': 45 | penpotNode = transformTextNode(node); 46 | break; 47 | case 'VECTOR': 48 | penpotNode = transformVectorNode(node); 49 | break; 50 | case 'LINE': 51 | penpotNode = transformLineNode(node); 52 | break; 53 | case 'STAR': 54 | case 'POLYGON': 55 | penpotNode = transformPathNode(node); 56 | break; 57 | case 'BOOLEAN_OPERATION': 58 | penpotNode = await transformBooleanNode(node); 59 | break; 60 | case 'COMPONENT': 61 | penpotNode = await transformComponentNode(node); 62 | break; 63 | case 'INSTANCE': 64 | penpotNode = await transformInstanceNode(node); 65 | break; 66 | } 67 | 68 | if (penpotNode === undefined) { 69 | console.warn(`Unsupported node type: ${node.type}`); 70 | } 71 | 72 | return penpotNode; 73 | }; 74 | -------------------------------------------------------------------------------- /plugin-src/translators/tokens/translateFloatVariable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isAliasValue, 3 | translateAliasValue, 4 | translateGenericVariable, 5 | translateScope, 6 | translateVariableValues 7 | } from '@plugin/translators/tokens'; 8 | 9 | import type { Token, TokenType } from '@ui/lib/types/shapes/tokens'; 10 | 11 | const isValidOpacityValue = (value: number): boolean => { 12 | return value >= 0 && value <= 100; 13 | }; 14 | 15 | const isValidFontWeightValue = (value: number): boolean => { 16 | return [100, 200, 300, 400, 500, 600, 700, 800, 900, 950].includes(value); 17 | }; 18 | 19 | const isNumberValue = (value: VariableValue): value is number => { 20 | return typeof value === 'number'; 21 | }; 22 | 23 | const translateValue = (value: VariableValue, tokenType: TokenType): Token['$value'] | null => { 24 | if (isAliasValue(value)) { 25 | return translateAliasValue(value); 26 | } 27 | 28 | if (!isNumberValue(value)) { 29 | return null; 30 | } 31 | 32 | if (tokenType === 'fontWeights') { 33 | return isValidFontWeightValue(value) ? value.toString() : null; 34 | } 35 | 36 | if (tokenType === 'opacity') { 37 | return isValidOpacityValue(value) ? (value / 100).toString() : null; 38 | } 39 | 40 | return value.toString(); 41 | }; 42 | 43 | const translateScopes = (variable: Variable): TokenType[] => { 44 | if (variable.scopes.length === 0) { 45 | return ['number']; 46 | } 47 | 48 | if (variable.scopes[0] === 'ALL_SCOPES') { 49 | return [ 50 | 'borderRadius', 51 | 'sizing', 52 | 'spacing', 53 | 'borderWidth', 54 | 'opacity', 55 | 'fontWeights', 56 | 'fontSizes', 57 | 'letterSpacing' 58 | ]; 59 | } 60 | 61 | return variable.scopes.map(scope => translateScope(scope)).filter(scope => scope !== null); 62 | }; 63 | 64 | export const translateFloatVariable = ( 65 | variable: Variable, 66 | variableName: string, 67 | modeId: string 68 | ): [string, Token | Record] | null => { 69 | return translateGenericVariable(variable, variableName, modeId, (variable, modeId) => 70 | translateVariableValues(variable, modeId, translateScopes, translateValue) 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /plugin-src/translators/translateChildren.ts: -------------------------------------------------------------------------------- 1 | import { yieldByTime } from '@common/sleep'; 2 | 3 | import { transformGroupNodeLike, transformSceneNode } from '@plugin/transformers'; 4 | import { transformMaskIds } from '@plugin/transformers/partials'; 5 | 6 | import type { PenpotNode } from '@ui/types'; 7 | 8 | /** 9 | * Translates the children of a node that acts as a mask. 10 | * We need to split the children into two groups: the ones that are masked and the ones that are not. 11 | * 12 | * The masked children will be grouped together in a mask group. 13 | * The unmasked children will be returned as they are. 14 | * 15 | * @maskIndex The index of the mask node in the children array 16 | */ 17 | export const translateMaskChildren = async ( 18 | children: readonly SceneNode[], 19 | maskIndex: number 20 | ): Promise => { 21 | const maskChild = children[maskIndex]; 22 | 23 | if ( 24 | maskChild.type === 'STICKY' || 25 | maskChild.type === 'CONNECTOR' || 26 | maskChild.type === 'CODE_BLOCK' || 27 | maskChild.type === 'WIDGET' || 28 | maskChild.type === 'EMBED' || 29 | maskChild.type === 'LINK_UNFURL' || 30 | maskChild.type === 'MEDIA' || 31 | maskChild.type === 'SECTION' || 32 | maskChild.type === 'TABLE' || 33 | maskChild.type === 'SHAPE_WITH_TEXT' 34 | ) { 35 | return await translateChildren(children); 36 | } 37 | 38 | const unmaskedChildren = await translateChildren(children.slice(0, maskIndex)); 39 | const maskedChildren = await translateChildren(children.slice(maskIndex)); 40 | 41 | const maskGroup = { 42 | ...transformMaskIds(maskChild), 43 | ...transformGroupNodeLike(maskChild), 44 | children: maskedChildren, 45 | maskedGroup: true 46 | }; 47 | 48 | return [...unmaskedChildren, maskGroup]; 49 | }; 50 | 51 | export const translateChildren = async (children: readonly SceneNode[]): Promise => { 52 | const transformedChildren: PenpotNode[] = []; 53 | 54 | for (const child of children) { 55 | const penpotNode = await transformSceneNode(child); 56 | 57 | if (penpotNode) transformedChildren.push(penpotNode); 58 | 59 | await yieldByTime(); 60 | } 61 | 62 | return transformedChildren; 63 | }; 64 | -------------------------------------------------------------------------------- /plugin-src/translators/translateGrids.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GridLayoutGrid, 3 | LayoutGrid, 4 | RowsColsLayoutGrid 5 | } from '@figma/plugin-typings/plugin-api-standalone'; 6 | 7 | import { rgbToHex } from '@plugin/utils'; 8 | 9 | import type { 10 | ColumnGrid, 11 | Grid, 12 | GridAlignment, 13 | RowGrid, 14 | SquareGrid 15 | } from '@ui/lib/types/utils/grid'; 16 | 17 | export const translateGrids = (layoutGrids: readonly LayoutGrid[]): Grid[] => { 18 | return layoutGrids.map(grid => { 19 | switch (grid.pattern) { 20 | case 'GRID': 21 | return translateSquareGrid(grid); 22 | case 'ROWS': 23 | case 'COLUMNS': 24 | return translateRowColsGrid(grid); 25 | } 26 | }); 27 | }; 28 | 29 | const translateSquareGrid = (layoutGrid: GridLayoutGrid): SquareGrid => { 30 | return { 31 | type: 'square', 32 | display: layoutGrid.visible ? layoutGrid.visible : false, 33 | params: { 34 | size: layoutGrid.sectionSize, 35 | color: { 36 | color: layoutGrid.color ? rgbToHex(layoutGrid.color) : '#000000', 37 | opacity: layoutGrid.color ? layoutGrid.color.a : 0 38 | } 39 | } 40 | }; 41 | }; 42 | 43 | const translateRowColsGrid = (layoutGrid: RowsColsLayoutGrid): RowGrid | ColumnGrid => { 44 | return { 45 | type: layoutGrid.pattern === 'ROWS' ? 'row' : 'column', 46 | display: layoutGrid.visible ? layoutGrid.visible : false, 47 | params: { 48 | color: { 49 | color: layoutGrid.color ? rgbToHex(layoutGrid.color) : '#000000', 50 | opacity: layoutGrid.color ? layoutGrid.color.a : 0 51 | }, 52 | type: translateGridAlignment(layoutGrid.alignment), 53 | size: layoutGrid.count === Infinity ? undefined : layoutGrid.count, 54 | margin: layoutGrid.offset, 55 | gutter: layoutGrid.gutterSize, 56 | itemLength: layoutGrid.sectionSize 57 | } 58 | }; 59 | }; 60 | 61 | const translateGridAlignment = (alignment: 'MIN' | 'MAX' | 'STRETCH' | 'CENTER'): GridAlignment => { 62 | switch (alignment) { 63 | case 'MIN': 64 | return 'left'; 65 | case 'MAX': 66 | return 'right'; 67 | case 'STRETCH': 68 | return 'stretch'; 69 | case 'CENTER': 70 | return 'center'; 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /plugin-src/transformers/partials/transformIds.ts: -------------------------------------------------------------------------------- 1 | import { identifiers } from '@plugin/libraries'; 2 | import { generateUuid } from '@plugin/utils'; 3 | 4 | import type { ShapeBaseAttributes } from '@ui/lib/types/shapes/shape'; 5 | import type { Uuid } from '@ui/lib/types/utils/uuid'; 6 | 7 | const parseFigmaId = (figmaId: string): Uuid => { 8 | const id = identifiers.get(figmaId); 9 | 10 | if (id) { 11 | return id; 12 | } 13 | 14 | const newId = generateUuid(); 15 | 16 | identifiers.set(figmaId, newId); 17 | 18 | return newId; 19 | }; 20 | 21 | const getRelatedNodeId = (nodeId: string): string | undefined => { 22 | const ids = nodeId.split(';'); 23 | 24 | if (ids.length > 1) { 25 | return ids.slice(1).join(';'); 26 | } 27 | }; 28 | 29 | const normalizeNodeId = (nodeId: string): string => { 30 | return nodeId.replace('I', ''); 31 | }; 32 | 33 | const transformShapeRef = (node: SceneNode): Uuid | undefined => { 34 | const relatedNodeId = getRelatedNodeId(node.id); 35 | if (!relatedNodeId) { 36 | return; 37 | } 38 | 39 | return parseFigmaId(relatedNodeId); 40 | }; 41 | 42 | export const transformId = (node: SceneNode): Uuid => { 43 | return parseFigmaId(normalizeNodeId(node.id)); 44 | }; 45 | 46 | export const transformIds = (node: SceneNode): Pick => { 47 | return { 48 | id: transformId(node), 49 | shapeRef: transformShapeRef(node) 50 | }; 51 | }; 52 | 53 | export const transformMaskIds = (node: SceneNode): Pick => { 54 | const normalizedId = normalizeNodeId(node.id); 55 | const relatedNodeId = getRelatedNodeId(node.id); 56 | 57 | return { 58 | id: parseFigmaId(`M${normalizedId}`), 59 | shapeRef: relatedNodeId ? parseFigmaId(`M${relatedNodeId}`) : undefined 60 | }; 61 | }; 62 | 63 | export const transformVectorIds = ( 64 | node: SceneNode, 65 | index: number 66 | ): Pick => { 67 | const normalizedId = normalizeNodeId(node.id); 68 | const relatedNodeId = getRelatedNodeId(node.id); 69 | 70 | return { 71 | id: parseFigmaId(`V${index}${normalizedId}`), 72 | shapeRef: relatedNodeId ? parseFigmaId(`V${index}${relatedNodeId}`) : undefined 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /ui-src/components/ProgressStepper/ProgressStepper.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { Circle, CircleCheck } from 'lucide-react'; 3 | import type { JSX } from 'preact'; 4 | 5 | import { Stack } from '@ui/components/Stack'; 6 | import { PROGRESS_STEPS, type Steps } from '@ui/types/progressMessages'; 7 | 8 | import styles from './ProgressStepper.module.css'; 9 | 10 | type ProgressStepperProps = { 11 | currentStep: Steps; 12 | }; 13 | 14 | const STEP_NAMES: Record = { 15 | processing: 'Scan Figma pages', 16 | processAssets: 'Gather linked assets', 17 | buildAssets: 'Build Penpot assets', 18 | building: 'Assemble Penpot pages', 19 | components: 'Create components', 20 | exporting: 'Package Penpot file' 21 | }; 22 | 23 | export const ProgressStepper = ({ currentStep }: ProgressStepperProps): JSX.Element | null => { 24 | const currentStepIndex = PROGRESS_STEPS.indexOf(currentStep); 25 | 26 | if (currentStepIndex === -1) { 27 | return null; 28 | } 29 | 30 | return ( 31 | 32 |
33 | 34 | Step {currentStepIndex + 1} of {PROGRESS_STEPS.length} 35 | {' '} 36 | — {STEP_NAMES[currentStep]} 37 |
38 | 39 | 40 | {PROGRESS_STEPS.map((step, index) => { 41 | const isCompleted = index < currentStepIndex; 42 | const isCurrent = index === currentStepIndex; 43 | const isNextStep = index > currentStepIndex; 44 | 45 | return ( 46 |
53 | {isCurrent ? ( 54 | 55 | ) : isCompleted ? ( 56 | 57 | ) : ( 58 | 59 | )} 60 | {STEP_NAMES[step]} 61 |
62 | ); 63 | })} 64 |
65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /ui-src/lib/types/shapes/textShape.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutChildAttributes } from '@ui/lib/types/shapes/layout'; 2 | import type { 3 | ShapeAttributes, 4 | ShapeBaseAttributes, 5 | ShapeGeomAttributes 6 | } from '@ui/lib/types/shapes/shape'; 7 | import type { Fill } from '@ui/lib/types/utils/fill'; 8 | import type { Typography } from '@ui/lib/types/utils/typography'; 9 | 10 | export type TextShape = ShapeBaseAttributes & 11 | ShapeGeomAttributes & 12 | ShapeAttributes & 13 | TextAttributes & 14 | LayoutChildAttributes; 15 | 16 | export type TextAttributes = { 17 | type?: 'text'; 18 | content?: TextContent; 19 | characters?: string; // @ TODO: move to any other place 20 | }; 21 | 22 | export type TextContent = { 23 | type: 'root'; 24 | key?: string; 25 | verticalAlign?: TextVerticalAlign; 26 | children?: ParagraphSet[]; 27 | }; 28 | 29 | export type TextVerticalAlign = 'top' | 'bottom' | 'center'; 30 | export type TextHorizontalAlign = 'left' | 'right' | 'center' | 'justify'; 31 | export type TextFontStyle = 'normal' | 'italic'; 32 | 33 | export type ParagraphSet = { 34 | type: 'paragraph-set'; 35 | key?: string; 36 | children: Paragraph[]; 37 | }; 38 | 39 | export type Paragraph = { 40 | type: 'paragraph'; 41 | key?: string; 42 | children: TextNode[]; 43 | } & TextStyle; 44 | 45 | export type TextNode = { 46 | text: string; 47 | key?: string; 48 | } & TextStyle; 49 | 50 | export type TextStyle = TextTypography & { 51 | textDecoration?: string; 52 | direction?: string; 53 | typographyRefId?: string; 54 | typographyRefFile?: string; 55 | textAlign?: TextHorizontalAlign; 56 | textDirection?: 'ltr' | 'rtl' | 'auto'; 57 | fills?: Fill[]; 58 | 59 | fillStyleId?: string; // @TODO: move to any other place 60 | textStyleId?: string; // @TODO: move to any other place 61 | }; 62 | 63 | export type TextTypography = FontId & { 64 | fontFamily?: string; 65 | fontSize?: string; 66 | fontWeight?: string; 67 | fontStyle?: TextFontStyle; 68 | lineHeight?: string; 69 | letterSpacing?: string; 70 | textTransform?: string; 71 | }; 72 | 73 | export type FontId = { 74 | fontId?: string; 75 | fontVariantId?: string; 76 | }; 77 | 78 | export type TypographyStyle = { 79 | name: string; 80 | textStyle: TextStyle; 81 | typography: Typography; 82 | }; 83 | -------------------------------------------------------------------------------- /plugin-src/translators/text/paragraph/List.ts: -------------------------------------------------------------------------------- 1 | import { ListTypeFactory } from '@plugin/translators/text/paragraph/ListTypeFactory'; 2 | import type { TextSegment } from '@plugin/translators/text/paragraph/translateParagraphProperties'; 3 | 4 | import type { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape'; 5 | 6 | type Level = { 7 | style: PenpotTextNode; 8 | counter: number; 9 | type: ListType; 10 | }; 11 | 12 | type ListType = 'ORDERED' | 'UNORDERED' | 'NONE'; 13 | 14 | export class List { 15 | private levels: Map = new Map(); 16 | private indentation = 0; 17 | protected counter: number[] = []; 18 | private listTypeFactory = new ListTypeFactory(); 19 | 20 | public update(textNode: PenpotTextNode, segment: TextSegment): void { 21 | if (segment.indentation < this.indentation) { 22 | for (let i = segment.indentation + 1; i <= this.indentation; i++) { 23 | this.levels.delete(i); 24 | } 25 | } 26 | 27 | let level = this.levels.get(segment.indentation); 28 | 29 | if (!level || level.type !== segment.listOptions.type) { 30 | level = { 31 | style: this.createStyle(textNode, segment.indentation), 32 | counter: 0, 33 | type: segment.listOptions.type 34 | }; 35 | 36 | this.levels.set(segment.indentation, level); 37 | } 38 | 39 | level.counter++; 40 | this.indentation = segment.indentation; 41 | } 42 | 43 | public getCurrentList(textNode: PenpotTextNode, segment: TextSegment): PenpotTextNode { 44 | const level = this.levels.get(segment.indentation); 45 | if (level === undefined) { 46 | throw new Error('Levels not updated'); 47 | } 48 | 49 | const listType = this.listTypeFactory.getListType(segment.listOptions); 50 | 51 | return this.updateCurrentSymbol( 52 | listType.getCurrentSymbol(level.counter, segment.indentation), 53 | level.style 54 | ); 55 | } 56 | 57 | private createStyle(node: PenpotTextNode, indentation: number): PenpotTextNode { 58 | return { 59 | ...node, 60 | text: `${'\t'.repeat(Math.max(0, indentation - 1))}{currentSymbol}` 61 | }; 62 | } 63 | 64 | private updateCurrentSymbol(character: string, currentStyle: PenpotTextNode): PenpotTextNode { 65 | return { 66 | ...currentStyle, 67 | text: currentStyle.text.replace('{currentSymbol}', character) 68 | }; 69 | } 70 | } 71 | --------------------------------------------------------------------------------