Skip to content

Commit

Permalink
Find vectors and export the images
Browse files Browse the repository at this point in the history
We will firstly find all the vector nodes. For each vector node, find the first frame node
ancestor. If all nodes under that frame node are only group nodes, vector nodes or text
nodes, we will treat it as a svg drawable and export it.
  • Loading branch information
yiqunw700 committed Sep 19, 2024
1 parent cea2866 commit 2ab1659
Show file tree
Hide file tree
Showing 5 changed files with 292 additions and 11 deletions.
4 changes: 3 additions & 1 deletion support-figma/extended-layout-plugin/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
{ "name": "Generate String Resource", "command": "localization" },
{ "name": "Clear String Resource", "command": "clear-localization" },
{ "name": "Export Images", "command": "export-images" },
{ "name": "Clear Image Resource", "command": "clear-image-res" }
{ "name": "Clear Image Resource", "command": "clear-image-res" },
{ "name": "Export Vectors", "command": "export-vectors" },
{ "name": "Clear Vector Resource", "command": "clear-vector-res" }
]
}
],
Expand Down
11 changes: 11 additions & 0 deletions support-figma/extended-layout-plugin/src/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as Utils from "./utils";
import * as Localization from "./localization-module";
import * as DesignSpecs from "./design-spec-module";
import * as ImageRes from "./image-res-module";
import * as VectorRes from "./vector-res-module";

