Figma Inside - .fig 파일 이란?

easylogic·2024년 9월 30일
post-thumbnail

이 글은 Figma 전 CTO evan wallace 가 만든 kiwi 라는 라이브러리를 사용해서 Figma 파일을 분석하는 글입니다.
현재 Figma는 공식적으로 이 라이브러리를 사용하고 있지 않습니다.
하지만 Figma 파일의 구조를 이해하는데 큰 도움이 되었습니다.

다루는 여러가지 함수나 로직은 https://madebyevan.com/figma/fig-file-parser/ 의 소스를 분석해서 재구성했습니다.

.fig 파일은 미스터리?

여러분은 혹시 .fig 파일을 열어본 적이 있으신가요? 아마도 대부분의 답변은 '아니오'일 것입니다.

전통적인 디자인 도구들과 달리, Figma는 클라우드 기반의 동시 편집 시스템을 채택했습니다. 이것은 우리가 익숙했던 '디자인 파일 저장'의 개념을 완전히 바꿔놓았습니다.

그 결과 .fig 파일은 우리가 알던 그 어떤 파일과도 다른 형태를 띠게 되었습니다. 기존에 원본 데이타로써 쓰던 파일 저장 개념을 가질 수 없게 되었습니다. 그럼 데이타를 어떻게 관리 해야하는걸까요?

아직까지 저장개념으로 백업파일을 요구하는 곳이 생각보다 많은데요.
전혀 백업의 개념으로 사용할 수가 없게 됩니다.

그리고 또 하나 문제가 생겼는데요. .fig 의 포맷이 공개되어 있지 않기 때문에 파일을 열어볼 수 없습니다. .fig 를 import 하게 되면 완전히 새로운 figma 파일안에서 이루어지기 때문에 전혀 다른 데이타가 되어 버립니다.

그렇다면 왜 우리는 이 파일을 직접 열어볼 수 없는 걸까요? 그 안에는 어떤 정보가 담겨 있을까요?

Figma의 파일 구조

Figma 파일은 binary 형태로 저장이 되어 있어서 우리가 직접 열어볼 수 없습니다. 하지만 Figma 파일의 구조를 이해하면 Figma를 더 잘 이해할 수 있습니다.

아래의 순서대로 하나씩 파일을 분석해보겠습니다.

  1. 파일 헤더 분석
  2. 파일 추출
  3. 압축 해제

파일 헤더 분석

figma 파일은 압축된 바이너리 형태로 저장이 됩니다.

파일을 로드 한 이후에 파일의 첫 두 바이트를 확인해보면 아래와 같은 형식을 확인할 수 있습니다.

header 가 PK 인지 확인합니다.

const zipHeader = String.fromCharCode(...arrayBuffer.slice(0, 2));
let fileEntries = [];

if (zipHeader === 'PK') {
  // ZIP 파일인 경우
  const zipReader = new zip.ZipReader(new zip.Uint8ArrayReader(new Uint8Array(arrayBuffer)));
  const entries = await zipReader.getEntries();
  fileEntries = entries.filter((entry) => !entry.directory);
} else {
  // 단일 파일인 경우
  fileEntries = [{ filename: file.name, getData: async () => new Uint8Array(arrayBuffer) }];
}

PK 인 경우는 ZIP 파일로 압축이 되어 있다는 뜻입니다.
@zip.js/zip.js 라이브러리를 사용하여 압축을 해제합니다.

zip 으로 압축 풀어져서 unzip 해도 그냥 풀리네요.

Archive: scripts/sample-design.fig
extracting: canvas.fig
extracting: thumbnail.png
inflating: meta.json
creating: images/
extracting: images/072244895a84d42d6830153fdd427054c3b92e5b
extracting: images/ad81c993cfd7c4df28dc7c20635334de68fb5249
extracting: images/5fa7cc56ad2ffaeb1e88356d208bec30e6f7ecf5
extracting: images/e7b5d4319b7f7037dc01f10637a389730aee63c5
extracting: images/3bc7a75365abd25dbd57e724280b40f26f1483fb

파일 추출

zip 파일에 있는 파일별 entry 를 구했습니다. 이제 해야할 것은 각각의 entry 에서 데이터를 추출하는 것입니다.

Figma 파일 내부는 총 3가지 형태의 entry(파일) 이 존재합니다.

  1. 디자인 데이터
  2. 메타데이터
  3. 그래픽 데이타

디자인 데이터

일반적인 이미지 파일을 저장한다고 보시면 됩니다.

png, jpeg, gif 등의 이미지 파일도 디자인 데이터에 포함됩니다.

이미지 파일들 역시 binary 형태로 저장 되어 있기 때문에, 앞단에서 시그니쳐를 확인하여 이미지 파일인지 아닌지 확인합니다.

const isPNG = String.fromCharCode(...bytes.slice(0, 8)) === String.fromCharCode(137, 80, 78, 71, 13, 10, 26, 10);
const isJPEG = String.fromCharCode(...bytes.slice(0, 2)) === String.fromCharCode(255, 216);
const isGIF = ['GIF87a', 'GIF89a'].includes(String.fromCharCode(...bytes.slice(0, 6)));

if (isPNG || isJPEG || isGIF) {
  const imageType = isPNG ? 'PNG' : isJPEG ? 'JPEG' : 'GIF';
  // 이미지 미리보기 로직 추가
  return;
}

메타데이터

.json 파일은 메타데이터를 담고 있습니다.

if (filename.endsWith('.json')) {
  // TextDecoder를 사용하여 바이트 배열을 문자열로 변환
  const decoder = new TextDecoder('utf-8');
  const jsonString = decoder.decode(bytes);
  const json = JSON.parse(jsonString);

  // JSON 뷰어 로직 추가
  console.log('JSON 파일 내용:', json);
  return;
}

대략 아래와 같은 형태로 이루어져있습니다.

{
  "client_meta": {
    "background_color": {
      "r": 0.9607843160629272,
      "g": 0.9607843160629272,
      "b": 0.9607843160629272,
      "a": 1
    },
    "thumbnail_size": { "width": 400, "height": 154 },
    "render_coordinates": { "x": -443, "y": -195, "width": 3161, "height": 1216 }
  },
  "file_name": "sample-design",
  "developer_related_links": []
}

신기하게 client_meta 라는 항목이 있네요.

그래픽 데이타(Figma 자료구조)

Figma 파일의 헤더를 확인하여 파일 유형을 판단하고 처리합니다.
Figma 파일은 고유한 시그니처를 가지고 있어 이를 통해 파일 유형을 식별할 수 있습니다.

const figHeader = String.fromCharCode(...bytes.slice(0, 8));
if (figHeader === 'fig-kiwi' || figHeader === 'fig-jam.' || figHeader === 'fig-deck') {
  const fileType = figHeader === 'fig-jam.' ? 'FigJam' : figHeader === 'fig-deck' ? 'FigDeck' : 'Figma';
  console.log(`${fileType} 파일 감지됨`);

  return await decodeFile(bytes);
}

파일 앞 8바이트를 확인하여 파일 유형을 판단합니다.

  • fig-kiwi: Figma
  • fig-jam.: FigJam board
  • fig-deck: Slide Deck

현재는 3가지 정도 유형을 가지고 있는 것 같아요. (서비스가 3가지 형태라)
앞으로 더 많은 형태의 파일이 생길 수 있을 것 같습니다.

첫번째 파일헤더가 특이한데요. kiwi 라고 적혀있습니다.

kiwi 는 2021년까지 Figma CTO 를 지낸 Evan Wallace 의 https://github.com/evanw/kiwi 라는 라이브러리입니다.
이분이 esbuild 도 만들었죠.

kiwi 는 Google 의 Protocol Buffer 에서 영감을 받아서 만들어졌습니다. 바이너리 형태로 자료형을 표현하고 관리하기 위해서 만들어졌다고 합니다.

그래서 figma 내부적으로 kiwi 를 사용해서 파일을 저장하고 있습니다.
다만 이건 현재까지의 이야기 일뿐 공식적으로 어떻게 된다는 것이 나와있지 않아서 언제든지 바뀔 수 있습니다.

파일 디코딩

앞에 8 바이트를 읽은 것은 파일을 디코딩 하는 것으로 볼 수 있습니다.
그 다음을 계속 보시죠.

  1. 파일 헤더
const bytes = arrayBuffer;
const header = String.fromCharCode(...bytes.slice(0, 8));
console.log('헤더:', header);

fig-kiwi 가 나왔다고 가정합니다.

  1. 버전 정보 추출

앞에 header 까지는 문자열 데이타로 읽을 수 있지만 기본적으로 binary 는 uint8array 에 가까워서 정확한 자료형을 얻기 위해서는 DataView 를 사용합니다.

const view = new DataView(arrayBuffer.buffer);
const version = view.getUint32(8, true);
console.log('버전:', version);

index 8 부터 Uint32(4 바이트)를 읽어서 버전을 얻습니다.

최근에 저장한 파일에서는 대략 version: 70 정도 나오네요.
뭔지는 모르지만 상당한 버전업이 되어 있다고 보시면 되겠네요.

  1. 디자인 데이타 추출

version 이후의 데이타를 읽어보겠습니다.

