mirror of
https://github.com/penpot/penpot-exporter-figma-plugin.git
synced 2025-01-03 13:20:37 -05:00
Convert Figma nodes with image fills into Penpot images by exporting the node to png
Signed-off-by: Ryan Breen <rbreen@zmags.com>
This commit is contained in:
parent
30b1f3d5b1
commit
cac49c2e9e
3 changed files with 82 additions and 31 deletions
|
@ -30,6 +30,7 @@
|
||||||
"@types/react-dom": "^17.0.9",
|
"@types/react-dom": "^17.0.9",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"crypto-browserify": "^3.12.0",
|
"crypto-browserify": "^3.12.0",
|
||||||
|
"pngjs": "^6.0.0",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dev-utils": "^11.0.4",
|
"react-dev-utils": "^11.0.4",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
|
|
16
src/code.ts
16
src/code.ts
|
@ -53,20 +53,22 @@ function traverse(node): NodeData {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.fills && Array.isArray(node.fills)){
|
if (node.fills && Array.isArray(node.fills)){
|
||||||
for (const paint of node.fills) {
|
|
||||||
if (paint.type === 'IMAGE') {
|
// Find any fill of type image
|
||||||
// Get the (encoded) bytes for this image.
|
const imageFill = node.fills.find(fill => fill.type === "IMAGE");
|
||||||
const image = figma.getImageByHash(paint.imageHash);
|
if (imageFill) {
|
||||||
image.getBytesAsync().then((value) => {
|
// An "image" in Figma is a shape with one or more image fills, potentially blended with other fill
|
||||||
|
// types. Given the complexity of mirroring this exactly in Penpot, which treats images as first-class
|
||||||
|
// objects, we're going to simplify this by exporting this shape as a PNG image.
|
||||||
|
node.exportAsync({format: "PNG"}).then((value) => {
|
||||||
const b64 = figma.base64Encode(value);
|
const b64 = figma.base64Encode(value);
|
||||||
figma.ui.postMessage({type: "IMAGE", data: {
|
figma.ui.postMessage({type: "IMAGE", data: {
|
||||||
imageHash: paint.imageHash,
|
id: node.id,
|
||||||
value: "data:" + detectMimeType(b64) + ";base64," + b64
|
value: "data:" + detectMimeType(b64) + ";base64," + b64
|
||||||
}});
|
}});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type == "TEXT") {
|
if (node.type == "TEXT") {
|
||||||
const styledTextSegments = node.getStyledTextSegments(["fontName", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "fills"]);
|
const styledTextSegments = node.getStyledTextSegments(["fontName", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "fills"]);
|
||||||
|
|
76
src/ui.tsx
76
src/ui.tsx
|
@ -12,12 +12,18 @@ declare function require(path: string): any;
|
||||||
type PenpotExporterProps = {
|
type PenpotExporterProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FigmaImageData = {
|
||||||
|
value: string,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
type PenpotExporterState = {
|
type PenpotExporterState = {
|
||||||
isDebug: boolean,
|
isDebug: boolean,
|
||||||
penpotFileData: string
|
penpotFileData: string
|
||||||
figmaFileData: string
|
figmaFileData: string
|
||||||
figmaRootNode: NodeData
|
figmaRootNode: NodeData
|
||||||
images: { [id: string] : string; };
|
images: { [id: string] : FigmaImageData; };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class PenpotExporter extends React.Component<PenpotExporterProps, PenpotExporterState> {
|
export default class PenpotExporter extends React.Component<PenpotExporterProps, PenpotExporterState> {
|
||||||
|
@ -77,6 +83,7 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
|
||||||
}
|
}
|
||||||
|
|
||||||
translateFill(fill, width, height){
|
translateFill(fill, width, height){
|
||||||
|
|
||||||
if (fill.type === "SOLID"){
|
if (fill.type === "SOLID"){
|
||||||
return this.translateSolidFill(fill);
|
return this.translateSolidFill(fill);
|
||||||
} else if (fill.type === "GRADIENT_LINEAR"){
|
} else if (fill.type === "GRADIENT_LINEAR"){
|
||||||
|
@ -211,17 +218,51 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
|
||||||
}
|
}
|
||||||
|
|
||||||
createPenpotImage(file, node, baseX, baseY, image){
|
createPenpotImage(file, node, baseX, baseY, image){
|
||||||
file.createImage({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: node.width, height: node.height,
|
file.createImage({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: image.width, height: image.height,
|
||||||
metadata: {
|
metadata: {
|
||||||
width: node.width,
|
width: image.width,
|
||||||
height: node.height
|
height: image.height
|
||||||
},
|
},
|
||||||
dataUri: image
|
dataUri: image.value
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
calculateAdjustment(node){
|
||||||
|
// For each child, check whether the X or Y position is less than 0 and less than the
|
||||||
|
// current adjustment.
|
||||||
|
let adjustedX = 0;
|
||||||
|
let adjustedY = 0;
|
||||||
|
for (var child of node.children){
|
||||||
|
if (child.x < adjustedX){
|
||||||
|
adjustedX = child.x;
|
||||||
|
}
|
||||||
|
if (child.y < adjustedY){
|
||||||
|
adjustedY = child.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [adjustedX, adjustedY];
|
||||||
|
}
|
||||||
|
|
||||||
createPenpotItem(file, node, baseX, baseY){
|
createPenpotItem(file, node, baseX, baseY){
|
||||||
if (node.type == "PAGE"){
|
|
||||||
|
// We special-case images because an image in figma is a shape with one or many
|
||||||
|
// image fills. Given that handling images in Penpot is a bit different, we
|
||||||
|
// rasterize a figma shape with any image fills to a PNG and then add it as a single
|
||||||
|
// Penpot image. Implication is that any node that has an image fill will only be
|
||||||
|
// treated as an image, so we skip node type checks.
|
||||||
|
const hasImageFill = node.fills?.some(fill => fill.type === "IMAGE");
|
||||||
|
if (hasImageFill){
|
||||||
|
|
||||||
|
// If the nested frames extended the bounds of the rasterized image, we need to
|
||||||
|
// account for this both in position on the canvas and the calculated width and
|
||||||
|
// height of the image.
|
||||||
|
const [adjustedX, adjustedY] = this.calculateAdjustment(node);
|
||||||
|
const width = node.width + Math.abs(adjustedX);
|
||||||
|
const height = node.height + Math.abs(adjustedY);
|
||||||
|
|
||||||
|
this.createPenpotImage(file, node, baseX + adjustedX, baseY + adjustedY, this.state.images[node.id]);
|
||||||
|
}
|
||||||
|
else if (node.type == "PAGE"){
|
||||||
this.createPenpotPage(file, node);
|
this.createPenpotPage(file, node);
|
||||||
}
|
}
|
||||||
else if (node.type == "FRAME"){
|
else if (node.type == "FRAME"){
|
||||||
|
@ -231,14 +272,8 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
|
||||||
this.createPenpotGroup(file, node,baseX, baseY);
|
this.createPenpotGroup(file, node,baseX, baseY);
|
||||||
}
|
}
|
||||||
else if (node.type == "RECTANGLE"){
|
else if (node.type == "RECTANGLE"){
|
||||||
// Find a fill of type IMAGE (note, a Figma node can have only one IMAGE fill)
|
|
||||||
const imageFill = node.fills.find((fill) => fill.type === "IMAGE");
|
|
||||||
if (imageFill){
|
|
||||||
this.createPenpotImage(file, node, baseX, baseY, this.state.images[imageFill.imageHash]);
|
|
||||||
} else {
|
|
||||||
this.createPenpotRectangle(file, node, baseX, baseY);
|
this.createPenpotRectangle(file, node, baseX, baseY);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if (node.type == "ELLIPSE"){
|
else if (node.type == "ELLIPSE"){
|
||||||
this.createPenpotCircle(file, node, baseX, baseY);
|
this.createPenpotCircle(file, node, baseX, baseY);
|
||||||
}
|
}
|
||||||
|
@ -274,13 +309,26 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
|
||||||
figmaRootNode: event.data.pluginMessage.data}));
|
figmaRootNode: event.data.pluginMessage.data}));
|
||||||
}
|
}
|
||||||
else if (event.data.pluginMessage.type == "IMAGE") {
|
else if (event.data.pluginMessage.type == "IMAGE") {
|
||||||
|
|
||||||
const data = event.data.pluginMessage.data;
|
const data = event.data.pluginMessage.data;
|
||||||
this.setState(state =>
|
const image = document.createElement('img');
|
||||||
|
const thisObj = this;
|
||||||
|
|
||||||
|
image.addEventListener('load', function() {
|
||||||
|
// Get byte array from response
|
||||||
|
thisObj.setState(state =>
|
||||||
{
|
{
|
||||||
state.images[data.imageHash] = data.value;
|
state.images[data.id] = {
|
||||||
|
value: data.value,
|
||||||
|
width: image.naturalWidth,
|
||||||
|
height: image.naturalHeight
|
||||||
|
};
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
image.src = data.value;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue