1
1

More than 1 year has passed since last update.

Photoshop 形式のファイルから RichText を取り出して HTML と CSS に変換する - 後編

Posted at

前編からの続きです。

psd-tools を利用したスクリプトを実行し、
リッチテキストとして取り出したい PSD (PSB) ファイルを指定すると、HTML+CSS のソースコードと CSV ファイルが作成されるという内容です。

ちなみに、PSD (PSB) ファイルに変更を加えることはありません。

動作環境

  • Python 3.5 以上
  • psd-tools-1.9.18(インストール手順は前編を参照)

スクリプトの全文

TextExtructFromPSD.py
import csv
import os

from decimal import Decimal, ROUND_HALF_UP
from psd_tools import PSDImage

# 出力
out_data = ''
out_path = '%s/%s' % (os.getcwd(), 'TextExtractFromPSD.txt')
csv_data = [['Text', 'Aux', 'Font', 'Size', 'HEX', 'A', 'R', 'G', 'B', 'FontWeight', 'FontStyle', 'TextDecoration']]
csv_path = '%s/%s' % (os.getcwd(), 'TextExtractFromPSD.csv')


# CSS プールに追加
def add_style(layer_name, style_dict):

    # 既定値の項目は出力しない
    code  = '\n.%s {\n' % layer_name
    code += '    color: %s;\n' % style_dict['color']
    code += '    font-family: %s;\n' % style_dict['font-family']
    code += '    font-size: %s;\n' % style_dict['font-size']
    if not 'normal' in style_dict['font-style']:
        code += '    font-style: %s;\n' % style_dict['font-style']
    if not 'normal' in style_dict['font-weight']:
        code += '    font-weight: %s;\n' % style_dict['font-weight']
    if not 'none' in style_dict['text-decoration']:
        code += '    text-decoration: %s;\n' % style_dict['text-decoration']
    code += '}\n'
    return code


# CSV に追加
def csv_style(pool_text, css_id, aux_id):
    global csv_data

    # テキストが空の場合は何もしない
    if pool_text == '':
        return

    csv_data.append([
        pool_text.replace('\r', '<br>'),
        aux_id,
        str(css[css_id]['font-family']).replace('\'', ''),
        css[css_id]['font-size'],
        '#%s%s%s' % (
            format(css[css_id]['color_r'], '02x'),
            format(css[css_id]['color_g'], '02x'),
            format(css[css_id]['color_b'], '02x')
        ),
        css[css_id]['color_a'],
        css[css_id]['color_r'],
        css[css_id]['color_g'],
        css[css_id]['color_b'],
        css[css_id]['font-weight'],
        css[css_id]['font-style'],
        css[css_id]['text-decoration']
    ])


# 数値fの小数点以下を正規化し、文字列で返す
def decimal_normalize(f):
    def _remove_exponent(d):
        return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize()
    a = Decimal.normalize(Decimal(str(f)))
    b = _remove_exponent(a)
    return str(b)


# 処理開始

# PSD ファイルを指定
target = input('Target PSD file path: ').strip()

# PSD ファイルが未指定または存在しない場合は終了
if target == '' or not os.path.exists(target):
    exit()

# PSD ファイルを開く
psd = PSDImage.open(target)