참고
Figma 파일에서는 little endian 으로 저장이 되어 있습니다.

const view = new DataView(arrayBuffer);
const chunkLength = view.getUint32(12, true);

그래서 모든 함수에 true 를 넣어서 읽을 예정입니다.

대략적인 구조를 먼저 보시면

[header:8][version:4][schema_chunk_length:4][schema_chunk:schema_chunk_length][data_chunk_length:4][data_chunk:data_chunk_length]

이런 형태로 되어 있습니다.
그래서 바이너리에서 총 4가지 데이타를 구해야합니다.

[schema_chunk_length:4][schema_chunk:schema_chunk_length][data_chunk_length:4][data_chunk:data_chunk_length]

데이타 청크 추출

청크를 구해봅시다.

const chunks = [];
let offset = 12;

while (offset < bytes.length) {
  const chunkLength = view.getUint32(offset, true);
  offset += 4;
  chunks.push(bytes.slice(offset, offset + chunkLength));
  offset += chunkLength;
}

if (chunks.length < 2) throw new Error('청크가 충분하지 않습니다');
  • offset 의 시작은 8(헤더) + 4(버전) 해서 12 부터 시작합니다.
  • 각 청크의 길이를 먼저 읽고, 그 길이만큼의 데이타를 슬라이스하여 chunks 배열에 추가합니다.
  • 추출된 청크들을 배열에 저장합니다.

이렇게 하여 총 2가지 청크를 얻을 수 있습니다.

  • chunks[0] : schema 청크
  • chunks[1] : data 청크

schema 청크는 Figma의 자료구조를 저장하는 kiwi 의 스키마를 저장하고 있습니다.
data 청크는 마찬가지로 kiwi 의 데이타 입니다.

두가지를 조합해서 실제 데이타를 만들어 내게 됩니다.

압축 해제

지금까지 구한 청크들은 모두 압축이 되어 있는 상태입니다.

그래서 압축을 풀어줘야 하는데요.

const uncompressChunk = (chunkBytes) => {
  try {
    return pako.inflateRaw(chunkBytes);
  } catch (err) {
    try {
      // zstd 압축 해제
      return decoder.decode(chunkBytes);
    } catch {
      throw err;
    }
  }
};

const encodedSchema = uncompressChunk(chunks[0]);
const encodedData = uncompressChunk(chunks[1]);

간단하게 함수를 하나 만들어서 처리 해줍니다.
코드를 보시면 2가지 압축 해제 방법을 시도합니다.

  • pako.inflateRaw : DEFLATE 압축 해제
  • decoder.decode : ZSTD 압축 해제

DEFLATE 압축 해제는 표준 압축 알고리즘으로 널리 사용되는 방법입니다.
ZSTD 압축 해제는 빠른 압축 속도와 높은 압축률을 가진 알고리즘입니다.

ZStandard 은 빠른 압축 속도와 높은 압축률을 가진 알고리즘입니다.
페이스북에서 만들었어요. https://github.com/facebook/zstd

스키마 및 데이터 디코딩

앞에서 구한 스키마와 데이터를 디코딩 해줍니다.

// kiwi-schema 라이브러리를 사용하여 바이너리 스키마를 디코딩하고 컴파일합니다.
// decodeBinarySchema: 바이너리 형식의 스키마를 JavaScript 객체로 변환합니다.
// compileSchema: 변환된 스키마 객체를 실제로 사용 가능한 스키마 인스턴스로 컴파일합니다.
const schema = kiwi.compileSchema(kiwi.decodeBinarySchema(encodedSchema));

// 컴파일된 스키마를 사용하여 인코딩된 데이터를 디코딩합니다.
// decodeMessage: 바이너리 데이터를 스키마에 정의된 구조에 맞게 JavaScript 객체로 변환합니다.
// nodeChanges: Figma 문서의 노드 변경 사항을 나타내는 배열입니다.
// blobs: 바이너리 데이터(commands, vectorNetwork)를 포함할 수 있는 객체입니다.
const { nodeChanges, blobs } = schema.decodeMessage(encodedData);

// 디코딩된 데이터의 일부를 콘솔에 출력하여 확인합니다.
console.log('nodeChanges', nodeChanges[0]);
console.log('schema', schema);
// console.log('blobs', blobs);

최종 결과물을 디코딩 하면 아래와 같은 데이타가 나옵니다.

type
nodeChanges <-- 변경 트리
blobs <-- command, vectorNetwork 등의 데이타
sessionID
ackID
pasteID
pasteFileKey
pasteIsPartiallyOutsideEnclosingFrame
pastePageId
isCut
pasteEditorType
publishedAssetGuids
clipboardSelectionRegions

