前編からの続きです。
psd-tools を利用したスクリプトを実行し、
リッチテキストとして取り出したい PSD (PSB) ファイルを指定すると、HTML+CSS のソースコードと CSV ファイルが作成されるという内容です。
ちなみに、PSD (PSB) ファイルに変更を加えることはありません。
動作環境
- Python 3.5 以上
- psd-tools-1.9.18(インストール手順は前編を参照)
スクリプトの全文
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 ファイルが作成されます
クラス名はテキストレイヤーの名前になります。
ツールを実行する前に、テキストレイヤーの名前を希望のクラス名に変えてください。
<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 個の要素)
'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
から取得しにいく必要があるようです。
'StyleRun': {
'RunArray': [{
'StyleSheet': {
'StyleSheetData': {
'Font': 1,
'FontSet': [{
'Name': 'KozGoPr6N-Heavy' // 0
}, {
'Name': 'KozGoPr6N-Heavy' // 1
}, {
'Name': 'AdobeInvisFont' // 2
}, {
'Name': 'KozGoPr6N-Regular' // 3
}],
上記の例では、1番なので「KozGoPr6N-Heavy」になります。
ところで2番の「AdobeInvisFont」って何でしょうね?
フォントサイズ
Photoshop に表示されているフォントサイズは、変形が適用された後のサイズです。
変形は、テキストレイヤーを選択したときに表示される
下記のようなダイアログまたは、拡大・縮小の操作で適用できます。
変形によりフォントサイズは Photoshop 上で再計算されますが、内部的には元の値が保持されます。
'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」と返すためにはさらに下段にある太字ボタンが押されている(適用中である)必要があります。斜体や下線についても基本的に同様です。
分かりづらい変数名
変数名を決めるのは毎回面倒だなと思っているので、いつも適当になります。
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 使っているので敷居が高い!
と思う人もいると思いますが、使いこなせば多分、強力な相棒になるんじゃないかと思ってます。
コーディング効率化の一助になれば幸いです。