# レイヤー毎に処理を行う
for layer in list(psd.descendants()):

    # テキストレイヤーの場合
    if layer.kind == 'type':

        # レイヤー名を取得
        layer_name = layer.name

        # レイヤーの内容(テキスト)を取得
        layer_text = layer.text

        # スタイル辞書を作成
        css = {}
        css_id = 0

        # レイヤーのフォントセット一覧を作成
        fontset_array = []

        # レイヤーのフォントセット一覧を取得
        for FontSet in layer.resource_dict['FontSet']:
            fontset_array.append(FontSet['Name'])

        # 文字列のスタイルを取得
        for RunArray in layer.engine_dict['StyleRun']['RunArray']:

            # スタイル辞書に新規追加
            css[css_id] = {}

            # スタイルシートデータを抽出
            StyleSheetData = RunArray['StyleSheet']['StyleSheetData']

            # 書体
            css[css_id]['font-family'] = fontset_array[StyleSheetData['Font']]

            # フォントサイズ(レイヤーを変形している場合はフォントサイズに反映する)
            transform_yy = 1.0 if layer.transform[3] == 0.0 else layer.transform[3]
            css[css_id]['font-size'] = str(decimal_normalize(round(StyleSheetData['FontSize'] * transform_yy, 2))) + 'px'

            # フォントスタイル(斜体)
            if 'FauxItalic' in StyleSheetData:
                css[css_id]['font-style'] = 'normal' if not StyleSheetData['FauxItalic'] else 'italic'
            else:
                css[css_id]['font-style'] = 'normal'

            # フォントウェイト(太字)
            if 'FauxBold' in StyleSheetData:
                css[css_id]['font-weight'] = 'normal' if not StyleSheetData['FauxBold'] else 'bold'
            else:
                css[css_id]['font-weight'] = 'normal'

            # 下線
            if 'Underline' in StyleSheetData:
                css[css_id]['text-decoration'] = 'none' if not StyleSheetData['Underline'] else 'underline'
            else:
                css[css_id]['text-decoration'] = 'none'

            # 文字色(ex. A:1.0 R:0.0 G:0.0 B:0.0 → A:255 R:0 G:0 B:0)
            css[css_id]['color_a'] = StyleSheetData['FillColor']['Values'][0] * 255
            css[css_id]['color_r'] = StyleSheetData['FillColor']['Values'][1] * 255
            css[css_id]['color_g'] = StyleSheetData['FillColor']['Values'][2] * 255
            css[css_id]['color_b'] = StyleSheetData['FillColor']['Values'][3] * 255
            css[css_id]['color_a'] = int(Decimal(css[css_id]['color_a']).quantize(Decimal('0'), rounding=ROUND_HALF_UP))
            css[css_id]['color_r'] = int(Decimal(css[css_id]['color_r']).quantize(Decimal('0'), rounding=ROUND_HALF_UP))
            css[css_id]['color_g'] = int(Decimal(css[css_id]['color_g']).quantize(Decimal('0'), rounding=ROUND_HALF_UP))
            css[css_id]['color_b'] = int(Decimal(css[css_id]['color_b']).quantize(Decimal('0'), rounding=ROUND_HALF_UP))

            # 文字色が不透明の場合は16進表記(ex. #FFFFFF)
            if css[css_id]['color_a'] == 255:
                css[css_id]['color'] = '#%s%s%s' % (
                    format(css[css_id]['color_r'], '02x'),
                    format(css[css_id]['color_g'], '02x'),
                    format(css[css_id]['color_b'], '02x'),
                )

            # 文字色が不透明ではない場合は10進表記(ex. rgba(255, 255, 255, 0.9) )
            else:
                css[css_id]['color'] = 'rgba(%s, %s, %s, %s)' % (
                    css[css_id]['color_r'],
                    css[css_id]['color_g'],
                    css[css_id]['color_b'],
                    int(css[css_id]['color_a'] / 255))

            # 次のスタイル辞書へ進む
            css_id += 1

        # スタイル辞書の KEY-ID をリセット
        css_id = 0

        pool_text = ''   # テキストプール
        pool_html = ''   # HTML プール
        pool_css = ''    # CSS プール

        aux_id = 1       # 補助タグ番号
        aux_flag = False # 補助タグフラグ

        # 文字列のスタイルそれぞれの長さから HTML と CSS を生成する
        for RunLength in layer.engine_dict['StyleRun']['RunLengthArray']:

            # 最初の文字列のスタイルは全体の基準となる
            if css_id == 0:

                # CSV に追加
                csv_style(layer_text.strip(), css_id, aux_id - 1)

                # RunLength で指定された長さの分だけ文字列を切り取る
                pool_html += layer_text[0:RunLength]
                layer_text = layer_text[RunLength:]

                # CSS プールに追加
                pool_css = add_style(layer_name, css[0])

            else:

                # 1つ前の文字列のスタイルと異なる場合
                if css[css_id - 1] != css[css_id]:

                    # 補助タグのスタイル名を定義
                    style_name = '%s_aux%s' % (layer_name, str(aux_id))

                    # 補助タグが続いているときはタグを閉じる
                    if aux_flag:
                        pool_html += '</span>'
                        aux_flag = False

                        # CSV に追加
                        csv_style(pool_text.strip(), css_id - 1, aux_id - 1)
                        pool_text = ''

                    # 補助タグが閉じられていてかつ、基準となるスタイルと異なる場合
                    # 新しい補助タグを開始する
                    if not aux_flag and css[0] != css[css_id]:
                        pool_html += '<span class="%s">' % style_name
                        aux_id += 1
                        aux_flag = True

                    # RunLength で指定された長さの分だけ文字列を切り取る
                    pool_text += layer_text[0:RunLength] if aux_flag else ''
                    pool_html += layer_text[0:RunLength]
                    layer_text = layer_text[RunLength:]

                    # CSS プールに追加
                    if css[0] != css[css_id]:
                        pool_css += add_style(style_name, css[css_id])

                # 1つ前の文字列のスタイルと同じだった場合
                # 文字列を切り出す処理のみ実行する
                else:

                    # RunLength で指定された長さの分だけ文字列を切り取る
                    pool_text += layer_text[0:RunLength] if aux_flag else ''
                    pool_html += layer_text[0:RunLength]
                    layer_text = layer_text[RunLength:]

            # 次のスタイル辞書へ進む
            css_id += 1

        # 補助タグが続いているときはタグを閉じる
        if aux_flag:
            pool_html += '</span>'

            # CSV に追加
            csv_style(pool_text.strip(), css_id - 1, aux_id - 1)

        # 出力
        out_data += '\n<p class="%s">%s</p>\n\n<style>%s\n</style>\n' % (layer_name, pool_html.replace('\r', '<br>'), pool_css.rstrip())