여기까지 보시면 아시겠지만 일반적인 문서 구조가 아닙니다.
nodeChanges 를 통해서 볼 수 있는 문서 구조로 변경을 해야 합니다.

figma 의 문서 구조는 기본적으로 트리인데, nodeChanges 는 배열입니다.
이 배열을 트리로 변경하는 과정이 필요합니다.

nodeChanges 변환

const nodes = new Map();

const orderByPosition = ({ parentIndex: { position: a } }, { parentIndex: { position: b } }) => {
  return (a < b) - (a > b);
};

for (const node of nodeChanges) {
  const { sessionID, localID } = node.guid;
  // figma node 의 고유 아이디는 sessionID 와 localID 로 구성되어 있습니다.
  // 이 아이디를 통해서 노드를 찾을 수 있습니다.
  nodes.set(`${sessionID}:${localID}`, node);
}

// 노드의 부모 관계를 설정합니다.
// 노드의 부모 관계는 parentIndex 를 통해서 알 수 있습니다.
for (const node of nodeChanges) {
  if (node.parentIndex) {
    const { sessionID, localID } = node.parentIndex.guid;
    const parent = nodes.get(`${sessionID}:${localID}`);
    if (parent) {
      parent.children ||= [];
      parent.children.push(node);
    }
  }
}

// 노드의 자식 노드들을 정렬합니다.
// 노드의 자식들을 정렬하는게 신기하죠?
for (const node of nodeChanges) {
  if (node.children) {
    node.children.sort(orderByPosition);
  }
}

// 자식 관계 설정이 끝났기 때문에 parentIndex 는 삭제해줍니다.
for (const node of nodeChanges) {
  delete node.parentIndex;
}

figma 는 멀티 플레이어 환경을 지원하기 때문에 변환에 민감합니다.
그래서 빠르게 속성이 변환할 필요가 있는데요.
이렇게 노드의 자식들을 정렬하는 것은 비효율적이라고 생각할 수 있지만,
자식을 추가 하거나 삭제 할 때 position 만 넣어주면 되기 때문에 상당히 편하게 사용할 수 있습니다.

node.get('0:0'); // root node

이제 Map 에 지정된 아이디로 노드를 찾을 수 있게 되었습니다.

0:0 은 루트 노드를 의미합니다.

blobs 처리

figma 는 vector 기반 툴이기 때문에 벡터 데이타를 저장할 방법이 필요합니다.
특히 vectorNetwork 라는 복잡한 알고리즘으로 그리기 패스를 관리하고 있기 때문에 저장내용이 많습니다.

그럼 이제 blobs 를 처리 해봅시다.

blobs 의 경우는 2가지 타입이 있는데요.

  1. commands
  2. vectorNetwork

commands 는 SVG 의 Path 데이타 정도로 보시면 됩니다.
vectorNetwork 는 Figma 만의 고유한 패스 표현 데이타입니다.

root 노드부터 순환을 하다가 2가지 속성(commandsBlob, vectorNetworkBlob)을 만나게 되면 아래와 같은 처리를 하게 됩니다.

xxxxBlob 속성은 blobs 의 index 를 가지고 있습니다.

