https://www.figma.com/developers/api
Figma 화면을 HTML로 변환하는 업무를 하면서
화면 내에 쓰인 이미지를 파일로 다운로드하는 기능을 구현해야 했는데
사이트는 온통 영어로 되어있지.. api 종류도 여러개지.. 많이 헷갈렸던 기억이 있어서
기억이 더 휘발되기 전에 대략적인 내용을 정리해보려합니다.
여러 엔드포인트 중 저는 GET image 엔드포인트를 썼습니다.
저 같은 경우는 그려진 화면 속 이미지를 추출해야했기 때문에
GET file 로 화면 json 을 받아서 json 의 노드를 돌며 이미지 관련 정보를 변수에 담았습니다.
필요한 정보는 id 와 name 입니다.
정확히는 api 통신을 위해선 id 만 필요하지만,
나중에 파일을 저장할 때 파일 이름으로 쓸 용도로 name 도 모아줍니다.
여기서 주의할 점은,
이미지 id 를 콤마(,) 로 연결해서 String 으로 만들어야 하는데 중간에 공백이 있으면 안된다는 겁니다!
아이디,아이디,아이디
이런 식으로요!
let imgIds = ""; // 다운로드할 img id들을 ,로 연결한 String
let imgNames = []; // 다운로드할 img 노드 이름 모아둔 배열
function addImgList(node) {
if (!imgNames.includes(nodeName)) {
imgNames.push(nodeName);
imgIds += imgIds === "" ? node.id : `,${node.id}`; // , 좌우 띄어쓰기 절대 금지~~ 'a,b,c,d'
}
}
const lowerName = node.name.toLowerCase();
if (lowerName.includes("icon") || lowerName.includes("img")) {
addImgList(node);
}
이미지 노드라는 사실을 json 만으로는 명확히 알기 어렵습니다.
이미지는 vertor값으로 복잡하게 들어오는데,
물론 이러한 vector type 이 있는지 찾아서 이미지인지 판단하는 방법도 있었겠지만
저는 간단하게,
< 노드 이름에 'img' 나 'icon' 을 넣을 것 >
이라는 규칙을 만들었습니다.
▼ 어마무시하게 긴 JSON 이미지 vertor 값 구경하기
{
"id": "I1:149;0:20746;1974:43899",
"name": "icon_share",
"type": "INSTANCE",
"scrollBehavior": "SCROLLS",
"componentPropertyReferences": {
"visible": "왼쪽 아이콘#5031:8"
},
"componentId": "1:27",
"overrides": [
{
"id": "I1:149;0:20746;1974:43899;1862:44113",
"overriddenFields": [
"inheritFillStyleId"
]
},
{
"id": "I1:149;0:20746;1974:43899;1862:44109",
"overriddenFields": [
"inheritFillStyleId"
]
},
{
"id": "I1:149;0:20746;1974:43899;1862:44105",
"overriddenFields": [
"strokes"
]
},
{
"id": "I1:149;0:20746;1974:43899",
"overriddenFields": [
"visible",
"name",
"exportSettings"
]
},
{
"id": "I1:149;0:20746;1974:43899;1862:44106",
"overriddenFields": [
"strokes"
]
},
{
"id": "I1:149;0:20746;1974:43899;1862:44110",
"overriddenFields": [
"inheritFillStyleId"
]
}
],
"children": [
{
"id": "I1:149;0:20746;1974:43899;1862:44104",
"name": "Group 13",
"type": "GROUP",
"scrollBehavior": "SCROLLS",
"children": [
{
"id": "I1:149;0:20746;1974:43899;1862:44105",
"name": "Stroke 9",
"type": "VECTOR",
"scrollBehavior": "SCROLLS",
"blendMode": "PASS_THROUGH",
"fills": [],
"strokes": [
{
"blendMode": "NORMAL",
"type": "SOLID",
"color": {
"r": 0.08235294371843338,
"g": 0.07450980693101883,
"b": 0.10196078568696976,
"a": 1
}
}
],
"strokeWeight": 1,
"strokeAlign": "CENTER",
"strokeCap": "ROUND",
"styles": {
"stroke": "1:118"
},
"absoluteBoundingBox": {
"x": -258.39990234375,
"y": 1167.8499755859375,
"width": 10.857000350952148,
"height": 6.14300012588501
},
"absoluteRenderBounds": {
"x": -258.8999938964844,
"y": 1167.349853515625,
"width": 11.857177734375,
"height": 7.1431884765625
},
"constraints": {
"vertical": "SCALE",
"horizontal": "SCALE"
},
"effects": [],
"interactions": []
},
{
"id": "I1:149;0:20746;1974:43899;1862:44106",
"name": "Stroke 11",
"type": "VECTOR",
"scrollBehavior": "SCROLLS",
"blendMode": "PASS_THROUGH",
"fills": [],
"strokes": [
{
"blendMode": "NORMAL",
"type": "SOLID",
"color": {
"r": 0.08235294371843338,
"g": 0.07450980693101883,
"b": 0.10196078568696976,
"a": 1
}
}
],
"strokeWeight": 1,
"strokeAlign": "CENTER",
"strokeCap": "ROUND",
"styles": {
"stroke": "1:118"
},
"absoluteBoundingBox": {
"x": -258.39990234375,
"y": 1173.99267578125,
"width": 10.857000350952148,
"height": 6.14300012588501
},
"absoluteRenderBounds": {
"x": -258.8999938964844,
"y": 1173.4925537109375,
"width": 11.857177734375,
"height": 7.1431884765625
},
"constraints": {
"vertical": "SCALE",
"horizontal": "SCALE"
},
"effects": [],
"interactions": []
},
{
"id": "I1:149;0:20746;1974:43899;1862:44107",
"name": "Group 3",
"type": "GROUP",
"scrollBehavior": "SCROLLS",
"children": [
{
"id": "I1:149;0:20746;1974:43899;1862:44108",
"name": "Clip 2",
"type": "VECTOR",
"scrollBehavior": "SCROLLS",
"blendMode": "PASS_THROUGH",
"fills": [
{
"blendMode": "NORMAL",
"type": "SOLID",
"color": {
"r": 0,
"g": 0,
"b": 0,
"a": 1
}
}
],
"strokes": [],
"strokeWeight": 0,
"strokeAlign": "CENTER",
"absoluteBoundingBox": {
"x": -250.0428924560547,
"y": 1165.3499755859375,
"width": 4.999899864196777,
"height": 5
},
"absoluteRenderBounds": null,
"constraints": {
"vertical": "SCALE",
"horizontal": "SCALE"
},
"effects": [],
"isMask": true,
"isMaskOutline": true,
"maskType": "VECTOR",
"interactions": []
},
{
"id": "I1:149;0:20746;1974:43899;1862:44109",
"name": "Fill 1",
"type": "VECTOR",
"scrollBehavior": "SCROLLS",
"blendMode": "PASS_THROUGH",
"fills": [
{
"blendMode": "NORMAL",
"type": "SOLID",
"color": {
"r": 0.08235294371843338,
"g": 0.07450980693101883,
"b": 0.10196078568696976,
"a": 1
}
}
],
"strokes": [],
"strokeWeight": 0,
"strokeAlign": "CENTER",
"styles": {
"fill": "1:118"
},
"absoluteBoundingBox": {
"x": -250.0428924560547,
"y": 1165.3499755859375,
"width": 5,
"height": 5
},
"absoluteRenderBounds": {
"x": -250.0428924560547,
"y": 1165.3499755859375,
"width": 4.9998931884765625,
"height": 5
},
"constraints": {
"vertical": "SCALE",
"horizontal": "SCALE"
},
"effects": [],
"interactions": []
}
],
"blendMode": "PASS_THROUGH",
"clipsContent": false,
"background": [
{
"blendMode": "NORMAL",
"visible": false,
"type": "SOLID",
"color": {
"r": 1,
"g": 1,
"b": 1,
"a": 1
}
}
],
"fills": [
{
"blendMode": "NORMAL",
"visible": false,
"type": "SOLID",
"color": {
"r": 1,
"g": 1,
"b": 1,
"a": 1
}
}
],
"strokes": [],
"strokeWeight": 0,
"strokeAlign": "CENTER",
"backgroundColor": {
"r": 0,
"g": 0,
"b": 0,
"a": 0
},
"strokeJoin": "BEVEL",
"absoluteBoundingBox": {
"x": -250.0428924560547,
"y": 1165.3499755859375,
"width": 4.999899864196777,
"height": 5
},
"absoluteRenderBounds": {
"x": -250.0428924560547,
"y": 1165.3499755859375,
"width": 4.999899864196777,
"height": 5
},
"constraints": {
"vertical": "TOP",
"horizontal": "LEFT"
},
"effects": [],
"interactions": []
},
{
"id": "I1:149;0:20746;1974:43899;1862:44110",
"name": "Fill 4",
"type": "VECTOR",
"scrollBehavior": "SCROLLS",
"blendMode": "PASS_THROUGH",
"fills": [
{
"blendMode": "NORMAL",
"type": "SOLID",
"color": {
"r": 0.08235294371843338,
"g": 0.07450980693101883,
"b": 0.10196078568696976,
"a": 1
}
}
],
"strokes": [],
"strokeWeight": 0,
"strokeAlign": "CENTER",
"styles": {
"fill": "1:118"
},
"absoluteBoundingBox": {
"x": -260.8999938964844,
"y": 1171.4927978515625,
"width": 5,
"height": 5
},
"absoluteRenderBounds": {
"x": -260.8999938964844,
"y": 1171.4927978515625,
"width": 5,
"height": 5
},
"constraints": {
"vertical": "SCALE",
"horizontal": "SCALE"
},
"effects": [],
"interactions": []
},
{
"id": "I1:149;0:20746;1974:43899;1862:44111",
"name": "Group 8",
"type": "GROUP",
"scrollBehavior": "SCROLLS",
"children": [
{
"id": "I1:149;0:20746;1974:43899;1862:44112",
"name": "Clip 7",
"type": "VECTOR",
"scrollBehavior": "SCROLLS",
"blendMode": "PASS_THROUGH",
"fills": [
{
"blendMode": "NORMAL",
"type": "SOLID",
"color": {
"r": 0,
"g": 0,
"b": 0,
"a": 1
}
}
],
"strokes": [],
"strokeWeight": 0,
"strokeAlign": "CENTER",
"absoluteBoundingBox": {
"x": -250.0428924560547,
"y": 1177.6356201171875,
"width": 4.999899864196777,
"height": 5
},
"absoluteRenderBounds": null,
"constraints": {
"vertical": "SCALE",
"horizontal": "SCALE"
},
"effects": [],
"isMask": true,
"isMaskOutline": true,
"maskType": "VECTOR",
"interactions": []
},
{
"id": "I1:149;0:20746;1974:43899;1862:44113",
"name": "Fill 6",
"type": "VECTOR",
"scrollBehavior": "SCROLLS",
"blendMode": "PASS_THROUGH",
"fills": [
{
"blendMode": "NORMAL",
"type": "SOLID",
"color": {
"r": 0.08235294371843338,
"g": 0.07450980693101883,
"b": 0.10196078568696976,
"a": 1
}
}
],
"strokes": [],
"strokeWeight": 0,
"strokeAlign": "CENTER",
"styles": {
"fill": "1:118"
},
"absoluteBoundingBox": {
"x": -250.0428924560547,
"y": 1177.6356201171875,
"width": 5,
"height": 5
},
"absoluteRenderBounds": {
"x": -250.0428924560547,
"y": 1177.6356201171875,
"width": 4.9998931884765625,
"height": 5
},
"constraints": {
"vertical": "SCALE",
"horizontal": "SCALE"
},
"effects": [],
"interactions": []
}
],
"blendMode": "PASS_THROUGH",
"clipsContent": false,
"background": [
{
"blendMode": "NORMAL",
"visible": false,
"type": "SOLID",
"color": {
"r": 1,
"g": 1,
"b": 1,
"a": 1
}
}
],
"fills": [
{
"blendMode": "NORMAL",
"visible": false,
"type": "SOLID",
"color": {
"r": 1,
"g": 1,
"b": 1,
"a": 1
}
}
],
"strokes": [],
"strokeWeight": 0,
"strokeAlign": "CENTER",
"backgroundColor": {
"r": 0,
"g": 0,
"b": 0,
"a": 0
},
"strokeJoin": "BEVEL",
"absoluteBoundingBox": {
"x": -250.0428924560547,
"y": 1177.6356201171875,
"width": 4.999899864196777,
"height": 5
},
"absoluteRenderBounds": {
"x": -250.0428924560547,
"y": 1177.6356201171875,
"width": 4.999899864196777,
"height": 5
},
"constraints": {
"vertical": "TOP",
"horizontal": "LEFT"
},
"effects": [],
"interactions": []
}
],
"blendMode": "PASS_THROUGH",
"clipsContent": false,
"background": [
{
"blendMode": "NORMAL",
"visible": false,
"type": "SOLID",
"color": {
"r": 1,
"g": 1,
"b": 1,
"a": 1
}
}
],
"fills": [
{
"blendMode": "NORMAL",
"visible": false,
"type": "SOLID",
"color": {
"r": 1,
"g": 1,
"b": 1,
"a": 1
}
}
],
"strokes": [],
"strokeWeight": 0,
"strokeAlign": "CENTER",
"backgroundColor": {
"r": 0,
"g": 0,
"b": 0,
"a": 0
},
"strokeJoin": "BEVEL",
"absoluteBoundingBox": {
"x": -260.8999938964844,
"y": 1165.3499755859375,
"width": 15.857000350952148,
"height": 17.28569984436035
},
"absoluteRenderBounds": {
"x": -260.8999938964844,
"y": 1165.3499755859375,
"width": 15.857000350952148,
"height": 17.28569984436035
},
"constraints": {
"vertical": "TOP",
"horizontal": "LEFT"
},
"effects": [],
"interactions": []
}
],
"blendMode": "PASS_THROUGH",
"clipsContent": false,
"background": [],
"fills": [],
"strokes": [],
"strokeWeight": 0,
"strokeAlign": "CENTER",
"backgroundColor": {
"r": 0,
"g": 0,
"b": 0,
"a": 0
},
"strokeJoin": "BEVEL",
"absoluteBoundingBox": {
"x": -264.5,
"y": 1162,
"width": 24,
"height": 24
},
"absoluteRenderBounds": {
"x": -264.5,
"y": 1162,
"width": 24,
"height": 24
},
"constraints": {
"vertical": "TOP",
"horizontal": "LEFT"
},
"layoutAlign": "INHERIT",
"layoutGrow": 0,
"layoutSizingHorizontal": "FIXED",
"layoutSizingVertical": "FIXED",
"exportSettings": [
{
"suffix": "",
"format": "PNG",
"constraint": {
"type": "SCALE",
"value": 1
}
}
],
"effects": [],
"interactions": []
},
const url = `https://api.figma.com/v1/images/${fileKey}`;
const params = {
ids: imgIdString,
scale: 4,
format: "png",
};
다운로드 할 이미지의 id, name 을 다 모았다면
GET image 로 요청을 보냅니다.
format 에는 jpg, png, svg, pdf 가 있는데, 저는 png 로 했습니다.
자세한 파라메터 정보는 상단의 FIGMA API 의 공식 문서를 참조해주세요.
{
err: null,
images: {
'I1:149;0:20746;1974:43899': 'https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/~~~~',
'1:162': '~~~',
~~~
}
}
GET image 는 리턴값으로 이미지를 다운로드할 수 있는 링크를 줍니다.
해당 링크를 타고 들어가면 이런 식으로 덜렁 이미지만 뜹니다.
우클릭해서 이미지를 개별로 저장할 순 있지만, 저는 자동으로 저장되길 원하기 때문에 추가 로직이 필요합니다.
제가 시도해본 이미지 다운로드 방식은 두 가지가 있는데요.
첫번째는 fileStream 을 열어서 다운로드 한 방식이고,
두번째는 arraybuffer 로 이미지를 다운로드 한 방식입니다.
stream 방식
try {
// 이미지 다운로드
const response = await axios.get(url, { responseType: "stream" });
const fileStream = fs.createWriteStream(filePath);
// 응답 데이터를 파일스트림으로 전달해 파일로 저장
response.data.pipe(fileStream);
// 결과 처리
return new Promise((resolve, reject) => {
fileStream.on("finish", () => {
console.log(`${filename}.png`);
resolve();
});
fileStream.on("error", (err) => {
console.error("Failed to download image files (Stream):", err);
reject(err);
});
});
} catch (error) {
console.error("Failed during axios request for images downloading:", error);
}
stream 방식은 데이터를 한 번에 로드하지 않고 청크 단위로 처리해서 메모리 효율성이 좋습니다.
데이터가 바로바로 파일로 저장되어 큰 데이터를 메모리로 올릴 필요가 없어서
대용량 파일(비디오, 오디오, 대형 이미지 등)을 다운로드하거나 업로드할 때 적합합니다.
arraybuffer 방식
try {
// 이미지 데이터 다운로드
const response = await axios.get(url, { responseType: 'arraybuffer' });
const newData = Buffer.from(response.data);
// 파일이 변경된 경우에만 저장
const saved = await saveFileIfChanged(filePath, newData);
if (saved.success) {
console.log(`${filename}.png`);
}
} catch (error) {
console.error('Failed to download image files (arraybuffer):', error);
}
arraybuffer 방식은 데이터를 메모리에 한 번에 로드한 후 buffer 로 변환하여 파일을 저장합니다.
때문에 메모리 사용량이 크지만, 저장 전에 데이터를 조작하거나 변환작업을 수행할 수 있다는 장점이 있습니다.
stream 방식으로 이미지를 다운받는 방식이 빠르고 간편합니다.
그러나 수십개의 화면을 여러번 다운받을 경우, 똑같은 파일을 중복으로 다운받는 일이 생겨 효율적이지 못합니다.
그리고 저는 변경된 화면을 앞단에 알려줘야 해서
(변경된 화면만 다운받는 api 는 찾지 못했습니다 ㅜㅜ)
중간에 중복 비교하는 로직을 넣을 필요가 있었습니다.
그래서 처음 파일을 내려받을 땐 stream 방식으로 한번에 내려받고
이후에는 arraybuffer 방식으로 데이터를 불러와 중간에 중복검사를 하고 통과되지 못한 화면만 다운로드 되도록 했습니다.
이렇게 하면
이렇게 실제 파일로 이미지를 얻을 수 있습니다.
짜잔~