# txt ファイルに書き込む
with open(out_path, mode='w', newline='') as f:
    f.write(out_data)

# csv ファイルに書き込む
with open(csv_path, mode='w', newline='') as f:
    writer = csv.writer(f)
    writer.writerows(csv_data)

スクリプトの実行

  • コマンドラインにて python3 TextExtructFromPSD.py
  • Target PSD file path: と尋ねられるので PSD (PSB) ファイルのパス名を入力
  • TextExtructFromPSD.py と同じ階層に TextExtructFromPSD.txt と TextExtructFromPSD.csv ファイルが作成されます

クラス名はテキストレイヤーの名前になります。 ツールを実行する前に、テキストレイヤーの名前を希望のクラス名に変えてください。

TextExtructFromPSD.txt

<p class="text1">100本<span class="text1_aux1"></span>解説動画<span class="text1_aux2"></span>6つ<span class="text1_aux3"></span>豪華特典<span class="text1_aux4"><br>さらに講師が</span>あなた<span class="text1_aux5"></span><span class="text1_aux6">徹底</span>サポート!</p>

<style>
.text1 {
    color: #d92367;
    font-family: 'KozGoPr6N-Heavy';
    font-size: 50px;
}

.text1_aux1 {
    color: #d92367;
    font-family: 'KozGoPr6N-Heavy';
    font-size: 40px;
}

.text1_aux2 {
    color: #242424;
    font-family: 'KozGoPr6N-Heavy';
    font-size: 40px;
}

.text1_aux3 {
    color: #d92367;
    font-family: 'KozGoPr6N-Heavy';
    font-size: 40px;
}

.text1_aux4 {
    color: #242424;
    font-family: 'KozGoPr6N-Heavy';
    font-size: 40px;
}

.text1_aux5 {
    color: #d92367;
    font-family: 'KozGoPr6N-Heavy';
    font-size: 40px;
}

.text1_aux6 {
    color: #ff9900;
    font-family: 'KozGoPr6N-Heavy';
    font-size: 50px;
    font-weight: bold;
    text-decoration: underline;
}
</style>

TextExtructFromPSD.csv

Text Aux Font Size HEX A R G B FontWeight FontStyle TextDecoration
100本の解説動画と6つの豪華特典、
さらに講師があなたを徹底サポート!
0 KozGoPr6N-Heavy 50px #d92367 255 217 35 103 normal normal none
1 KozGoPr6N-Heavy 40px #d92367 255 217 35 103 normal normal none
2 KozGoPr6N-Heavy 40px #242424 255 36 36 36 normal normal none
3 KozGoPr6N-Heavy 40px #d92367 255 217 35 103 normal normal none

さらに講師が
4 KozGoPr6N-Heavy 40px #242424 255 36 36 36 normal normal none
5 KozGoPr6N-Heavy 40px #d92367 255 217 35 103 normal normal none
徹底 6 KozGoPr6N-Heavy 50px #ff9900 255 255 153 0 bold normal underline

処理の流れ

PSD (PSB) ファイルを読み込むと、ざっくり以下の流れで処理を行います。

  • 全てのレイヤーを走査し、テキストレイヤーを特定する
  • エンジンデータ(辞書)から文字列の各スタイルを全て取得する
  • 各スタイルの範囲(文字数)が配列になっているので、これを参考に補助タグ(span)を追加する
  • 文字列とスタイルシートをテキストに書き出す

開発メモ

以下は備忘録です。

RunLengthArray

スタイルの出現位置を示している、と思います。多分。
RunLengthArray の長さ分だけ、StyleSheetData が存在します。
(下記の例だと 13 個の要素)