function parseBlob(key, { bytes }) {
  const view = new DataView(bytes.buffer);
  let offset = 0;

  switch (key) {
    case 'commands':
      const path = [];

      while (offset < bytes.length) {
        switch (bytes[offset++]) {
          case 0:
            path.push('Z');
            break;
          case 1:
            if (offset + 8 > bytes.length) return;
            path.push('M', view.getFloat32(offset, true), view.getFloat32(offset + 4, true));
            offset += 8;
            break;
          case 2:
            if (offset + 8 > bytes.length) return;
            path.push('L', view.getFloat32(offset, true), view.getFloat32(offset + 4, true));
            offset += 8;
            break;
          case 3:
            if (offset + 16 > bytes.length) return;
            path.push(
              'Q',
              view.getFloat32(offset, true),
              view.getFloat32(offset + 4, true),
              view.getFloat32(offset + 8, true),
              view.getFloat32(offset + 12, true),
            );
            offset += 16;
            break;
          case 4:
            if (offset + 24 > bytes.length) return;
            path.push(
              'C',
              view.getFloat32(offset, true),
              view.getFloat32(offset + 4, true),
              view.getFloat32(offset + 8, true),
              view.getFloat32(offset + 12, true),
              view.getFloat32(offset + 16, true),
              view.getFloat32(offset + 20, true),
            );
            offset += 24;
            break;
          default:
            return;
        }
      }

      return path;

      break;
    case 'vectorNetwork':
      const vertexCount = view.getUint32(0, true);
      const segmentCount = view.getUint32(4, true);
      const regionCount = view.getUint32(8, true);

      const vertices = [];
      const segments = [];
      const regions = [];

      for (let i = 0; i < vertexCount; i++) {
        if (offset + 8 > bytes.length) break;
        vertices.push({
          styleID: view.getUint32(offset + 0, true),
          x: view.getFloat32(offset + 4, true),
          y: view.getFloat32(offset + 8, true),
        });
        offset += 12;
      }

      for (let i = 0; i < segmentCount; i++) {
        if (offset + 28 > bytes.length) break;
        const startVertex = view.getUint32(offset + 4, true);
        const endVertex = view.getUint32(offset + 16, true);

        if (startVertex >= vertexCount || endVertex >= vertexCount) continue;

        segments.push({
          styleID: view.getUint32(offset + 0, true),
          start: {
            vertex: startVertex,
            dx: view.getFloat32(offset + 8, true),
            dy: view.getFloat32(offset + 12, true),
          },
          end: {
            vertex: endVertex,
            dx: view.getFloat32(offset + 20, true),
            dy: view.getFloat32(offset + 24, true),
          },
        });
        offset += 28;
      }

      for (let i = 0; i < regionCount; i++) {
        if (offset + 8 > bytes.length) break;
        let styleID = view.getUint32(offset, true);
        const windingRule = styleID & 1 ? 'NONZERO' : 'ODD';

        styleID >>= 1;
        const loopCount = view.getUint32(offset + 4, true);
        const loops = [];

        offset += 8;

        for (let i = 0; i < loopCount; i++) {
          if (offset + 4 > bytes.length) break;
          const indexCount = view.getUint32(offset, true);
          const indices = [];
          offset += 4;

          if (offset + indexCount * 4 > bytes.length) return;

          for (let k = 0; k < indexCount; k++) {
            const segment = view.getUint32(offset, true);
            if (segment >= segmentCount) return;
            indices.push(segment);
            offset += 4;
          }

          loops.push({
            segments: indices,
            windingRule,
          });
        }

        regions.push({
          styleID,
          windingRule,
          loops,
        });
      }

      return { vertices, segments, regions };
  }

  return { type, length, data };
}

최종 데이타 구조

