UnityでFigma APIを叩いて9スライス画像を取り込む方法の紹介

# はじめに

本記事は、[QualiArts Advent Calender 2024](https://qiita.com/advent-calendar/2024/qualiarts)の11日目の記事になります。

UnityでのUI開発において、9スライスで表現可能な画像については9スライスを活用することがメモリ使用量の節約などの観点で重要です。
9スライスとは、画像の端のサイズを維持しつつをそれ以外を伸縮することで、角丸などの端の見た目を保ったまま画像の大きさを変更することができる技術です。
9スライスを利用するためには、1枚の画像をどのように9つのスライスに分割するかという情報（ボーダー情報）が必要です。

本記事では、Figma APIを使ってFigmaで作成された9スライス画像を取得し、Unityでボーダー情報をセットするまでを自動で行う処理の実装方法を示します。
この仕組みにより、Figma上でデザイナーが作成した9スライス画像を、エンジニアが手作業で設定することなく効率的にUnityプロジェクトに取り込むことができます。
また、Figma上での9スライスの情報が正式な元データとして扱われるため、デザイナーとエンジニアの間での情報の齟齬を防ぐことも期待できます。

本記事の実装が含まれるUnityプロジェクトはGitHub上で公開しているので、全体の実装を見たい方や気軽に試してみたい方は利用して頂ければと思います。

https://github.com/sune2/FigmaNineSliceImporter


# Figma上での9スライス

Figmaでは直接9スライス画像を作成する機能は提供されていませんが、プラグインを使えば9スライス画像を簡単に作成することができます。

例えば、[9-Slice Scaling (New)](https://www.figma.com/community/plugin/1219930483320755221/9-slice-scaling-new)というプラグインを使うと、対象が9つのスライスに分割され、それぞれのレイアウト制約（constraints）がうまく設定された状態にすることができます。

例えば、Figmaで以下のような図形をを作ります。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/576cde0b-d4d3-adff-13ba-b997f466ecbc.png)

この図形を選択した状態で「9-Slice Scaling (New)」を実行すると以下のようなポップアップが開くので、9スライスする位置を指定したうえでConfirmをクリックします。

<img width=400 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/5e3e60d8-b6c3-c81e-6319-e5fd5fc45e08.png>

すると、以下のように図形が9つのレイヤー（パーツ）に分割されます。

<img width=700 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/0f52f2db-940b-ca17-e8c5-48f3d8e4f1c8.png>


Figma上でこのオブジェクトのサイズを変更すると、9つのスライスのうち端のスライスは伸縮せず、中央のスライスが伸縮することが確認できます。

![Dec-09-2024 15-49-10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/7ed85dad-ee67-3587-d2c8-3017dd5da8a5.gif)

今回は、9スライス画像がFigma上でこのような構造になっていることを前提として9スライス画像の取り込みを行います。

# Figma API
[Figma API](https://www.figma.com/developers/api)は、FigmaのデザインデータにアクセスするためのAPIです。
Figma APIを使うことで、Figma上のデザインのレイヤー構造や画像データを取得することが可能です。

自身のFigmaアカウントのトークンを利用することで、自身の権限で読み取り可能なデザインデータであれば、Figma APIを使ってデータを取得できます。

ただし、今回の用途では問題になりませんが、デザインデータの変更や新規作成などの操作ができる機能は提供されていないことには注意が必要です。

## トークンの取得

Figma APIのトークンの取得は、Figmaのホーム画面のアカウントの部分から「設定 > セキュリティ > 個人アクセストークン > 新規トークンを作成」をクリックして行うことができます。
詳しくは、[Figmaの公式ドキュメント](https://www.figma.com/developers/api#access-tokens)を参照してください。

<img width=500 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/e7f053ab-a1ee-4cf1-f1f6-f282d5745f7a.png>

スコープについては、「ファイルのコンテンツ」のみ「読み取りのみ」、他は「アクセスなし」で問題ありません。

# Unityでの実装

それでは本題である、Unity側でFigma APIを叩き9スライス画像を取り込む実装に入ります。

## ScriptableObjectの作成

まず、9スライス画像取得のための設定をScriptableObjectで管理するため、以下のように`FigmaNineSliceImporter`というクラスを作成します。

```csharp:FigmeNineSliceImporter.cs
using UnityEditor;
using UnityEngine;

[CreateAssetMenu]
public class FigmaNineSliceImporter : ScriptableObject
{
    /// <summary>
    /// Figma APIのトークン
    /// </summary>
    public string token;

    /// <summary>
    /// Figmaのファイルキー
    /// </summary>
    public string fileKey;

    /// <summary>
    /// 取り込み対象のレイヤー名の正規表現
    /// </summary>
    public string targetRegex;

    /// <summary>
    /// 出力先のディレクトリ
    /// </summary>
    public DefaultAsset output;
}
```

CreateAssetMenu属性により、Projectビューの右クリックメニューから `FigmaNineSliceImporter` のScriptableObjectを作成できるようになります。

<img width=500 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/df6119db-c309-1514-ace6-5f7fb5435a06.png>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/966449d7-abfb-c51d-bdfe-c731ac1380b7.png)

各設定項目の詳細は以下の通りです。

#### Token
先ほど取得したFigma APIのトークンを指定します。

#### File Key
取り込み対象のデザインファイルのキーを指定します。
Figmaのデザインファイルは以下のようなURLになっており、[ファイルキー]の部分がファイルキーになります。

`https://www.figma.com/design/[ファイルキー]/[ファイル名]?色々`

#### Target Regex
対象のファイルの中で、9スライス画像として取り込むレイヤー名の正規表現を指定します。
例えばデザイン上9スライスの構造を持ったレイヤーには末尾に `_9s` を付けるというルールにしている場合は、 `.*_9s$` という正規表現を指定するとうまく取り込み対象を絞り込めます。

#### Output
画像を取り込むディレクトリを指定します。


## エディタ拡張の作成

### エディタ拡張の基本構造

今回は`FigmaNineSliceImporter`のエディタ拡張として9スライス画像取得の処理を実装します。

```csharp:FigmaNineSliceImporterEditor.cs
[CustomEditor(typeof(FigmaNineSliceImporter))]
public class FigmaNineSliceImporterEditor : Editor
{
    private FigmaNineSliceImporter _importer;
    private Regex _targetNameRegex;

    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        if (GUILayout.Button("Import"))
        {
            _importer = (FigmaNineSliceImporter)target;
            _targetNameRegex = new Regex(_importer.targetRegex);

            Import(); // 次の工程で実装
        }
    }

    // 略
}
```

これでインスペクターにImportボタンが表示され、それをクリックすることで9スライス画像の取り込みを実行できるようになります。

### デザインファイル情報の取得

Figma APIでデザインファイルの情報を取得するためには、[`/v1/files/:key`](https://www.figma.com/developers/api#get-files-endpoint)というエンドポイントを使います。
このAPIを呼び出すURLは `https://api.figma.com/v1/files/:key` となります。
keyは対象のファイルキーです。

Figma APIのトークンはヘッダーにセットして利用します。

```csharp:FigmaNineSliceImporterEditor.cs
    private void Import()
    {
        var url = $"https://api.figma.com/v1/files/{_importer.fileKey}";
        var request = UnityWebRequest.Get(url);
        request.SetRequestHeader("X-FIGMA-TOKEN", _importer.token);
        request.SendWebRequest().completed += _ =>
        {
            if (request.error != null)
            {
                Debug.LogError(request.error);
                Debug.LogError(request.downloadHandler.text);
                return;
            }

            // レスポンスのテキストをFileResponseという型でデシリアライズ
            var response = JsonConvert.DeserializeObject<FileResponse>(request.downloadHandler.text);
            var targetInfoList = new List<TargetInfo>();

            // レスポンスからインポート対象の情報を取得（次の工程で実装）
            BuildTargetInfoList(response.document, targetInfoList);

            if (targetInfoList.Count == 0)
            {
                Debug.Log("対象が見つかりませんでした");
                return;
            }

            // 画像の取得とインポート（次の次の工程で実装）
            ImportImage(targetInfoList);
        };
    }
```

ここで、レスポンスのJSONをデシリアライズするための`FileResponse`クラスは以下のように定義します。

```csharp:FileResponse.cs
[Serializable]
public class FileResponse
{
    public Node document;
}

[Serializable]
public class Node
{
    public string name;
    public Node[] children;
    public string id;
    public string type;
    public LayoutConstraint constraints;
    public Rectangle absoluteRenderBounds;
}

[Serializable]
public class Rectangle
{
    public float x;
    public float y;
    public float width;
    public float height;

    public float MaxX => x + width;
    public float MaxY => y + height;
}

[Serializable]
public class LayoutConstraint
{
    public string horizontal;
    public string vertical;
}
```

FileResponseクラスは、Figma APIのレスポンスのJSON構造に合わせて定義しています。
Nodeは、Figmaのデザインファイルのレイヤーの木構造のノードに対応するクラスです。
どのようなプロパティが存在するかは[ドキュメント](https://www.figma.com/developers/api#get-files-endpoint)を参照してください。
今回の実装では、今回の用途で利用するプロパティだけに絞って定義しています。

またJSONのデシリアライズには、[Newtonsoft Json](https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@2.0/manual/index.html)を使っています。
Newtonsoft JsonはUnityのパッケージマネージャーから `com.unity.nuget.newtonsoft-json` を追加することで利用できます。

### 対象のノードの検出とボーダー情報の取得

ファイル情報には、デザインファイルのレイヤーの木構造が含まれています。
そこから9スライス画像としてインポートする対象のノードの検出と、そのボーダー情報の取得を行います。

対象のノードは、`FigmaNineSliceImporter`の設定で指定された正規表現にマッチするノードです。
ただし、typeが`TEXT`のノードは対象外としています。

ボーダー情報は、対象の子要素を調べ、それらのレイアウト制約と位置から取得します。

対象のノードの情報を保持するための`TargetInfo`クラスを定義し、ノードのIDと名前、ボーダー情報を保持します。

```csharp:FigmaNineSliceImporterEditor.cs
    /// <summary>
    /// インポート対象のターゲットの情報
    /// </summary>
    private class TargetInfo
    {
        /// <summary>
        /// ID
        /// </summary>
        public string id;

        /// <summary>
        /// レイヤー名
        /// </summary>
        public string name;

        /// <summary>
        /// ボーダー
        /// </summary>
        public Vector4 borders;
    }

    /// <summary>
    /// ターゲット情報のリストを構築する
    /// </summary>
    private void BuildTargetInfoList(Node node, List<TargetInfo> result)
    {
        if (node.type == "TEXT") return; // テキストは対象外
        if (_targetNameRegex.IsMatch(node.name))
        {
            result.Add(GetTargetInfo(node));
            return;
        }

        if (node.children == null)
        {
            return;
        }

        foreach (var child in node.children)
        {
            BuildTargetInfoList(child, result);
        }
    }

    /// <summary>
    /// TargetInfoの取得
    /// ボーダーは子要素のレイアウト制約と位置から取得
    /// </summary>
    private TargetInfo GetTargetInfo(Node node)
    {
        var bounds = node.absoluteRenderBounds;
        var left = 0f;
        var top = 0f;
        var right = 0f;
        var bottom = 0f;

        foreach (var child in node.children)
        {
            switch (child.constraints.horizontal)
            {
                case "LEFT":
                    left = Mathf.Max(left, child.absoluteRenderBounds.MaxX - bounds.x);
                    break;
                case "RIGHT":
                    right = Mathf.Max(right, bounds.MaxX - child.absoluteRenderBounds.x);
                    break;
            }

            switch (child.constraints.vertical)
            {
                case "TOP":
                    top = Mathf.Max(top, child.absoluteRenderBounds.MaxY - bounds.y);
                    break;
                case "BOTTOM":
                    bottom = Mathf.Max(bottom, bounds.MaxY - child.absoluteRenderBounds.y);
                    break;
            }
        }

        return new TargetInfo
        {
            id = node.id,
            name = node.name,
            borders = new Vector4(left, top, right, bottom)
        };
    }
```

### 画像データの取得

次は、インポート対象のノードの画像データを取得する処理を実装します。

画像データの取得には、[`/v1/images/:key`](https://www.figma.com/developers/api#get-images-endpoint)というエンドポイントを使います。
このAPIを呼び出すURLは `https://api.figma.com/v1/images/:key` となります。
画像を取得したいノードのIDをコンマ区切りにしてidsというクエリパラメータで指定します。

このAPIを実行すると、指定したノードの画像データを取得するためのURLがレスポンスとして返ってきます。

```csharp:FigmaNineSliceImporterEditor.cs
    private void ImportImage(List<TargetInfo> targetInfoList)
    {
        var idListString = string.Join(",", targetInfoList.Select(targetInfo => targetInfo.id));
        var url = $"https://api.figma.com/v1/images/{_importer.fileKey}?ids={idListString}&format=png";
        var request = UnityWebRequest.Get(url);
        request.SetRequestHeader("X-FIGMA-TOKEN", _importer.token);
        request.SendWebRequest().completed += _ =>
        {
            if (request.error != null)
            {
                Debug.LogError(request.error);
                Debug.LogError(request.downloadHandler.text);
                return;
            }

            var response = JsonConvert.DeserializeObject<ImageResponse>(request.downloadHandler.text);

            foreach (var targetInfo in targetInfoList)
            {
                var imageUrl = response.images[targetInfo.id];
                // 画像のダウンロードとインポート設定の適用（次の工程で実装）
                DownloadImage(imageUrl, targetInfo);
            }
        };
    }
```

ImageResponseクラスは、Figma APIのレスポンスのJSON構造に合わせて以下のように定義しています。

```csharp:ImageResponse.cs
[Serializable]
public class ImageResponse
{
    public Dictionary<string, string> images;
}
```

### 画像のダウンロードとインポート

最後に、Figma APIで取得した画像のURLを使って画像のダウンロードを行い、ボーダー情報を設定します。

```csharp:FigmaNineSliceImporterEditor.cs
    /// <summary>
    /// 画像のダウンロードとインポート設定の適用
    /// </summary>
    private void DownloadImage(string url, TargetInfo targetInfo)
    {
        var request = UnityWebRequest.Get(url);
        request.SendWebRequest().completed += _ =>
        {
            if (request.error != null)
            {
                Debug.LogError(request.error);
                return;
            }

            var bytes = request.downloadHandler.data;
            var directory = AssetDatabase.GetAssetPath(_importer.output);
            var savePath = $"{directory}/{targetInfo.name}.png";
            File.WriteAllBytes(savePath, bytes);
            AssetDatabase.Refresh();
            var importer = (TextureImporter)AssetImporter.GetAtPath(savePath);
            importer.textureType = TextureImporterType.Sprite;
            importer.spriteImportMode = SpriteImportMode.Single;
            importer.spriteBorder = targetInfo.borders;
            importer.SaveAndReimport();
        };
    }
```

TextureImporterのspriteBorderプロパティにボーダー情報を設定することで、適切なボーダー情報を持つ9スライス画像としてインポートしています。
このとき、テクスチャをSingleモードのSpriteとして扱う設定も合わせて行っています。

## 実行結果

実際にUnityエディタ上で`FigmaNineSliceImporter`の設定を行いImportボタンをクリックすると、9スライス画像がうまく取り込めることを確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/90c59880-74d0-3d93-a63d-e62c07642f7d.png)

ただし、今回の実装ではFigmaでの見た目をそのまま画像として取り込むため、上の例のように9スライス画像としては余分な領域が含まれてしまいます。
本来は以下のように引き伸ばす領域のサイズは最小限にしたいです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81323/d1981078-56ee-45fc-16c2-12111158e38e.png)

これに対応するためには、Figma上で取り込む対象をあらかじめ最小限のサイズにしておくか、Unity側で画像を取得した後に余計な部分を自動でカットするような処理を実装すると良いかと思います。

# おわりに

本記事では、Figma APIを使ってFigmaで作成された9スライス画像を取得し、Unityでボーダー情報をセットするまでを自動で行う方法を紹介しました。

今回の実装では、エラーハンドリングや書き出し対象の選定など、ツールとしての使い勝手については最小限の対応にとどめているため、実際のプロジェクトで利用する際にはさらに機能を拡張する必要があるかもしれません。
また、ファイル全体の情報を取得しているため、大規模なデザインファイルの場合は取得に時間がかかる可能性があることにも注意してください。