psdデータからフォント情報を取得する

[PONOS Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ponos)の23日目の記事です。
前回は[@michel_mayonaka](https://qiita.com/michel_mayonaka)さんでした。

## はじめに
普段はUnity（C#）を使用して開発している私ですが、先日pythonを触る機会がありました。今回はその時に実装したpsdデータからフォント情報を取得する機能のお話です。

## 作ったもの
前述している通り、psdデータからフォント情報を取得する機能です。
試しに以下のpsdデータ（掲載はpng形式）のフォント情報を取得します。
![フォント調査テストデータ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2991131/290fd0df-f720-5af0-138f-fe4c3673c98d.png)
これを解析すると以下のような出力結果が得られます。
```
/Test/FontExtractTest.psd:'UDKakugo_SmallPr6N-B':スマートオブジェクト二重の文字
/FontExtractTest.psd:'KurokaneStd-EB':スマートオブジェクトの文字
/Test/FontExtractTest.psd:'SOT-Kagerou-R':打ち込んだ文字
```
出力は[対象のpsdデータ名:フォント名:実際の文字列]の形式で出力されます。
スマートオブジェクトにも対応することができます。
ちなみにですが、ラスタライズされた文字列はレイヤー自体がテキストレイヤーとして認識されないため、出力することができません。また、Gimpで作成したpsdデータは解析することができません。（実行自体はできますが、出力結果が得られません。）

## 使用したライブラリ
そもそも、psdデータをスクリプトから解析できるのか不明だったため、psdデータについていろいろ調べたところ、[psd-tools](https://psd-tools.readthedocs.io/en/latest/)というライブラリがpsdデータを色々できるとわかったのでこちらを使用しています。

## 実装
ソースコードは以下(処理部分のみ抜粋)です。

```python:FontExtractor
class FontExtractor:

    currentPsdFilePath = ""

    textAssetDataList = list()
    fontList = list()　　　　　
    errorList = list()　　
    
    isError = False

    def Execute(self,path):
        self.isError = False
        self.currentPsdFilePath = path

        try:
            psd = PSDImage.open(path)
            with open(path, 'rb') as f:
                psd = PSDImage.open(f)
                self.Parse(psd.descendants())
        except Exception as e:
            err = str(e)
            errorData = FileErrorData(err,self.currentPsdFilePath)
            self.errorList.append(errorData)
            self.isError = True                

    # 解析する関数
    def Parse(self,layers):
        # ファイルを開くのに失敗していた場合は処理しない
        if self.isError:
           return
         
        for layer in layers:
            if layer.visible == False:
                continue
            if layer.kind == "smartobject":
                if layer.smart_object.is_psd():
                    try:
                        with layer.smart_object.open() as f:
                            psb = PSDImage.open(f)
                            self.Parse(psb.descendants())
                    except Exception as e:
                        err = str(e)
                        errorData = FileErrorData(err,self.currentPsdFilePath)
                        self.errorList.append(errorData)
                        self.isError = True

            elif layer.kind == "group":
                self.Parse(layer.descendants())

            elif layer.kind =="type":
                text = layer.engine_dict['Editor']['Text'].value
                text = layer.text
                fontset = layer.resource_dict['FontSet']
                runlength = layer.engine_dict['StyleRun']['RunLengthArray']
                rundata = layer.engine_dict['StyleRun']['RunArray']

                index = 0
                previousTextData = TextAssetData('','')
                
                for length, style in zip(runlength, rundata):
                    substring = text[index:index + length].replace('\r','').replace('\n','')
                    index += length
                    # 空白なら出力しない
                    if substring == '':
                        continue

                    stylesheet = style['StyleSheet']['StyleSheetData']
                    font = fontset[stylesheet['Font']]
                    fontName = font['Name']

                    if previousTextData.fontName == fontName:
                        previousTextData.textValue += substring
                    else:
                        data = TextAssetData(substring,fontName)
                        self.textAssetDataList.append(data)
                        previousTextData = data

                    if fontName is not self.fontList:
                        self.fontList.append(fontName)

            elif layer.kind == "pixel":
                if layer.is_group():
                    self.Parse(layer.descendants())   

    # 解析したデータをクリアする関数                
    def ClearData(self):
        self.currentPsdFilePath = ""

        self.textAssetDataList.clear()
        self.fontList.clear()
        self.errorList.clear()                 

```
※pythonを触るのはこれが初めてなのでこの書き方の方がいいなどあるかもしれないです。
TextAssetDataはフォント名、文字列を保持するクラス、FileErrorDataはエラー内容、エラーが発生したファイルパスを保持する自作クラスです。

処理内容は引数で受け取ったレイヤー配列をfor文で解析し、レイヤーの種類がtypeの時にフォント情報を取得するようにしています。
種類がtypeレイヤー以外のレイヤー(smartobject,group,pixel)の場合はParse()を内部で呼び出して再帰的に処理するようにして全てのレイヤーを解析するようにしています。

実際にフォント情報を取得している処理は以下です。
```python
text = layer.engine_dict['Editor']['Text'].value
text = layer.text
fontset = layer.resource_dict['FontSet']
runlength = layer.engine_dict['StyleRun']['RunLengthArray']
rundata = layer.engine_dict['StyleRun']['RunArray']

index = 0
previousTextData = TextAssetData('','')
                
for length, style in zip(runlength, rundata):
    substring = text[index:index + length].replace('\r','').replace('\n','')
    index += length
    # 空白なら出力しない
    if substring == '':
        continue

    stylesheet = style['StyleSheet']['StyleSheetData']
    font = fontset[stylesheet['Font']]
    fontName = font['Name']

    if previousTextData.fontName == fontName:
        previousTextData.textValue += substring
    else:
        data = TextAssetData(substring,fontName)
        self.textAssetDataList.append(data)
        previousTextData = data

    if fontName is not self.fontList:
        self.fontList.append(fontName)
```
ここで空白文字が使用されている場合は除外し、フォント情報、文字列を配列に格納して保持するようにしています。

このコードはpsdファイル単体でしか実行できませんが、バッチ処理などをかければフォルダ内に存在するpsdデータ全てに対して解析することもできるので興味のある方はぜひやってみていただければと思います。

## さいごに
今回初めてpythonでコードを書きましたが、意外と書けるもんだなぁと思いました。（ちゃんとした書き方かどうかはわかりませんが...）これを機にC#だけでなく他の言語も書けるようになって知識を深めていきたいと思います。

次回は[@aibausound](https://qiita.com/aibausound)さんです。