{
  "guid": {
    "sessionID": 0,
    "localID": 0
  },
  "phase": "CREATED",
  "type": "DOCUMENT",
  "name": "Document",
  "visible": true,
  "opacity": 1,
  "transform": {
    "m00": 1,
    "m01": 0,
    "m02": 0,
    "m10": 0,
    "m11": 1,
    "m12": 0
  },
  "strokeWeight": 0,
  "strokeAlign": "CENTER",
  "strokeJoin": "BEVEL",
  "documentColorProfile": "SRGB",
  "children": [
    {
      "guid": {
        "sessionID": 0,
        "localID": 2
      },
      "phase": "CREATED",
      "type": "CANVAS",
      "name": "Internal Only Canvas",
      "visible": false,
      "opacity": 1,
      "transform": {
        "m00": 1,
        "m01": 0,
        "m02": 0,
        "m10": 0,
        "m11": 1,
        "m12": 0
      },
      "strokeWeight": 0,
      "strokeAlign": "CENTER",
      "strokeJoin": "BEVEL",
      "internalOnly": true,
      "children": [
        {
          "guid": {
            "sessionID": 209,
            "localID": 145
          },
          "phase": "CREATED",
          "type": "VARIABLE",
          "name": "FrameType",
          "isPublishable": true,
          "version": "209:28",
          "userFacingVersion": "209:28",
          "sortPosition": "~~~?",
          "variableSetID": {
            "guid": {
              "sessionID": 1,
              "localID": 2
            }
          },
          "variableResolvedType": "STRING",
          "variableDataValues": {
            "entries": [
              {
                "modeID": {
                  "sessionID": 1,
                  "localID": 0
                },
                "variableData": {
                  "value": {
                    "textValue": "Default"
                  },
                  "dataType": "STRING",
                  "resolvedDataType": "STRING"
                }
              },
              {
                "modeID": {
                  "sessionID": 209,
                  "localID": 0
                },
                "variableData": {
                  "value": {
                    "textValue": "hover"
                  },
                  "dataType": "STRING",
                  "resolvedDataType": "STRING"
                }
              },
              {
                "modeID": {
                  "sessionID": 209,
                  "localID": 1
                },
                "variableData": {
                  "value": {
                    "textValue": "pressed"
                  },
                  "dataType": "STRING",
                  "resolvedDataType": "STRING"
                }
              }
            ]
          },
          "isSoftDeleted": false
        },
        {
          "guid": {
            "sessionID": 209,
            "localID": 144
          },
          "phase": "CREATED",
          "type": "VARIABLE",
          "name": "smaple-text",
          "isPublishable": true,
          "version": "209:31",
          "userFacingVersion": "209:31",
          "sortPosition": "~~~>",
          "variableSetID": {
            "guid": {
              "sessionID": 1,
              "localID": 2
            }
          },
          "variableResolvedType": "STRING",
          "variableDataValues": {
            "entries": [
              {
                "modeID": {
                  "sessionID": 1,
                  "localID": 0
                },
                "variableData": {
                  "value": {
                    "textValue": "99999"
                  },
                  "dataType": "STRING",
                  "resolvedDataType": "STRING"
                }
              },
              {
                "modeID": {
                  "sessionID": 209,
                  "localID": 0
                },
                "variableData": {
                  "value": {
                    "textValue": "678678"
                  },
                  "dataType": "STRING",
                  "resolvedDataType": "STRING"
                }
              },
              {
                "modeID": {
                  "sessionID": 209,
                  "localID": 1
                },
                "variableData": {
                  "value": {
                    "textValue": "uiouio8678"
                  },
                  "dataType": "STRING",
                  "resolvedDataType": "STRING"
                }
              }
            ]
          },
          "isSoftDeleted": false
        },
        ...
    }
}

내용이 너무 많다 보니 전체를 다 볼 수가 없습니다.
데이타 구조가 figma 플러그인에서 보는 구조와 완전히 일치하지 않습니다.

결론

.fig 파일 데이타를 어떤식으로 볼 수 있는지 대략적으로 알아 보았습니다.
Figma가 어떤 구조를 가지는지 조금은 이해의 폭을 넓힌 것 같습니다.

아직까지는 공식적인 문서가 없어서 유추를 해야하지만, 다음번에는 스키마 구조와 데이타 구조가 어떻게 이루어지는지 살펴보도록 하겠습니다.

데이타를 저장하는 구조와 메모리 상에서 가지고 있는 구조, 실제로 데이타(REST API)로 받을 수 있는 모두가 다른 Figma 를 이해하는 그날까지 ~ 계속 삽질 해야겠습니다.


지금까지 만든 코드들의 전체 소스는 다음과 같습니다.
이 소스는 node 기반으로 동작합니다. (웹에서도 비슷한 방식으로 돌려볼 수 있습니다.)

#! /usr/bin/env node

import { ZSTDDecoder } from 'zstddec';
import fs from 'fs';
import path from 'path';
import pako from 'pako';
import * as zip from '@zip.js/zip.js';
import * as kiwi from 'kiwi-schema';

const decoder = new ZSTDDecoder();
await decoder.init();

const uncompressChunk = (chunkBytes) => {
  try {
    return pako.inflateRaw(chunkBytes);
  } catch (err) {
    try {
      return decoder.decode(chunkBytes);
    } catch {
      throw err;
    }
  }
};

async function decodeFile(arrayBuffer) {
  const bytes = arrayBuffer;

  const header = String.fromCharCode(...bytes.slice(0, 8));
  console.log('헤더:', header);

  const view = new DataView(arrayBuffer.buffer);
  const version = view.getUint32(8, true);
  console.log('버전:', version);

  const chunks = [];
  let offset = 12;

  while (offset < bytes.length) {
    const chunkLength = view.getUint32(offset, true);
    offset += 4;
    chunks.push(bytes.slice(offset, offset + chunkLength));
    offset += chunkLength;
  }

  if (chunks.length < 2) throw new Error('청크가 충분하지 않습니다');

  const encodedSchema = uncompressChunk(chunks[0]);
  const encodedData = uncompressChunk(chunks[1]);

  const decodedSchema = kiwi.decodeBinarySchema(encodedSchema);
  console.log(decodedSchema.definitions[2], encodedData);
  const schema = kiwi.compileSchema(decodedSchema);
  const { nodeChanges, blobs } = schema.decodeMessage(encodedData);

  const nodes = new Map();

  const orderByPosition = ({ parentIndex: { position: a } }, { parentIndex: { position: b } }) => {
    return (a < b) - (a > b);
  };

  for (const node of nodeChanges) {
    const { sessionID, localID } = node.guid;
    nodes.set(`${sessionID}:${localID}`, node);
  }

  for (const node of nodeChanges) {
    if (node.parentIndex) {
      const { sessionID, localID } = node.parentIndex.guid;
      const parent = nodes.get(`${sessionID}:${localID}`);
      if (parent) {
        parent.children ||= [];
        parent.children.push(node);
      }
    }
  }

  for (const node of nodeChanges) {
    if (node.children) {
      node.children.sort(orderByPosition);
    }
  }

  for (const node of nodeChanges) {
    delete node.parentIndex;
  }

  console.log('blobs', blobs[0]);

  fs.writeFileSync('./scripts/sample-design.json', JSON.stringify(nodeChanges, null, 2));
  // console.log('nodeChanges', nodeChanges);
  // console.log('schema', schema);
  // console.log('blobs', blobs);

  console.log('압축 해제된 스키마:', encodedSchema);
  console.log('압축 해제된 데이터:', encodedData);

  return { encodedSchema, encodedData, nodes, nodeChanges, version, root: nodes.get('0:0'), blobs };
}

function parseBlob(key, { bytes }) {
  const view = new DataView(bytes.buffer);
  let offset = 0;

  switch (key) {
    case 'commands':
      const path = [];

      while (offset < bytes.length) {
        switch (bytes[offset++]) {
          case 0:
            path.push('Z');
            break;
          case 1:
            if (offset + 8 > bytes.length) return;
            path.push('M', view.getFloat32(offset, true), view.getFloat32(offset + 4, true));
            offset += 8;
            break;
          case 2:
            if (offset + 8 > bytes.length) return;
            path.push('L', view.getFloat32(offset, true), view.getFloat32(offset + 4, true));
            offset += 8;
            break;
          case 3:
            if (offset + 16 > bytes.length) return;
            path.push(
              'Q',
              view.getFloat32(offset, true),
              view.getFloat32(offset + 4, true),
              view.getFloat32(offset + 8, true),
              view.getFloat32(offset + 12, true),
            );
            offset += 16;
            break;
          case 4:
            if (offset + 24 > bytes.length) return;
            path.push(
              'C',
              view.getFloat32(offset, true),
              view.getFloat32(offset + 4, true),
              view.getFloat32(offset + 8, true),
              view.getFloat32(offset + 12, true),
              view.getFloat32(offset + 16, true),
              view.getFloat32(offset + 20, true),
            );
            offset += 24;
            break;
          default:
            return;
        }
      }

      return path;

      break;
    case 'vectorNetwork':
      const vertexCount = view.getUint32(0, true);
      const segmentCount = view.getUint32(4, true);
      const regionCount = view.getUint32(8, true);

      const vertices = [];
      const segments = [];
      const regions = [];

      for (let i = 0; i < vertexCount; i++) {
        if (offset + 8 > bytes.length) break;
        vertices.push({
          styleID: view.getUint32(offset + 0, true),
          x: view.getFloat32(offset + 4, true),
          y: view.getFloat32(offset + 8, true),
        });
        offset += 12;
      }

      for (let i = 0; i < segmentCount; i++) {
        if (offset + 28 > bytes.length) break;
        const startVertex = view.getUint32(offset + 4, true);
        const endVertex = view.getUint32(offset + 16, true);

        if (startVertex >= vertexCount || endVertex >= vertexCount) continue;

        segments.push({
          styleID: view.getUint32(offset + 0, true),
          start: {
            vertex: startVertex,
            dx: view.getFloat32(offset + 8, true),
            dy: view.getFloat32(offset + 12, true),
          },
          end: {
            vertex: endVertex,
            dx: view.getFloat32(offset + 20, true),
            dy: view.getFloat32(offset + 24, true),
          },
        });
        offset += 28;
      }

      for (let i = 0; i < regionCount; i++) {
        if (offset + 8 > bytes.length) break;
        let styleID = view.getUint32(offset, true);
        const windingRule = styleID & 1 ? 'NONZERO' : 'ODD';

        styleID >>= 1;
        const loopCount = view.getUint32(offset + 4, true);
        const loops = [];

        offset += 8;

        for (let i = 0; i < loopCount; i++) {
          if (offset + 4 > bytes.length) break;
          const indexCount = view.getUint32(offset, true);
          const indices = [];
          offset += 4;

          if (offset + indexCount * 4 > bytes.length) return;

          for (let k = 0; k < indexCount; k++) {
            const segment = view.getUint32(offset, true);
            if (segment >= segmentCount) return;
            indices.push(segment);
            offset += 4;
          }

          loops.push({
            segments: indices,
            windingRule,
          });
        }

        regions.push({
          styleID,
          windingRule,
          loops,
        });
      }

      return { vertices, segments, regions };
  }

  return { type, length, data };
}

function recoverProperty(prop, blobs) {
  if (Array.isArray(prop)) {
    return prop.map((item) => recoverProperty(item, blobs));
  } else if (typeof prop === 'object' && prop !== null) {
    return recoverObject(prop, blobs);
  }
  return prop;
}

function recoverObject(obj, blobs) {
  for (const [key, value] of Object.entries(obj)) {
    if (key.endsWith('Blob')) {
      const blobKey = key.slice(0, -4); // 'Blob' 제거
      const blobData = blobs[value];
      if (blobData) {
        obj[blobKey] = parseBlob(blobKey, blobData);
        delete obj[key]; // 원래의 Blob 키 제거
      }
    } else {
      obj[key] = recoverProperty(value, blobs);
    }
  }
  return obj;
}

// 노드 & blobs 복구 함수
function recoverNode(node, blobs) {
  // 노드 자체를 복구
  recoverObject(node, blobs);

  // 자식 노드 재귀적 처리
  if (node.children) {
    node.children = node.children.map((child) => recoverNode(child, blobs));
  }

  return node;
}

async function showPreview(filename, bytes, originalFilePrefix) {
  // 이미지 파일인 경우 처리
  const isPNG = String.fromCharCode(...bytes.slice(0, 8)) === String.fromCharCode(137, 80, 78, 71, 13, 10, 26, 10);
  const isJPEG = String.fromCharCode(...bytes.slice(0, 2)) === String.fromCharCode(255, 216);
  const isGIF = ['GIF87a', 'GIF89a'].includes(String.fromCharCode(...bytes.slice(0, 6)));

  if (isPNG || isJPEG || isGIF) {
    const imageType = isPNG ? 'PNG' : isJPEG ? 'JPEG' : 'GIF';
    // console.log(`${imageType} 이미지 파일 감지됨`);
    // 이미지 미리보기 로직 추가

    // bytes 그대로 저장하면 됨

    return;
  }

  // JSON 파일인 경우 처리
  if (filename.endsWith('.json')) {
    const json = JSON.parse(new TextDecoder().decode(bytes));
    console.log('JSON 파일 감지됨:', json);
    // JSON 뷰어 로직 추가

    return;
  }

  // Figma 파일인 경우 처리
  const figHeader = String.fromCharCode(...bytes.slice(0, 8));
  console.log('figHeader', figHeader);
  if (figHeader === 'fig-kiwi' || figHeader === 'fig-jam.' || figHeader === 'fig-deck') {
    const fileType = figHeader === 'fig-jam.' ? 'FigJam' : figHeader === 'fig-deck' ? 'FigDeck' : 'Figma';
    console.log(`${fileType} 파일 감지됨`);
    // Figma 데이터 파싱 및 뷰어 로직 추가

    const { encodedSchema, encodedData, version, root, nodeChanges, blobs } = await decodeFile(bytes);

    console.log('version', version);
    const newRoot = recoverNode(root, blobs);

    return newRoot;
  }
}

async function startLoading(files) {
  try {
    if (files.length !== 1) throw new Error('단일 파일만 지원됩니다');

    const file = files[0];
    const originalFilePrefix = file.name.replace(/\.fig$/, '');
    const arrayBuffer = file.arrayBuffer;

    const zipHeader = String.fromCharCode(...arrayBuffer.slice(0, 2));
    let fileEntries = [];

    if (zipHeader === 'PK') {
      // ZIP 파일인 경우
      const zipReader = new zip.ZipReader(new zip.Uint8ArrayReader(new Uint8Array(arrayBuffer)));
      const entries = await zipReader.getEntries();
      fileEntries = entries.filter((entry) => !entry.directory);
    } else {
      // 단일 파일인 경우
      fileEntries = [{ filename: file.name, getData: async () => new Uint8Array(arrayBuffer) }];
    }

    console.log(
      '파일 엔트리:',
      fileEntries.map((entry) => entry.filename),
    );

    // 첫 번째 파일의 내용 로드
    if (fileEntries.length > 0) {
      const firstEntry = fileEntries[0];

      fileEntries.forEach(async (entry) => {
        const bytes = await entry.getData(new zip.Uint8ArrayWriter());
        const node = await showPreview(entry.filename, bytes, originalFilePrefix);
        entry.node = node;
      });
    }

    console.log('파일 디코딩 완료:', originalFilePrefix);
  } catch (err) {
    console.error('오류 발생:', err);
  }
}

class File {
  constructor(arrayBuffer, name) {
    this.arrayBuffer = arrayBuffer;
    this.name = name;
  }
}

// 테스트를 위한 임시 코드
const testFile = new File(await fs.promises.readFile('./scripts/sample-design.fig'), 'sample-design.fig');
await startLoading([testFile]);

곁다리

  • figma wasm 코드를 보면 kiwi 키워드가 보입니다.
  • figma 는 데이타를 전송 할 때도 zstd 로 압축을 합니다.
profile
행복개발자

0개의 댓글