// Warning component.
interface ClippyWarningRun {
Expand Down Expand Up @@ -564,6 +565,16 @@ if (figma.command === "sync") {
};
} else if (figma.command === "clear-image-res") {
ImageRes.clear();
} else if (figma.command === "export-vectors") {
VectorRes.exportAllVectorsAsync();
figma.ui.onmessage = (msg) => {
if (msg.msg === "show-node") {
Utils.showNode(msg.node);
} else if (msg.msg === "update-vector-res-name") {
}
}
} else if (figma.command === "clear-vector-res") {

} else if (figma.command === "move-plugin-data") {
function movePluginDataWithKey(node: BaseNode, key: string) {
// Read the private plugin data, write to shared
Expand Down
9 changes: 9 additions & 0 deletions support-figma/extended-layout-plugin/src/image-res-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,12 @@ function parseCachedImageHashToResMap(imageHashToResNameData?: string) {
);
return cachedImageHashToResMap;
}

export function loadImageDrawableResNames(): Array<string> {
let imageResNames = new Array<string>();
let cachedHashToResMap = loadExportedImages();
cachedHashToResMap.forEach((resName, _) => imageResNames.push(resName));
let cachedHashToResMapExcluded = loadNonExportedImages();
cachedHashToResMapExcluded.forEach((resName, _) => imageResNames.push(resName));
return imageResNames;
}
104 changes: 94 additions & 10 deletions support-figma/extended-layout-plugin/src/ui.html
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,18 @@
</div>
<!-- END OF IMAGE RES PLUGIN UI -->

<!-- BEGIN OF VECTOR RES PLUGIN UI -->
<div id="vectorExport" class="page page-padding-large">
<div style="border-bottom: solid #000000; font-weight: bold;">Vector Frames:</div>
<div style="font-size: 14; margin-top: 8px;"><li>Vector frames will be exported as png files, supporting ldpi, mdpi, hdpi, xhdpi and xxhdpi densities.</li></div>
<hr/>
<div id="vectorInfo"></div>
<button id="downloadVectorButton" class="styled-button" style="width: 200px; margin-top: 16px;">
Download
</button>
</div>
<!-- END OF VECTOR RES PLUGIN UI -->

<div id="toast"></div>

<script>
Expand Down Expand Up @@ -1341,17 +1353,17 @@

// jpeg and png will be downloaded as png. Gif will be downloaded as gif. Other
// image types are not supported.
async function processImage(imageHash, imageBytes) {
async function processImage(imageBytes) {
const imageType = getImageType(imageBytes);
switch(imageType) {
case 'png':
case 'jpeg':
const fileFormat = 'png';
const canvas = document.createElement('canvas');
canvas.style.marginTop = '8px';
const ctx = canvas.getContext('2d');
const newBytes = await convert(canvas, ctx, imageBytes);
return {element: canvas, outputImgBytes: newBytes, imageFormat: fileFormat};
case 'png':
case 'gif':
default:
const blob = new Blob([imageBytes], { type: `image/${imageType}` });
Expand Down Expand Up @@ -1407,7 +1419,7 @@
resNameElement.appendChild(input);
imageInfo.appendChild(resNameElement);

let {element, outputImgBytes, imageFormat} = await processImage(imageHash, imageBytes);
let {element, outputImgBytes, imageFormat} = await processImage(imageBytes);
imageInfo.appendChild(element);

if (exportedImageHashArray.includes(imageHash)) {
Expand Down Expand Up @@ -1460,8 +1472,7 @@
link.className = 'button button--primary';
link.href = blobURL;
link.download = "drawable.zip"
link.click()
link.setAttribute('download', name + '.zip');
link.click();

parent.postMessage({
pluginMessage: {
Expand All @@ -1472,12 +1483,85 @@
});
}

// Display the vector frames
async function createVectorImageOutput(vectorResArray) {
const vectorInfo = document.getElementById('vectorInfo');
vectorInfo.innerHTML = "";

const vectorResNameMap = new Map(vectorResArray);
const jszip = new JSZip();
for (const [vectorResName, vectorRes] of vectorResNameMap) {
const resNameElement = document.createElement('div');
resNameElement.style.marginTop = '8px';
resNameElement.textContent = 'Image res name: ';
const resName = vectorResName;
const input = document.createElement("input");
input.addEventListener('change', function (evt) {
const newValue = this.value;
parent.postMessage({
pluginMessage: {
msg: 'update-vector-res-name',
resName: newValue,
node: vectorRes["nodeId"],
}
}, '*');
});
input.setAttribute("type", "text");
input.setAttribute("value", resName);
resNameElement.appendChild(input);
vectorInfo.appendChild(resNameElement);

let {element, outputImgBytes, imageFormat} = await processImage(vectorRes["mdpiBytes"]);
vectorInfo.appendChild(element);

const nodeIdElement = document.createElement('div');
nodeIdElement.style.marginTop = '8px';
nodeIdElement.textContent = "Node id: ";
nodeIdElement.appendChild(createNodeIdSpan(vectorRes["nodeId"]));
vectorInfo.appendChild(nodeIdElement);

const divider = document.createElement('hr');
vectorInfo.appendChild(divider);

jszip.file(`drawable/${resName}.${imageFormat}`, vectorRes["ldpiBytes"], {base64: true});
jszip.file(`drawable-mdpi/${resName}.${imageFormat}`, outputImgBytes, {base64: true});
jszip.file(`drawable-hdpi/${resName}.${imageFormat}`, vectorRes["hdpiBytes"], {base64: true});
jszip.file(`drawable-xhdpi/${resName}.${imageFormat}`, vectorRes["xhdpiBytes"], {base64: true});
jszip.file(`drawable-xxhdpi/${resName}.${imageFormat}`, vectorRes["xxhdpiBytes"], {base64: true});
jszip.file(`drawable-xxxhdpi/${resName}.${imageFormat}`, vectorRes["xxxhdpiBytes"], {base64: true});
}

const downloadButton = document.getElementById('downloadVectorButton');

downloadButton.addEventListener('click', ()=>{

jszip.generateAsync({ type: 'blob' })
.then((content) => {
const blobURL = window.URL.createObjectURL(content);
const link = document.createElement('a');
link.className = 'button button--primary';
link.href = blobURL;
link.download = "res.zip";
link.click();

parent.postMessage({
pluginMessage: {
msg: 'close-plugin'
}
}, '*');
});
});

}

jsonInputFile.addEventListener('change', jsonInputFileChanged, false);
stringsXmlInputFile.addEventListener('change', stringsXmlInputFileChanged, false);

// Update the form from a selection change.
window.onmessage = async function (event) {
let msg = event.data.pluginMessage;
// JSZip also posts messages but not plugin messages. Add a check to avoid errors in logging.
if (!msg) return;

if (msg.msg == 'selection-cleared') {
currentSelection = null;
Expand Down Expand Up @@ -1602,10 +1686,8 @@
}
else if (msg.msg == 'meters-selection') {
setMeterData(msg);
}

if (msg.msg == 'localization') {
openPage('localizationOptions')
} else if (msg.msg == 'localization') {
openPage('localizationOptions');
} else if (msg.msg == 'localization-output') {
loadOutputStringData(msg.output);
createOutputStringTable('outputStrings');
Expand All @@ -1618,7 +1700,9 @@
} else if (msg.msg == 'image-export') {
openPage('imageExport');
createImageOutput(msg.imageNodesArray, msg.imageResNameArray, msg.imageBytesArray, msg.exportedImageHashArray);
} else if (msg.msg == 'vector-export') {
openPage('vectorExport');
createVectorImageOutput(msg.vectorResArray, msg.existingImageDrawableResNames);
}

}
</script>
175 changes: 175 additions & 0 deletions support-figma/extended-layout-plugin/src/vector-res-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { loadImageDrawableResNames } from "./image-res-module";
import * as Utils from "./utils";

const IMAGE_REPLACEMENT_RES_NAME = "image_replacement_res_name";

// Node id to vector root(frame node)
const vectorFrames = new Map<string, SceneNode>();
// Res name to VectorRes map.
const vectorResMap = new Map<string, VectorRes>();
// Image res names that vector exported images can not use
var existingImageDrawableResNames : Array<string>;

// ldpi: 0.75x, mdpi: 1x, hdpi: 1.5x, xhdpi: 2x, xxhdpi: 3x, xxxhdpi: 4x according to:
// https://developer.android.com/training/multiscreen/screendensities
// Ignores the 0.5x export option.
interface VectorRes {
resName: string;
nodeId: string;
ldpiBytes: Uint8Array;
mdpiBytes: Uint8Array;
hdpiBytes: Uint8Array;
xhdpiBytes: Uint8Array;
xxhdpiBytes: Uint8Array;
}

export async function exportAllVectorsAsync(){
await figma.loadAllPagesAsync();

vectorFrames.clear();
vectorResMap.clear();

const vectorNodes = figma.root.findAll((node) => node.type === "VECTOR");

for (const vectorNode of vectorNodes) {
const frameAncestor = findFrameAncestor(vectorNode);
if (frameAncestor && isVectorRoot(frameAncestor)) {
vectorFrames.set(frameAncestor.id, frameAncestor);
}
}

existingImageDrawableResNames = loadImageDrawableResNames();
for (const [frameNodeId, frameNode] of vectorFrames) {
const cachedResName = getResName(frameNode);
var resName = cachedResName
? cachedResName
: `ic_${Utils.toSnakeCase(frameNode.name)}`;
var index = 0;
while (vectorResMap.has(resName) || existingImageDrawableResNames.includes(resName)) {
index += 1;
resName = resName + "_" + index;
}

let vectorRes = {
resName: resName,
nodeId: frameNodeId,
ldpiBytes: await frameNode.exportAsync({
format: "PNG",
colorProfile: "SRGB",
constraint: { type: "SCALE", value: 0.75 },
}),
mdpiBytes: await frameNode.exportAsync({
format: "PNG",
colorProfile: "SRGB",
constraint: { type: "SCALE", value: 1 },
}),
hdpiBytes: await frameNode.exportAsync({
format: "PNG",
colorProfile: "SRGB",
constraint: { type: "SCALE", value: 1.5 },
}),
xhdpiBytes: await frameNode.exportAsync({
format: "PNG",
colorProfile: "SRGB",
constraint: { type: "SCALE", value: 2 },
}),
xxhdpiBytes: await frameNode.exportAsync({
format: "PNG",
colorProfile: "SRGB",
constraint: { type: "SCALE", value: 3 },
}),
xxxhdpiBytes: await frameNode.exportAsync({
format: "PNG",
colorProfile: "SRGB",
constraint: { type: "SCALE", value: 4 },
}),
};
vectorResMap.set(resName, vectorRes);
setResName(frameNode, resName);
}

figma.showUI(__html__, { width: 600, height: 600 });
figma.ui.postMessage({
msg: "vector-export",
vectorResArray: Array.from(vectorResMap),
existingImageDrawableResNames: existingImageDrawableResNames,
});
}

function getResName(node: SceneNode): string {
return node.getSharedPluginData(
Utils.SHARED_PLUGIN_NAMESPACE,
IMAGE_REPLACEMENT_RES_NAME
);
}

function setResName(node: SceneNode, resName: string) {
node.setSharedPluginData(
Utils.SHARED_PLUGIN_NAMESPACE,
IMAGE_REPLACEMENT_RES_NAME,
resName
);
}

function findFrameAncestor(node: PageNode | SceneNode): FrameNode | undefined {
if (node.parent?.type === "FRAME") {
return node.parent!!;
}

if (node.parent?.type === "GROUP") {
return findFrameAncestor(node.parent!!);
}

return undefined;
}

function isVectorRoot(frameNode: FrameNode): boolean {
for (const childNode of frameNode.children) {
if (!isVectorOrTextOrVectorGroup(childNode)) {
return false;
}
}
return true;
}

// Returns true if the node is a vector node or a group node with vector as descendants.
function isVectorOrTextOrVectorGroup(node: SceneNode): boolean {
if (node.type === "VECTOR") {
return true;
}
if (node.type === "TEXT") {
return true;
}
if (node.type === "GROUP") {
return isVectorGroup(node);
}
return false;
}

function isVectorGroup(groupNode: GroupNode): boolean {
for (const childNode of groupNode.children) {
if (childNode.type === "GROUP") {
if (!isVectorGroup(childNode)) {
return false;
}
} else if (childNode.type !== "VECTOR" && childNode.type != "TEXT") {
return false;
}
}
return true;
}

0 comments on commit 2ab1659

Please sign in to comment.