engine-dict
  'Editor': {
    'Text': '100本の解説動画と6つの豪華特典、\rさらに講師があなたを徹底サポート!\r'
  },
  'StyleRun': {
    'RunArray': [{
      'StyleSheet': {
        'StyleSheetData': {
          (スタイルシートのデータ本体)
        }
      }
    }],
    'RunLengthArray': [1, 3, 1, 4, 1, 2, 1, 4, 8, 3, 1, 2, 6],

RunLengthArray[0] = 1 (1)
RunLengthArray[1] = 3 (00本)
RunLengthArray[2] = 1 (の)
RunLengthArray[3] = 4 (解説動画)
RunLengthArray[4] = 1 (と)
RunLengthArray[5] = 2 (6つ)
RunLengthArray[6] = 1 (の)
RunLengthArray[7] = 4 (豪華特典)
RunLengthArray[8] = 8 (、\rさらに講師が)
RunLengthArray[9] = 3 (あなた)
RunLengthArray[10] = 1 (を)
RunLengthArray[11] = 2 (徹底)
RunLengthArray[12] = 6 (サポート!\r)

\r (carriage return) は MacOS9 時代まで使われていた改行で1文字と数えます。

フォントセットは別の辞書から

Font はインデックス番号になっているので、
別途 resource_dict から取得しにいく必要があるようです。

engine-dict
  'StyleRun': {
    'RunArray': [{
      'StyleSheet': {
        'StyleSheetData': {
          'Font': 1,
resource-dict
  'FontSet': [{
    'Name': 'KozGoPr6N-Heavy' // 0
  }, {
    'Name': 'KozGoPr6N-Heavy' // 1
  }, {
    'Name': 'AdobeInvisFont' // 2
  }, {
    'Name': 'KozGoPr6N-Regular' // 3
  }],

上記の例では、1番なので「KozGoPr6N-Heavy」になります。
ところで2番の「AdobeInvisFont」って何でしょうね?

フォントサイズ

Photoshop に表示されているフォントサイズは、変形が適用された後のサイズです。
photoshop01.png

変形は、テキストレイヤーを選択したときに表示される
下記のようなダイアログまたは、拡大・縮小の操作で適用できます。
photoshop02.png

変形によりフォントサイズは Photoshop 上で再計算されますが、内部的には元の値が保持されます。

engine-dict
  'StyleRun': {
    'RunArray': [{
      'StyleSheet': {
        'StyleSheetData': {
          'FontSize': 50.0,

こうした仕様のため、こちらの記事
PhotoshopScriptが糞でTextLayerのfont sizeが正しく取れないのでどうにかした件
で書かれている通り、Photoshop 上のフォントサイズと、スクリプトで取得したフォントサイズにズレが発生します。

この問題を解決するには layer.transform を取得してその値を掛ければ良いわけですが、psd-tools の仕様書を見て (xx, xy, yx, yy, tx, ty) 左のうち、どのマトリックス値を使えばいいのかさっぱりでした。

結果、上記の Qiita 記事にある yy の値を採用したところ、Photoshop 上のフォントサイズと一致しました。yy であることには何か深い理由があるのでしょうが、今のところよく分かりません。

文字装飾

Photoshop で太字っぽく見えても、スクリプトは「normal」で返すことがあります。

下の図の、H の箇所(Heavy スタイル)は太字書体です。
書体は太字ですが、内部的には太字設定ではない文字として認識されています。

スクリプトが「bold」と返すためにはさらに下段にある太字ボタンが押されている(適用中である)必要があります。斜体や下線についても基本的に同様です。
photoshop03.png

分かりづらい変数名

変数名を決めるのは毎回面倒だなと思っているので、いつも適当になります。

css
layer.engine_dict から拾ったデータを辞書型にして格納します。

css_id
辞書型の順序番号です。確か Python3.5 以降は順序が保持されるはずなので、多分 3.5 以前のサポートとして機能するかも?動作確認は 3.9 で行っているため、どうなるかは知りません。

pool_text
CSV 出力用。テキストレイヤーから拾った文字列は、処理の仕様上 HTML に出力するタイミングで削り取ってしまうので、CSV 側に出力するまでは別のところで保持しておきたかったのです。

pool_html
HTML の出力をスタック方式にしたかったので、この変数に溜めておき、書き出しの時点でまとめて out_data に吐き出します。

pool_css
これもスタック方式にしたかったので。

aux_id
p タグの文字列スタイルを途中で変える span タグのことを何と呼べばいいか悩みました。補助タグ(Auxiliary Tag)でいいんじゃね?的な結論になりました。

aux_flag
処理が今、span タグの中にいるのか、外にいるのかのフラグです。
簡単に言うと閉じタグ挿入判定フラグです。

decimal_normalize(f) は何をしている?

50.00px を 50px に直してます。

HTML または CSV の書式とか、ファイル名を変更したいときは?

変数 out_path csv_path csv_data や、
関数 add_style csv_style 辺りを修正すればよいかと思ってます。

Warning: Unknown tagged block b'vowv'

によると、モノによってはちゃんと調べなきゃだけど、基本的に害はないし無視してもいいんじゃね?とのことです。

まとめ

Python 使っているので敷居が高い!
と思う人もいると思いますが、使いこなせば多分、強力な相棒になるんじゃないかと思ってます。

コーディング効率化の一助になれば幸いです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1