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

Text libraries (#185)

* color library

* fixes

* wip

* wip

* wip

* wip

* working

* improvements

* changeset

* changeset

* changeset & cleaning

* changeset & cleaning

* improvements

* fixes

* rebase
This commit is contained in:
Alex Sánchez 2024-06-25 16:08:59 +02:00 committed by GitHub
parent a58f9e913d
commit d3c144e5e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 506 additions and 155 deletions

View file

@ -0,0 +1,5 @@
---
"penpot-exporter": minor
---
Add support for typographies

View file

@ -0,0 +1,5 @@
---
"penpot-exporter": patch
---
Improve font weight translation

View file

@ -0,0 +1,5 @@
---
"penpot-exporter": patch
---
Fix letter spacing

21
plugin-src/TextLibrary.ts Normal file
View file

@ -0,0 +1,21 @@
class TextLibrary {
private styles: Map<string, TextStyle | undefined> = new Map();
public register(id: string, styles?: TextStyle | undefined) {
this.styles.set(id, styles);
}
public get(id: string): TextStyle | undefined {
return this.styles.get(id);
}
public has(id: string): boolean {
return this.styles.has(id);
}
public all(): Record<string, TextStyle | undefined> {
return Object.fromEntries(this.styles.entries());
}
}
export const textLibrary = new TextLibrary();

View file

@ -1,15 +1,11 @@
import { translateFillStyleId, translateFills } from '@plugin/translators/fills'; import { translateFillStyleId, translateFills } from '@plugin/translators/fills';
import { StyleTextSegment } from '@plugin/translators/text/paragraph'; import { TextSegment } from '@plugin/translators/text/paragraph';
import { ShapeAttributes } from '@ui/lib/types/shapes/shape'; import { ShapeAttributes } from '@ui/lib/types/shapes/shape';
import { TextStyle } from '@ui/lib/types/shapes/textShape'; import { TextStyle } from '@ui/lib/types/shapes/textShape';
export const transformFills = ( export const transformFills = (
node: node: (MinimalFillsMixin & DimensionAndPositionMixin) | VectorRegion | VectorNode | TextSegment
| (MinimalFillsMixin & DimensionAndPositionMixin)
| VectorRegion
| VectorNode
| StyleTextSegment
): Pick<ShapeAttributes, 'fills' | 'fillStyleId'> | Pick<TextStyle, 'fills' | 'fillStyleId'> => { ): Pick<ShapeAttributes, 'fills' | 'fillStyleId'> | Pick<TextStyle, 'fills' | 'fillStyleId'> => {
if (hasFillStyle(node)) { if (hasFillStyle(node)) {
return { return {
@ -39,11 +35,7 @@ export const transformVectorFills = (
}; };
const hasFillStyle = ( const hasFillStyle = (
node: node: (MinimalFillsMixin & DimensionAndPositionMixin) | VectorRegion | VectorNode | TextSegment
| (MinimalFillsMixin & DimensionAndPositionMixin)
| VectorRegion
| VectorNode
| StyleTextSegment
): boolean => { ): boolean => {
return ( return (
node.fillStyleId !== figma.mixed && node.fillStyleId !== figma.mixed &&

View file

@ -1,5 +1,5 @@
import { transformFills } from '@plugin/transformers/partials'; import { transformFills } from '@plugin/transformers/partials';
import { transformTextStyle, translateStyleTextSegments } from '@plugin/translators/text'; import { transformTextStyle, translateTextSegments } from '@plugin/translators/text';
import { translateGrowType, translateVerticalAlign } from '@plugin/translators/text/properties'; import { translateGrowType, translateVerticalAlign } from '@plugin/translators/text/properties';
import { TextAttributes, TextShape } from '@ui/lib/types/shapes/textShape'; import { TextAttributes, TextShape } from '@ui/lib/types/shapes/textShape';
@ -16,7 +16,8 @@ export const transformText = (node: TextNode): TextAttributes & Pick<TextShape,
'indentation', 'indentation',
'listOptions', 'listOptions',
'fills', 'fills',
'fillStyleId' 'fillStyleId',
'textStyleId'
]); ]);
return { return {
@ -30,7 +31,7 @@ export const transformText = (node: TextNode): TextAttributes & Pick<TextShape,
children: [ children: [
{ {
type: 'paragraph', type: 'paragraph',
children: translateStyleTextSegments(node, styledTextSegments), children: translateTextSegments(node, styledTextSegments),
...transformTextStyle(node, styledTextSegments[0]), ...transformTextStyle(node, styledTextSegments[0]),
...transformFills(node) ...transformFills(node)
} }

View file

@ -2,16 +2,26 @@ import { componentsLibrary } from '@plugin/ComponentLibrary';
import { imagesLibrary } from '@plugin/ImageLibrary'; import { imagesLibrary } from '@plugin/ImageLibrary';
import { remoteComponentLibrary } from '@plugin/RemoteComponentLibrary'; import { remoteComponentLibrary } from '@plugin/RemoteComponentLibrary';
import { styleLibrary } from '@plugin/StyleLibrary'; import { styleLibrary } from '@plugin/StyleLibrary';
import { textLibrary } from '@plugin/TextLibrary';
import { translateRemoteChildren } from '@plugin/translators'; import { translateRemoteChildren } from '@plugin/translators';
import { translatePaintStyles } from '@plugin/translators/fills'; import { translatePaintStyle, translateTextStyle } from '@plugin/translators/styles';
import { sleep } from '@plugin/utils'; import { sleep } from '@plugin/utils';
import { PenpotPage } from '@ui/lib/types/penpotPage'; import { PenpotPage } from '@ui/lib/types/penpotPage';
import { TypographyStyle } from '@ui/lib/types/shapes/textShape';
import { FillStyle } from '@ui/lib/types/utils/fill'; import { FillStyle } from '@ui/lib/types/utils/fill';
import { PenpotDocument } from '@ui/types'; import { PenpotDocument } from '@ui/types';
import { transformPageNode } from '.'; import { transformPageNode } from '.';
const isPaintStyle = (style: BaseStyle): style is PaintStyle => {
return style.type === 'PAINT';
};
const isTextStyle = (style: BaseStyle): style is TextStyle => {
return style.type === 'TEXT';
};
const downloadImages = async (): Promise<Record<string, Uint8Array>> => { const downloadImages = async (): Promise<Record<string, Uint8Array>> => {
const imageToDownload = Object.entries(imagesLibrary.all()); const imageToDownload = Object.entries(imagesLibrary.all());
const images: Record<string, Uint8Array> = {}; const images: Record<string, Uint8Array> = {};
@ -71,7 +81,7 @@ const getFillStyles = async (): Promise<Record<string, FillStyle>> => {
for (const [styleId, paintStyle] of stylesToFetch) { for (const [styleId, paintStyle] of stylesToFetch) {
const figmaStyle = paintStyle ?? (await figma.getStyleByIdAsync(styleId)); const figmaStyle = paintStyle ?? (await figma.getStyleByIdAsync(styleId));
if (figmaStyle && isPaintStyle(figmaStyle)) { if (figmaStyle && isPaintStyle(figmaStyle)) {
styles[styleId] = translatePaintStyles(figmaStyle); styles[styleId] = translatePaintStyle(figmaStyle);
} }
figma.ui.postMessage({ figma.ui.postMessage({
@ -87,8 +97,41 @@ const getFillStyles = async (): Promise<Record<string, FillStyle>> => {
return styles; return styles;
}; };
const isPaintStyle = (style: BaseStyle): style is PaintStyle => { const getTextStyles = async (): Promise<Record<string, TypographyStyle>> => {
return style.type === 'PAINT'; const stylesToFetch = Object.entries(textLibrary.all());
const styles: Record<string, TypographyStyle> = {};
if (stylesToFetch.length === 0) return styles;
let currentStyle = 1;
figma.ui.postMessage({
type: 'PROGRESS_TOTAL_ITEMS',
data: stylesToFetch.length
});
figma.ui.postMessage({
type: 'PROGRESS_STEP',
data: 'typographies'
});
for (const [styleId, style] of stylesToFetch) {
const figmaStyle = style ?? (await figma.getStyleByIdAsync(styleId));
if (figmaStyle && isTextStyle(figmaStyle)) {
styles[styleId] = translateTextStyle(figmaStyle);
}
figma.ui.postMessage({
type: 'PROGRESS_PROCESSED_ITEMS',
data: currentStyle++
});
await sleep(0);
}
await sleep(20);
return styles;
}; };
const processPages = async (node: DocumentNode): Promise<PenpotPage[]> => { const processPages = async (node: DocumentNode): Promise<PenpotPage[]> => {
@ -122,6 +165,11 @@ export const transformDocumentNode = async (node: DocumentNode): Promise<PenpotD
styleLibrary.register(style.id, style); styleLibrary.register(style.id, style);
}); });
const localTextStyles = await figma.getLocalTextStylesAsync();
localTextStyles.forEach(style => {
textLibrary.register(style.id, style);
});
const children = await processPages(node); const children = await processPages(node);
if (remoteComponentLibrary.remaining() > 0) { if (remoteComponentLibrary.remaining() > 0) {
@ -135,11 +183,14 @@ export const transformDocumentNode = async (node: DocumentNode): Promise<PenpotD
const images = await downloadImages(); const images = await downloadImages();
const typographies = await getTextStyles();
return { return {
name: node.name, name: node.name,
children, children,
components: componentsLibrary.all(), components: componentsLibrary.all(),
images, images,
styles styles,
typographies
}; };
}; };

View file

@ -1,4 +1,3 @@
export * from './translateFills'; export * from './translateFills';
export * from './translateImageFill'; export * from './translateImageFill';
export * from './translatePaintStyles';
export * from './translateSolidFill'; export * from './translateSolidFill';

View file

@ -0,0 +1,2 @@
export * from './translatePaintStyle';
export * from './translateTextStyle';

View file

@ -2,7 +2,7 @@ import { translateFill } from '@plugin/translators/fills/translateFills';
import { FillStyle } from '@ui/lib/types/utils/fill'; import { FillStyle } from '@ui/lib/types/utils/fill';
export const translatePaintStyles = (figmaStyle: PaintStyle): FillStyle => { export const translatePaintStyle = (figmaStyle: PaintStyle): FillStyle => {
const fillStyle: FillStyle = { const fillStyle: FillStyle = {
name: figmaStyle.name, name: figmaStyle.name,
fills: [], fills: [],
@ -10,7 +10,6 @@ export const translatePaintStyles = (figmaStyle: PaintStyle): FillStyle => {
}; };
const colorName = (figmaStyle: PaintStyle, index: number): string => { const colorName = (figmaStyle: PaintStyle, index: number): string => {
// @TODO: Think something better
return figmaStyle.paints.length > 1 ? `Color ${index + 1}` : figmaStyle.name; return figmaStyle.paints.length > 1 ? `Color ${index + 1}` : figmaStyle.name;
}; };

View file

@ -0,0 +1,32 @@
import { translateFontName } from '@plugin/translators/text/font';
import {
translateFontStyle,
translateLetterSpacing,
translateLineHeight,
translateTextDecoration,
translateTextTransform
} from '@plugin/translators/text/properties';
import { TypographyStyle } from '@ui/lib/types/shapes/textShape';
export const translateTextStyle = (figmaStyle: TextStyle): TypographyStyle => {
const path = figmaStyle.remote ? 'Remote / ' : '';
return {
name: figmaStyle.name,
textStyle: {
...translateFontName(figmaStyle.fontName),
fontFamily: figmaStyle.fontName.family,
fontSize: figmaStyle.fontSize.toString(),
fontStyle: translateFontStyle(figmaStyle.fontName.style),
textDecoration: translateTextDecoration(figmaStyle),
letterSpacing: translateLetterSpacing(figmaStyle),
textTransform: translateTextTransform(figmaStyle),
lineHeight: translateLineHeight(figmaStyle)
},
typography: {
path,
name: figmaStyle.name
}
};
};

View file

@ -1,10 +1,15 @@
import { getCustomFontId, translateFontVariantId } from '@plugin/translators/text/font/custom'; import { getCustomFontId, translateFontVariantId } from '@plugin/translators/text/font/custom';
import { FontId } from '@ui/lib/types/shapes/textShape'; import { TextTypography } from '@ui/lib/types/shapes/textShape';
export const translateCustomFont = (fontName: FontName, fontWeight: number): FontId | undefined => { export const translateCustomFont = (
fontName: FontName,
fontWeight: string
): Pick<TextTypography, 'fontId' | 'fontVariantId' | 'fontWeight'> | undefined => {
const customFontId = getCustomFontId(fontName);
return { return {
fontId: `custom-${getCustomFontId(fontName)}`, fontId: customFontId ? `custom-${customFontId}` : '',
fontVariantId: translateFontVariantId(fontName, fontWeight) fontVariantId: translateFontVariantId(fontName, fontWeight),
fontWeight
}; };
}; };

View file

@ -1,5 +1,5 @@
export const translateFontVariantId = (fontName: FontName, fontWeight: number) => { export const translateFontVariantId = (fontName: FontName, fontWeight: string) => {
const style = fontName.style.toLowerCase().includes('italic') ? 'italic' : 'normal'; const style = fontName.style.toLowerCase().includes('italic') ? 'italic' : 'normal';
return `${style}-${fontWeight.toString()}`; return `${style}-${fontWeight}`;
}; };

View file

@ -3,7 +3,7 @@ import { GoogleFont } from './googleFont';
export const translateFontVariantId = ( export const translateFontVariantId = (
googleFont: GoogleFont, googleFont: GoogleFont,
fontName: FontName, fontName: FontName,
fontWeight: number fontWeight: string
) => { ) => {
// check match directly by style // check match directly by style
const variant = googleFont.variants?.find(variant => variant === fontName.style.toLowerCase()); const variant = googleFont.variants?.find(variant => variant === fontName.style.toLowerCase());
@ -13,7 +13,7 @@ export const translateFontVariantId = (
// check match by style and weight // check match by style and weight
const italic = fontName.style.toLowerCase().includes('italic') ? 'italic' : ''; const italic = fontName.style.toLowerCase().includes('italic') ? 'italic' : '';
const variantWithWeight = googleFont.variants?.find( const variantWithWeight = googleFont.variants?.find(
variant => variant === `${fontWeight.toString()}${italic}` variant => variant === `${fontWeight}${italic}`
); );
if (variantWithWeight !== undefined) return variantWithWeight; if (variantWithWeight !== undefined) return variantWithWeight;

View file

@ -3,21 +3,25 @@ import slugify from 'slugify';
import { Cache } from '@plugin/Cache'; import { Cache } from '@plugin/Cache';
import { translateFontVariantId } from '@plugin/translators/text/font/gfonts'; import { translateFontVariantId } from '@plugin/translators/text/font/gfonts';
import { FontId } from '@ui/lib/types/shapes/textShape'; import { TextTypography } from '@ui/lib/types/shapes/textShape';
import { items as gfonts } from './gfonts.json'; import { items as gfonts } from './gfonts.json';
import { GoogleFont } from './googleFont'; import { GoogleFont } from './googleFont';
const fontsCache = new Cache<string, GoogleFont>({ max: 30 }); const fontsCache = new Cache<string, GoogleFont>({ max: 30 });
export const translateGoogleFont = (fontName: FontName, fontWeight: number): FontId | undefined => { export const translateGoogleFont = (
fontName: FontName,
fontWeight: string
): Pick<TextTypography, 'fontId' | 'fontVariantId' | 'fontWeight'> | undefined => {
const googleFont = getGoogleFont(fontName); const googleFont = getGoogleFont(fontName);
if (googleFont === undefined) return; if (googleFont === undefined) return;
return { return {
fontId: `gfont-${slugify(fontName.family.toLowerCase())}`, fontId: `gfont-${slugify(fontName.family.toLowerCase())}`,
fontVariantId: translateFontVariantId(googleFont, fontName, fontWeight) fontVariantId: translateFontVariantId(googleFont, fontName, fontWeight),
fontWeight
}; };
}; };

View file

@ -1 +1 @@
export * from './translateFontId'; export * from './translateFontName';

View file

@ -3,13 +3,12 @@ import { LocalFont } from './localFont';
export const translateFontVariantId = ( export const translateFontVariantId = (
localFont: LocalFont, localFont: LocalFont,
fontName: FontName, fontName: FontName,
fontWeight: number fontWeight: string
): string | undefined => { ): string | undefined => {
// check match by style and weight // check match by style and weight
const italic = fontName.style.toLowerCase().includes('italic'); const italic = fontName.style.toLowerCase().includes('italic');
const variantWithStyleWeight = localFont.variants?.find( const variantWithStyleWeight = localFont.variants?.find(
variant => variant => variant.weight === fontWeight && variant.style === (italic ? 'italic' : 'normal')
variant.weight === fontWeight.toString() && variant.style === (italic ? 'italic' : 'normal')
); );
if (variantWithStyleWeight !== undefined) return variantWithStyleWeight.id; if (variantWithStyleWeight !== undefined) return variantWithStyleWeight.id;

View file

@ -1,17 +1,21 @@
import { LocalFont, translateFontVariantId } from '@plugin/translators/text/font/local'; import { LocalFont, translateFontVariantId } from '@plugin/translators/text/font/local';
import { FontId } from '@ui/lib/types/shapes/textShape'; import { TextTypography } from '@ui/lib/types/shapes/textShape';
import { items as localFonts } from './localFonts.json'; import { items as localFonts } from './localFonts.json';
export const translateLocalFont = (fontName: FontName, fontWeight: number): FontId | undefined => { export const translateLocalFont = (
fontName: FontName,
fontWeight: string
): Pick<TextTypography, 'fontId' | 'fontVariantId' | 'fontWeight'> | undefined => {
const localFont = getLocalFont(fontName); const localFont = getLocalFont(fontName);
if (localFont === undefined) return; if (localFont === undefined) return;
return { return {
fontId: localFont.id, fontId: localFont.id,
fontVariantId: translateFontVariantId(localFont, fontName, fontWeight) fontVariantId: translateFontVariantId(localFont, fontName, fontWeight),
fontWeight
}; };
}; };

View file

@ -1,13 +0,0 @@
import { FontId } from '@ui/lib/types/shapes/textShape';
import { translateCustomFont } from './custom';
import { translateGoogleFont } from './gfonts';
import { translateLocalFont } from './local';
export const translateFontId = (fontName: FontName, fontWeight: number): FontId | undefined => {
return (
translateGoogleFont(fontName, fontWeight) ??
translateLocalFont(fontName, fontWeight) ??
translateCustomFont(fontName, fontWeight)
);
};

View file

@ -0,0 +1,19 @@
import { translateFontWeight } from '@plugin/translators/text/properties';
import { TextTypography } from '@ui/lib/types/shapes/textShape';
import { translateCustomFont } from './custom';
import { translateGoogleFont } from './gfonts';
import { translateLocalFont } from './local';
export const translateFontName = (
fontName: FontName
): Pick<TextTypography, 'fontId' | 'fontVariantId' | 'fontWeight'> | undefined => {
const fontWeight = translateFontWeight(fontName);
return (
translateGoogleFont(fontName, fontWeight) ??
translateLocalFont(fontName, fontWeight) ??
translateCustomFont(fontName, fontWeight)
);
};

View file

@ -1 +1 @@
export * from './translateStyleTextSegments'; export * from './translateTextSegments';

View file

@ -1,4 +1,4 @@
import { StyleTextSegment } from '@plugin/translators/text/paragraph/translateParagraphProperties'; import { TextSegment } from '@plugin/translators/text/paragraph/translateParagraphProperties';
import { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape'; import { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape';
@ -18,7 +18,7 @@ export class List {
protected counter: number[] = []; protected counter: number[] = [];
private listTypeFactory = new ListTypeFactory(); private listTypeFactory = new ListTypeFactory();
public update(textNode: PenpotTextNode, segment: StyleTextSegment): void { public update(textNode: PenpotTextNode, segment: TextSegment): void {
if (segment.indentation < this.indentation) { if (segment.indentation < this.indentation) {
for (let i = segment.indentation + 1; i <= this.indentation; i++) { for (let i = segment.indentation + 1; i <= this.indentation; i++) {
this.levels.delete(i); this.levels.delete(i);
@ -41,7 +41,7 @@ export class List {
this.indentation = segment.indentation; this.indentation = segment.indentation;
} }
public getCurrentList(textNode: PenpotTextNode, segment: StyleTextSegment): PenpotTextNode { public getCurrentList(textNode: PenpotTextNode, segment: TextSegment): PenpotTextNode {
const level = this.levels.get(segment.indentation); const level = this.levels.get(segment.indentation);
if (level === undefined) { if (level === undefined) {
throw new Error('Levels not updated'); throw new Error('Levels not updated');

View file

@ -1,7 +1,7 @@
import { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape'; import { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape';
import { List } from './List'; import { List } from './List';
import { StyleTextSegment } from './translateParagraphProperties'; import { TextSegment } from './translateParagraphProperties';
export class Paragraph { export class Paragraph {
private isParagraphStarting = false; private isParagraphStarting = false;
@ -9,11 +9,7 @@ export class Paragraph {
private firstTextNode: PenpotTextNode | null = null; private firstTextNode: PenpotTextNode | null = null;
private list = new List(); private list = new List();
public format( public format(node: TextNode, textNode: PenpotTextNode, segment: TextSegment): PenpotTextNode[] {
node: TextNode,
textNode: PenpotTextNode,
segment: StyleTextSegment
): PenpotTextNode[] {
const textNodes: PenpotTextNode[] = []; const textNodes: PenpotTextNode[] = [];
const spacing = this.applySpacing(segment, node); const spacing = this.applySpacing(segment, node);
@ -32,7 +28,7 @@ export class Paragraph {
private applyIndentation( private applyIndentation(
textNode: PenpotTextNode, textNode: PenpotTextNode,
segment: StyleTextSegment, segment: TextSegment,
node: TextNode node: TextNode
): PenpotTextNode | undefined { ): PenpotTextNode | undefined {
if (this.isParagraphStarting || this.isFirstTextNode(textNode)) { if (this.isParagraphStarting || this.isFirstTextNode(textNode)) {
@ -44,7 +40,7 @@ export class Paragraph {
} }
} }
private applySpacing(segment: StyleTextSegment, node: TextNode): PenpotTextNode | undefined { private applySpacing(segment: TextSegment, node: TextNode): PenpotTextNode | undefined {
if (this.isParagraphStarting) { if (this.isParagraphStarting) {
const isList = segment.listOptions.type !== 'NONE'; const isList = segment.listOptions.type !== 'NONE';
@ -73,8 +69,8 @@ export class Paragraph {
fontSize: '5', fontSize: '5',
fontStyle: 'normal', fontStyle: 'normal',
fontWeight: '400', fontWeight: '400',
lineHeight: 1, lineHeight: '1',
letterSpacing: 0 letterSpacing: '0'
}; };
} }
@ -88,8 +84,8 @@ export class Paragraph {
fontSize: paragraphSpacing.toString(), fontSize: paragraphSpacing.toString(),
fontStyle: 'normal', fontStyle: 'normal',
fontWeight: '400', fontWeight: '400',
lineHeight: 1, lineHeight: '1',
letterSpacing: 0 letterSpacing: '0'
}; };
} }
} }

View file

@ -2,7 +2,7 @@ import { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape';
import { Paragraph } from './Paragraph'; import { Paragraph } from './Paragraph';
export type StyleTextSegment = Pick< export type TextSegment = Pick<
StyledTextSegment, StyledTextSegment,
| 'characters' | 'characters'
| 'start' | 'start'
@ -18,16 +18,17 @@ export type StyleTextSegment = Pick<
| 'listOptions' | 'listOptions'
| 'fills' | 'fills'
| 'fillStyleId' | 'fillStyleId'
| 'textStyleId'
>; >;
type PartialTranslation = { type PartialTranslation = {
textNodes: PenpotTextNode[]; textNodes: PenpotTextNode[];
segment: StyleTextSegment; segment: TextSegment;
}; };
export const translateParagraphProperties = ( export const translateParagraphProperties = (
node: TextNode, node: TextNode,
partials: { textNode: PenpotTextNode; segment: StyleTextSegment }[] partials: { textNode: PenpotTextNode; segment: TextSegment }[]
): PenpotTextNode[] => { ): PenpotTextNode[] => {
const splitSegments: PartialTranslation[] = []; const splitSegments: PartialTranslation[] = [];

View file

@ -1,4 +1,5 @@
export * from './translateFontStyle'; export * from './translateFontStyle';
export * from './translateFontWeight';
export * from './translateGrowType'; export * from './translateGrowType';
export * from './translateHorizontalAlign'; export * from './translateHorizontalAlign';
export * from './translateLetterSpacing'; export * from './translateLetterSpacing';

View file

@ -0,0 +1,39 @@
export const translateFontWeight = (fontName: FontName): string => {
switch (fontName.style) {
case 'Thin':
case 'Thin Italic':
return '100';
case 'Extra Light':
case 'ExtraLight':
case 'Extra Light Italic':
case 'ExtraLight Italic':
return '200';
case 'Light':
case 'Light Italic':
return '300';
case 'Regular':
case 'Italic':
return '400';
case 'Medium':
case 'Medium Italic':
return '500';
case 'Semi Bold':
case 'SemiBold':
case 'Semi Bold Italic':
case 'SemiBold Italic':
return '600';
case 'Bold':
case 'Bold Italic':
return '700';
case 'ExtraBold':
case 'Extra Bold':
case 'ExtraBold Italic':
case 'Extra Bold Italic':
return '800';
case 'Black':
case 'Black Italic':
return '900';
default:
return '400';
}
};

View file

@ -1,12 +1,12 @@
export const translateLetterSpacing = ( export const translateLetterSpacing = (
segment: Pick<StyledTextSegment, 'letterSpacing' | 'fontSize'> segment: Pick<StyledTextSegment, 'letterSpacing' | 'fontSize'>
): number => { ): string => {
switch (segment.letterSpacing.unit) { switch (segment.letterSpacing.unit) {
case 'PIXELS': case 'PIXELS':
return segment.letterSpacing.value; return segment.letterSpacing.value.toString();
case 'PERCENT': case 'PERCENT':
return (segment.fontSize * segment.letterSpacing.value) / 100; return ((segment.fontSize * segment.letterSpacing.value) / 100).toString();
default: default:
return 0; return '0';
} }
}; };

View file

@ -1,10 +1,12 @@
export const translateLineHeight = ( export const translateLineHeight = (
segment: Pick<StyledTextSegment, 'lineHeight' | 'fontSize'> segment: Pick<StyledTextSegment, 'lineHeight' | 'fontSize'>
): number | undefined => { ): string => {
switch (segment.lineHeight.unit) { switch (segment.lineHeight.unit) {
case 'PIXELS': case 'PIXELS':
return segment.lineHeight.value / segment.fontSize; return (segment.lineHeight.value / segment.fontSize).toString();
case 'PERCENT': case 'PERCENT':
return segment.lineHeight.value / 100; return (segment.lineHeight.value / 100).toString();
default:
return '1.2';
} }
}; };

View file

@ -1,48 +0,0 @@
import { transformFills } from '@plugin/transformers/partials';
import { translateFontId } from '@plugin/translators/text/font';
import { StyleTextSegment, translateParagraphProperties } from '@plugin/translators/text/paragraph';
import {
translateFontStyle,
translateHorizontalAlign,
translateLetterSpacing,
translateLineHeight,
translateTextDecoration,
translateTextTransform
} from '@plugin/translators/text/properties';
import { TextNode as PenpotTextNode, TextStyle } from '@ui/lib/types/shapes/textShape';
export const translateStyleTextSegments = (
node: TextNode,
segments: StyleTextSegment[]
): PenpotTextNode[] => {
const partials = segments.map(segment => ({
textNode: translateStyleTextSegment(node, segment),
segment
}));
return translateParagraphProperties(node, partials);
};
export const transformTextStyle = (node: TextNode, segment: StyleTextSegment): TextStyle => {
return {
...translateFontId(segment.fontName, segment.fontWeight),
fontFamily: segment.fontName.family,
fontSize: segment.fontSize.toString(),
fontStyle: translateFontStyle(segment.fontName.style),
fontWeight: segment.fontWeight.toString(),
textAlign: translateHorizontalAlign(node.textAlignHorizontal),
textDecoration: translateTextDecoration(segment),
textTransform: translateTextTransform(segment),
letterSpacing: translateLetterSpacing(segment),
lineHeight: translateLineHeight(segment)
};
};
const translateStyleTextSegment = (node: TextNode, segment: StyleTextSegment): PenpotTextNode => {
return {
text: segment.characters,
...transformTextStyle(node, segment),
...transformFills(segment)
};
};

View file

@ -0,0 +1,75 @@
import { textLibrary } from '@plugin/TextLibrary';
import { transformFills } from '@plugin/transformers/partials';
import { translateFontName } from '@plugin/translators/text/font';
import { TextSegment, translateParagraphProperties } from '@plugin/translators/text/paragraph';
import {
translateFontStyle,
translateHorizontalAlign,
translateLetterSpacing,
translateLineHeight,
translateTextDecoration,
translateTextTransform
} from '@plugin/translators/text/properties';
import { TextNode as PenpotTextNode, TextStyle } from '@ui/lib/types/shapes/textShape';
export const translateTextSegments = (
node: TextNode,
segments: TextSegment[]
): PenpotTextNode[] => {
const partials = segments.map(segment => ({
textNode: translateStyleTextSegment(node, segment),
segment
}));
return translateParagraphProperties(node, partials);
};
export const transformTextStyle = (node: TextNode, segment: TextSegment): TextStyle => {
if (hasTextStyle(segment)) {
return {
...partialTransformTextStyle(node, segment),
textStyleId: translateTextStyleId(segment.textStyleId)
};
}
return {
...partialTransformTextStyle(node, segment),
fontFamily: segment.fontName.family,
fontSize: segment.fontSize.toString(),
fontStyle: translateFontStyle(segment.fontName.style),
textDecoration: translateTextDecoration(segment),
letterSpacing: translateLetterSpacing(segment),
lineHeight: translateLineHeight(segment),
textTransform: translateTextTransform(segment)
};
};
const partialTransformTextStyle = (node: TextNode, segment: TextSegment): TextStyle => {
return {
...translateFontName(segment.fontName),
textAlign: translateHorizontalAlign(node.textAlignHorizontal)
};
};
const translateStyleTextSegment = (node: TextNode, segment: TextSegment): PenpotTextNode => {
return {
text: segment.characters,
...transformTextStyle(node, segment),
...transformFills(segment)
};
};
const hasTextStyle = (segment: TextSegment): boolean => {
return segment.textStyleId !== undefined && segment.textStyleId.length > 0;
};
const translateTextStyleId = (textStyleId: string | undefined): string | undefined => {
if (textStyleId === undefined) return;
if (!textLibrary.has(textStyleId)) {
textLibrary.register(textStyleId);
}
return textStyleId;
};

View file

@ -45,6 +45,15 @@ const stepMessages: Record<Steps, Messages> = {
exporting: { exporting: {
total: 'Generating Penpot file 🚀', total: 'Generating Penpot file 🚀',
current: 'Please wait, this process might take a while...' current: 'Please wait, this process might take a while...'
},
typographies: {
total: 'text styles fetched 📝'
},
typoFormat: {
total: 'formatting text styles 📝'
},
typoLibraries: {
total: 'text styles built 📝'
} }
}; };
@ -73,6 +82,9 @@ const StepProgress = (): JSX.Element | null => {
case 'components': case 'components':
case 'format': case 'format':
case 'libraries': case 'libraries':
case 'typographies':
case 'typoFormat':
case 'typoLibraries':
return ( return (
<> <>
{processedItems} of {totalItems} {stepMessages[step].total} {processedItems} of {totalItems} {stepMessages[step].total}

View file

@ -29,7 +29,10 @@ export type Steps =
| 'exporting' | 'exporting'
| 'fills' | 'fills'
| 'format' | 'format'
| 'libraries'; | 'libraries'
| 'typographies'
| 'typoFormat'
| 'typoLibraries';
export const useFigma = (): UseFigmaHook => { export const useFigma = (): UseFigmaHook => {
const [missingFonts, setMissingFonts] = useState<string[]>(); const [missingFonts, setMissingFonts] = useState<string[]>();

View file

@ -8,6 +8,7 @@ import { PathShape } from '@ui/lib/types/shapes/pathShape';
import { RectShape } from '@ui/lib/types/shapes/rectShape'; import { RectShape } from '@ui/lib/types/shapes/rectShape';
import { TextShape } from '@ui/lib/types/shapes/textShape'; import { TextShape } from '@ui/lib/types/shapes/textShape';
import { Color } from '@ui/lib/types/utils/color'; import { Color } from '@ui/lib/types/utils/color';
import { Typography } from '@ui/lib/types/utils/typography';
import { Uuid } from '@ui/lib/types/utils/uuid'; import { Uuid } from '@ui/lib/types/utils/uuid';
export interface PenpotFile { export interface PenpotFile {
@ -24,18 +25,11 @@ export interface PenpotFile {
createPath(path: PathShape): Uuid; createPath(path: PathShape): Uuid;
createText(options: TextShape): Uuid; createText(options: TextShape): Uuid;
addLibraryColor(color: Color): void; addLibraryColor(color: Color): void;
updateLibraryColor(color: Color): void; addLibraryTypography(typography: Typography): void;
deleteLibraryColor(color: Color): void;
// addLibraryTypography(typography: any): void;
// deleteLibraryTypography(typography: any): void;
startComponent(component: ComponentShape): Uuid; startComponent(component: ComponentShape): Uuid;
finishComponent(): void; finishComponent(): void;
// lookupShape(shapeId: string): PenpotNode;
// updateObject(id: string, object: any): void;
// deleteObject(id: string): void;
getId(): Uuid; getId(): Uuid;
getCurrentPageId(): Uuid; getCurrentPageId(): Uuid;
newId(): Uuid; newId(): Uuid;
// asMap(): unknown;
export(): Promise<Blob>; export(): Promise<Blob>;
} }

View file

@ -5,6 +5,7 @@ import {
ShapeGeomAttributes ShapeGeomAttributes
} from '@ui/lib/types/shapes/shape'; } from '@ui/lib/types/shapes/shape';
import { Fill } from '@ui/lib/types/utils/fill'; import { Fill } from '@ui/lib/types/utils/fill';
import { Typography } from '@ui/lib/types/utils/typography';
export type TextShape = ShapeBaseAttributes & export type TextShape = ShapeBaseAttributes &
ShapeGeomAttributes & ShapeGeomAttributes &
@ -45,25 +46,35 @@ export type TextNode = {
key?: string; key?: string;
} & TextStyle; } & TextStyle;
export type TextStyle = FontId & { export type TextStyle = TextTypography & {
fontFamily?: string;
fontSize?: string;
fontStyle?: TextFontStyle;
fontWeight?: string;
textDecoration?: string; textDecoration?: string;
textTransform?: string;
direction?: string; direction?: string;
typographyRefId?: string; typographyRefId?: string;
typographyRefFile?: string; typographyRefFile?: string;
lineHeight?: number;
letterSpacing?: number;
textAlign?: TextHorizontalAlign; textAlign?: TextHorizontalAlign;
textDirection?: 'ltr' | 'rtl' | 'auto'; textDirection?: 'ltr' | 'rtl' | 'auto';
fills?: Fill[]; fills?: Fill[];
fillStyleId?: string; // @TODO: move to any other place fillStyleId?: string; // @TODO: move to any other place
textStyleId?: string; // @TODO: move to any other place
};
export type TextTypography = FontId & {
fontFamily?: string;
fontSize?: string;
fontWeight?: string;
fontStyle?: TextFontStyle;
lineHeight?: string;
letterSpacing?: string;
textTransform?: string;
}; };
export type FontId = { export type FontId = {
fontId?: string; fontId?: string;
fontVariantId?: string; fontVariantId?: string;
}; };
export type TypographyStyle = {
name: string;
textStyle: TextStyle;
typography: Typography;
};

View file

@ -0,0 +1,8 @@
import { TextTypography } from '@ui/lib/types/shapes/textShape';
import { Uuid } from '@ui/lib/types/utils/uuid';
export type Typography = TextTypography & {
id?: Uuid;
name?: string;
path?: string;
};

View file

@ -4,7 +4,12 @@ import { sendMessage } from '@ui/context';
import { PenpotFile } from '@ui/lib/types/penpotFile'; import { PenpotFile } from '@ui/lib/types/penpotFile';
import { PenpotPage } from '@ui/lib/types/penpotPage'; import { PenpotPage } from '@ui/lib/types/penpotPage';
import { idLibrary } from '@ui/parser'; import { idLibrary } from '@ui/parser';
import { createColorsLibrary, createComponentsLibrary, createPage } from '@ui/parser/creators'; import {
createColorsLibrary,
createComponentsLibrary,
createPage,
createTextLibrary
} from '@ui/parser/creators';
import { uiComponents } from '@ui/parser/libraries'; import { uiComponents } from '@ui/parser/libraries';
export const buildFile = async (file: PenpotFile, children: PenpotPage[]) => { export const buildFile = async (file: PenpotFile, children: PenpotPage[]) => {
@ -35,6 +40,7 @@ export const buildFile = async (file: PenpotFile, children: PenpotPage[]) => {
} }
await createColorsLibrary(file); await createColorsLibrary(file);
await createTextLibrary(file);
await createComponentsLibrary(file); await createComponentsLibrary(file);

View file

@ -1,7 +1,8 @@
import { PenpotFile } from '@ui/lib/types/penpotFile'; import { PenpotFile } from '@ui/lib/types/penpotFile';
import { TextContent, TextShape } from '@ui/lib/types/shapes/textShape'; import { Paragraph, TextContent, TextNode, TextShape } from '@ui/lib/types/shapes/textShape';
import { parseFigmaId } from '@ui/parser'; import { parseFigmaId } from '@ui/parser';
import { symbolFills, symbolStrokes } from '@ui/parser/creators/symbols'; import { symbolFills, symbolStrokes } from '@ui/parser/creators/symbols';
import { uiTextLibraries } from '@ui/parser/libraries/UiTextLibraries';
export const createText = ( export const createText = (
file: PenpotFile, file: PenpotFile,
@ -18,15 +19,31 @@ export const createText = (
const parseContent = (content: TextContent | undefined): TextContent | undefined => { const parseContent = (content: TextContent | undefined): TextContent | undefined => {
if (!content) return; if (!content) return;
content.children?.forEach(paragraphSet => { content.children = content.children?.map(paragraphSet => {
paragraphSet.children.forEach(paragraph => { paragraphSet.children = paragraphSet.children.map(paragraph => {
paragraph.children.forEach(textNode => { paragraph.children = paragraph.children.map(textNode => {
textNode.fills = symbolFills(textNode.fillStyleId, textNode.fills); return parseTextStyle(textNode, textNode.textStyleId) as TextNode;
}); });
return parseTextStyle(paragraph, paragraph.textStyleId) as Paragraph;
paragraph.fills = symbolFills(paragraph.fillStyleId, paragraph.fills);
}); });
return paragraphSet;
}); });
return content; return content;
}; };
const parseTextStyle = (text: Paragraph | TextNode, textStyleId?: string): Paragraph | TextNode => {
let textStyle = text;
textStyle.fills = symbolFills(text.fillStyleId, text.fills);
const libraryStyle = textStyleId ? uiTextLibraries.get(textStyleId) : undefined;
if (libraryStyle) {
textStyle = {
...libraryStyle.textStyle,
...textStyle
};
}
return textStyle;
};

View file

@ -0,0 +1,42 @@
import { sleep } from '@plugin/utils/sleep';
import { sendMessage } from '@ui/context';
import { PenpotFile } from '@ui/lib/types/penpotFile';
import { uiTextLibraries } from '@ui/parser/libraries/UiTextLibraries';
export const createTextLibrary = async (file: PenpotFile) => {
let librariesBuilt = 1;
const libraries = uiTextLibraries.all();
sendMessage({
type: 'PROGRESS_TOTAL_ITEMS',
data: libraries.length
});
sendMessage({
type: 'PROGRESS_STEP',
data: 'typoLibraries'
});
for (const library of libraries) {
file.addLibraryTypography({
...library.typography,
fontId: library.textStyle.fontId,
fontVariantId: library.textStyle.fontVariantId,
letterSpacing: library.textStyle.letterSpacing,
fontWeight: library.textStyle.fontWeight,
fontStyle: library.textStyle.fontStyle,
fontFamily: library.textStyle.fontFamily,
fontSize: library.textStyle.fontSize,
textTransform: library.textStyle.textTransform,
lineHeight: library.textStyle.lineHeight
});
sendMessage({
type: 'PROGRESS_PROCESSED_ITEMS',
data: librariesBuilt++
});
await sleep(0);
}
};

View file

@ -12,3 +12,4 @@ export * from './createPage';
export * from './createPath'; export * from './createPath';
export * from './createRectangle'; export * from './createRectangle';
export * from './createText'; export * from './createText';
export * from './createTextLibrary';

View file

@ -0,0 +1,19 @@
import { TypographyStyle } from '@ui/lib/types/shapes/textShape';
class UiTextLibraries {
private libraries: Map<string, TypographyStyle> = new Map();
public register(id: string, textStyle: TypographyStyle) {
this.libraries.set(id, textStyle);
}
public get(id: string): TypographyStyle | undefined {
return this.libraries.get(id);
}
public all(): TypographyStyle[] {
return Array.from(this.libraries.values());
}
}
export const uiTextLibraries = new UiTextLibraries();

View file

@ -5,10 +5,11 @@ import { sleep } from '@plugin/utils/sleep';
import { sendMessage } from '@ui/context'; import { sendMessage } from '@ui/context';
import { createFile } from '@ui/lib/penpot'; import { createFile } from '@ui/lib/penpot';
import { PenpotFile } from '@ui/lib/types/penpotFile'; import { PenpotFile } from '@ui/lib/types/penpotFile';
import { TypographyStyle } from '@ui/lib/types/shapes/textShape';
import { FillStyle } from '@ui/lib/types/utils/fill'; import { FillStyle } from '@ui/lib/types/utils/fill';
import { buildFile } from '@ui/parser/creators'; import { buildFile } from '@ui/parser/creators';
import { uiImages } from '@ui/parser/libraries'; import { uiColorLibraries, uiImages } from '@ui/parser/libraries';
import { uiColorLibraries } from '@ui/parser/libraries'; import { uiTextLibraries } from '@ui/parser/libraries/UiTextLibraries';
import { PenpotDocument } from '@ui/types'; import { PenpotDocument } from '@ui/types';
import { parseImage } from '.'; import { parseImage } from '.';
@ -44,6 +45,43 @@ const optimizeImages = async (images: Record<string, Uint8Array>) => {
} }
}; };
const prepareTypographyLibraries = async (
file: PenpotFile,
styles: Record<string, TypographyStyle>
) => {
const stylesToRegister = Object.entries(styles);
if (stylesToRegister.length === 0) return;
let stylesRegistered = 1;
sendMessage({
type: 'PROGRESS_TOTAL_ITEMS',
data: stylesToRegister.length
});
sendMessage({
type: 'PROGRESS_STEP',
data: 'typoFormat'
});
for (const [key, style] of stylesToRegister) {
const typographyId = file.newId();
style.textStyle.typographyRefId = typographyId;
style.textStyle.typographyRefFile = file.getId();
style.typography.id = typographyId;
uiTextLibraries.register(key, style);
sendMessage({
type: 'PROGRESS_PROCESSED_ITEMS',
data: stylesRegistered++
});
await sleep(0);
}
};
const prepareColorLibraries = async (file: PenpotFile, styles: Record<string, FillStyle>) => { const prepareColorLibraries = async (file: PenpotFile, styles: Record<string, FillStyle>) => {
const stylesToRegister = Object.entries(styles); const stylesToRegister = Object.entries(styles);
@ -86,7 +124,8 @@ export const parse = async ({
children = [], children = [],
components, components,
images, images,
styles styles,
typographies
}: PenpotDocument) => { }: PenpotDocument) => {
componentsLibrary.init(components); componentsLibrary.init(components);
@ -94,6 +133,7 @@ export const parse = async ({
await optimizeImages(images); await optimizeImages(images);
await prepareColorLibraries(file, styles); await prepareColorLibraries(file, styles);
await prepareTypographyLibraries(file, typographies);
return buildFile(file, children); return buildFile(file, children);
}; };

View file

@ -1,5 +1,6 @@
import { PenpotPage } from '@ui/lib/types/penpotPage'; import { PenpotPage } from '@ui/lib/types/penpotPage';
import { ComponentShape } from '@ui/lib/types/shapes/componentShape'; import { ComponentShape } from '@ui/lib/types/shapes/componentShape';
import { TypographyStyle } from '@ui/lib/types/shapes/textShape';
import { FillStyle } from '@ui/lib/types/utils/fill'; import { FillStyle } from '@ui/lib/types/utils/fill';
export type PenpotDocument = { export type PenpotDocument = {
@ -8,4 +9,5 @@ export type PenpotDocument = {
components: Record<string, ComponentShape>; components: Record<string, ComponentShape>;
images: Record<string, Uint8Array>; images: Record<string, Uint8Array>;
styles: Record<string, FillStyle>; styles: Record<string, FillStyle>;
typographies: Record<string, TypographyStyle>;
}; };