デザイナーから受け取ったPSDを開いた瞬間、手が止まりました。1440×13333px、220MB。Android版の壁紙アプリ「綺麗な壁紙」のストア掲載スクリーンショットで、日本語で組まれたキャッチコピーが5枚のアートボードに散らばっています。これを少なくとも10言語、できれば15言語に展開したいところでした。けれど翌朝にはストアの差し替え作業を始めたい、という状況でした。
手作業でPhotoshopのテキストレイヤーを1枚ずつ翻訳して差し替えると、5枚 × 15言語で75画面分。コピー&ペーストの取り違えが必ず起きます。そこでCoworkに、このPSDそのものを渡して機械的に処理させることにしました。
やってみると、翻訳よりもフォントと字形のほうがずっと手強い、という当たり前の現実にぶつかります。この記事は、その一晩の作業ログです。
なぜ「スクショのローカライズ」だけは作品制作と切り離せるのか
最初に自分の中で整理したのは、ここでAIに任せてよいのかという線引きでした。個人開発を続けながら現代美術の作品制作も行っていますが、作品本体には一貫してAIを使っていません。手を動かして作ること自体に意味がある、という感覚があるからです。
一方で、作品やアプリを「世に出す」作業は、制作そのものとは別物です。ストアのスクリーンショットは、壁紙という商品の中身ではなく、それを店頭に並べるための値札やポップに近いものです。だからスクリーンショットの版組みとローカライズは、私の中では迷いなくAIに任せられるデザイナー業務に分類されます。
作品の制作とプロモーションを意識して分けておくと、こういう判断が一瞬で済みます。逆にこの線引きが曖昧なままだと、「どこまで自動化してよいのか」を毎回悩むことになり、手が止まります。
アップロードしたPSDの中身を、まず数える
ローカライズの前に、PSDが何でできているのかを正確に把握します。Coworkの作業環境にはPython版のpsd-toolsが入るので、開いて構造をダンプさせました。
なお、この環境の pip は PEP 668 で保護されているため、そのままでは externally-managed-environment で止まります。この点はCowork の Bash で pip install が externally-managed-environment エラーで止まるとき に詳しくまとめていますので、先に読んでおくと詰まりません。
from psd_tools import PSDImage
psd = PSDImage.open( "screenshot.psd" )
print ( f "canvas: { psd.width } x { psd.height } " )
for layer in psd.descendants():
kind = layer.kind # 'type'(テキスト) / 'pixel' / 'group' など
if kind == "type" :
print ( f "[text] { layer.name !r } @ { layer.bbox } -> { layer.text !r } " )
elif kind == "group" :
print ( f "[group] { layer.name !r } children= { len ( list (layer)) } " )
出力で分かったのは、5枚のアートボードがそれぞれグループになっていて、1枚あたりおよそ40個のレイヤー(うちテキストは見出し・説明の2系統)で構成されていること。
ここで重要なのは、ワークスペースに前からあったPSDとアップロード版で解像度が違った点でした。正本はアップロードされた1440×13333px・220MBのほうなので、抽出も描画もこのファイルから行うようスクリプトを固定しました。受け取ったファイルが複数あるときは、どれが正本かを最初に1回だけ確定させておくと、後の作り直しを防げます。
クラウド同期フォルダを介してPSDを受け渡す場合、bashからはファイルが見えないのに Finder には見える、という現象も起こります。これはCowork の bash が『ファイルがありません』と言うのに Finder には見える理由 で扱いました。220MBのファイルを扱う前に、実体化を済ませておくと安全です。
テキストレイヤーの「名前」に翻訳文そのものを入れる
ローカライズというと、テキストレイヤーの中身(text)を翻訳文に書き換えるのが素直な発想です。実際それもやるのですが、私はそれに加えて、各テキストレイヤーのレイヤー名 を翻訳文そのものに設定しました。
理由は、Photoshopのレイヤーパネルを開いたときに「どの言語のどの文か」が一目で分かるようにするためです。レイヤー名が headline_1 のままだと、字形が表示されない言語では中身が読めません。名前に翻訳文を入れておけば、たとえタイ語のように環境にグリフが無くてラスタライズできない言語でも、パネル上で内容を確認し、コピーして使えます。
手作業のブラッシュアップ前提のPSDを返すなら、この一手間が後工程をいちばん助けます。
言語ごとにグループを作り、グループ名は th_ไทย ko_한국어 のように「言語コード + その言語自身での言語名」にしました。英語名(Korean)ではなく現地語名にしておくと、各言語の担当に渡したときに迷いがありません。
フォントと字形の壁 — タイ語と韓国語でつまずく
ここからが本番です。テキストを書き換えても、その字形を描けるフォントが環境に無ければ、ラスタライズした瞬間に豆腐(□)になります。作業環境はネットワークが制限されていてNotoフォントを取りに行けなかったため、入手済みのフォントでどこまでカバーできるかを先に確認しました。
結論として、タイ語はPSDにラスタ配置せず、レイヤー名(=翻訳文)とExcel表での受け渡しに留める 判断をしました。最終的に、ラテン系15言語とアラビア語・中国語は描画できましたが、タイ語・韓国語の一部は字形不足で「名前のみ」として残しています。
フォントは後で本番用(Noto / IPAex系)に差し替える前提だったので、ここで完璧を目指さず「差し替え地点を明示して渡す」ほうが現実的でした。公式の体裁を整えるのは、グリフが揃った環境でやればよいのです。
描けるかどうかを、目で見ずに数で決める
最初のうちは、言語ごとに1枚だけ試し描きして、画面で豆腐が出ていないかを確かめておりました。5言語までは、それで足りていたのです。
崩れたのは12言語目でした。韓国語の見出しで、欠けていたのは1文字だけ——縮小表示では、その1文字が潰れているのか、もともとそういう字形なのかを見分けられません。気づいたのは翌朝で、その言語だけ作り直しました。
目で見て決めるのをやめて、描く前に数えることにしました。フォントの cmap を開き、これから流し込む文字列のコードポイントが全部そこにあるかを照合するだけです。
from fontTools.ttLib import TTFont
def covered_codepoints (font_path: str ) -> set :
"""フォントが cmap に持っているコードポイントの集合を返します。"""
font = TTFont(font_path, fontNumber = 0 , lazy = True )
points = set ()
for table in font[ "cmap" ].tables:
points.update(table.cmap.keys())
font.close()
return points
def missing_chars (text: str , covered: set ) -> list :
"""描けない文字を、重複を除いて出現順に返します。"""
seen, missing = set (), []
for ch in text:
if ch.isspace() or ch in seen:
continue
seen.add(ch)
if ord (ch) not in covered:
missing.append(ch)
return missing
DejaVu Sans の cmap は 5,955 コードポイントでした。ラテン文字・キリル文字・アラビア文字の見出しは欠けゼロ、韓国語は8文字、タイ語は7文字が不足という数字がそのまま出ます。前の節の表を目視で埋めた作業が、ここで一度に済みました。
ところが、コードポイントが揃っていても「描ける」とは限らないことに、アラビア語で気づかされます。アラビア文字は前後の文字で形が変わりますから、整形(シェーピング)を行うレイアウトエンジンが要ります。Pillow が libraqm 抜きでビルドされていると、その整形が働かず、切り離された字形が並んだだけの絵になります。
やっかいなのは、そのとき例外が出ないことです。幅の数字だけが静かにずれます。
同じフォント・同じ文字列・同じサイズで、レイアウトエンジンだけを入れ替えて幅を測ると、手元ではこうなりました。
ラテン文字とキリル文字の差は1%未満で、これは字送りの調整ぶんです。アラビア語だけが28.2%も広く出ています。後の節で入れる自動縮小は幅を見て働きますから、整形が効いていない環境で測った幅をそのまま信じると、縮める必要のない行まで縮めることになります。
そこで、コードポイントの照合と幅の二重測定を、1つの関数に畳みました。
from PIL import ImageFont
# 整形あり・なしの幅がこの割合以上ずれたら、自動縮小の判断を人の目で裏取りします
SHAPING_GAP_PCT = 3.0
def widths (text: str , font_path: str , size: int ) -> tuple[ int , int ]:
"""整形なし(BASIC)と整形あり(RAQM)で描いたときの幅を並べて返します。"""
out = []
for engine in (ImageFont.Layout. BASIC , ImageFont.Layout. RAQM ):
font = ImageFont.truetype(font_path, size, layout_engine = engine)
out.append(font.getbbox(text)[ 2 ])
return out[ 0 ], out[ 1 ]
def preflight (lang: str , text: str , font_path: str , size: int , covered: set ) -> dict :
missing = missing_chars(text, covered)
if missing:
return { "lang" : lang, "verdict" : "name-only" , "missing" : missing, "width" : None , "gap" : None }
basic, raqm = widths(text, font_path, size)
gap = round ((basic - raqm) / raqm * 100 , 1 ) if raqm else 0.0
# 差が数%なら字送りの調整だけ。二桁に届く言語は整形そのものに依存しています
verdict = "raster+eye" if abs (gap) >= SHAPING_GAP_PCT else "raster"
return { "lang" : lang, "verdict" : verdict, "missing" : [], "width" : raqm, "gap" : gap}
判定は3つだけです。name-only はラスタ配置をやめてレイヤー名とExcelで渡すもの、raster はそのまま描いてよいもの、raster+eye は描いたうえで書き出し後に人が1枚だけ見るもの。手元の見出し文で走らせると、英語・ドイツ語・ロシア語が raster、アラビア語が raster+eye、韓国語とタイ語が name-only に分かれました。前の節で表に手で書き込んだ判断と、一字一句同じ並びです。
違うのは、次に言語が増えたときの手間だけです。16言語目を足しても、私が見るのは raster+eye に落ちた行だけで済みます。
cmap の充足がラスタ配置の可否、整形の有無が人の目の要否。 この二段に分けてから、字形を理由にした作り直しは一度も起きておりません。
入れ子グループが保存・再オープンで壊れないかを、小さく潰す
5枚のアートボード × 言語別グループという入れ子構造に、新しいピクセルレイヤー(ラスタ化した訳文)を追加していきます。psd-toolsで複雑な階層を編集すると、保存して開き直したときに構造が壊れることがあります。220MBの本番ファイルでそれが起きると、保存だけで数分かかるため発見が遅れます。
そこで本番の前に、小さなPSDで「入れ子グループ + サブグループにピクセルレイヤーを足して保存 → 再オープン」が無事かを検証しました。
from psd_tools import PSDImage
def verify_roundtrip (path: str , expected_boards: int ) -> None :
"""保存済みPSDを開き直し、構造が保たれているかを機械的に確かめる。"""
psd = PSDImage.open(path)
boards = [l for l in psd if l.kind == "group" ]
assert len (boards) == expected_boards, f "artboards: { len (boards) } != { expected_boards } "
added = [l for l in psd.descendants() if l.kind == "pixel" and l.name.startswith( "t_" )]
assert added, "追加したピクセルレイヤーが再オープン後に消えています"
for l in added:
assert l.bbox != ( 0 , 0 , 0 , 0 ), f " { l.name } の座標が失われています"
print ( f "ok: boards= { len (boards) } added_layers= { len (added) } " )
verify_roundtrip( "mini_test.psd" , expected_boards = 5 )
assert を書いておくと、目視で「たぶん大丈夫」と流す余地がなくなります。アートボード5枚が保持され、追加したレイヤーの種別(pixel)も座標も正常に読めることを確認してから、本番ビルドに進みました。重い処理ほど、先にミニチュアで一往復させておく価値があります。
psd-tools が非ASCIIレイヤー名で落ちる(mac_roman問題)
描画自体は通ったのに、保存でエラーが出ました。原因は、psd-toolsがレイヤー名を旧来のmac_roman形式で書き出そうとするため、한국어 や Русский のような非ASCII文字を含む名前でエンコードに失敗していたことです。
レイヤー名に翻訳文や現地語名を入れる方針と真っ向からぶつかる罠でした。
PSDのレイヤー名は、実は二重構造になっています。ひとつはレコード本体にあるレガシーなPascal文字列(ASCII前提、mac_romanで書かれる)。もうひとつは追加情報ブロックの luni(Unicode Layer Name)で、こちらはUTF-16BEで正しく多言語を保持できます。Photoshopは読み込み時に luni を優先します。
つまり、レガシー側にはASCIIの安全な代替名だけを置き、luni に本当の名前を書けば、保存も表示も両立します。
from psd_tools.psd.tagged_blocks import TaggedBlocks, TaggedBlock
from psd_tools.constants import Tag
import re
def safe_ascii (name: str , index: int ) -> str :
"""レガシー領域用。非ASCIIを落とし、空になったら連番で埋める。"""
ascii_only = re.sub( r " [ ^ \x20 - \x7E ] " , "" , name).strip()
return ascii_only or f "layer_ { index } "
def set_layer_name (record, name: str , index: int ) -> None :
"""レガシー名はASCII、実体は luni(UTF-16BE) に書き込む。"""
record.name = safe_ascii(name, index) # mac_roman で必ず通る値だけを渡す
blocks = record.tagged_blocks or TaggedBlocks()
blocks[Tag. UNICODE_LAYER_NAME ] = TaggedBlock(
key = Tag. UNICODE_LAYER_NAME ,
data = name, # 'ko_한국어' などをそのまま
)
record.tagged_blocks = blocks
for i, (record, translated) in enumerate ( zip (layer_records, translations)):
set_layer_name(record, translated, i)
この分離を入れてから、保存は一度も落ちていません。「名前に多言語を入れる」設計を選ぶなら、この保存経路だけは最初に確認しておくべきでした。3回作り直したうちの2回は、この一点が原因です。
翻訳がはみ出す — フォントサイズの自動縮小を入れる
日本語のキャッチコピーは短く、同じ意味を英語やドイツ語で書くと確実に長くなります。キャンバス幅を超えたレイヤーをそのまま描画すると、文字が切れるか、隣の要素に重なります。
そこで、幅に収まるまでフォントサイズを段階的に落とす処理を入れました。下限は元の50%です。それ以下に落とすと、スクリーンショットとして読めなくなるからです。
from PIL import ImageFont
MIN_SCALE = 0.50
STEP = 0.95
def fit_font (text: str , font_path: str , base_size: int , max_width: int ):
"""max_width に収まる最大のフォントサイズを返す。収まらなければ None。"""
size = base_size
floor = int (base_size * MIN_SCALE )
while size >= floor:
font = ImageFont.truetype(font_path, size)
width = font.getbbox(text)[ 2 ]
if width <= max_width:
return font, size, size < base_size # (font, 実サイズ, 縮小したか)
size = int (size * STEP )
return None , floor, True # 下限でも収まらない → 人間に渡す
戻り値の3つ目で「縮小したか」を返しているのが要点です。縮小が発生した箇所だけをログに出し、後から目視で確認できるようにしました。
全31箇所の編集のうち、縮小対象になったのは言語によって2〜11箇所。単語が長い英語・ドイツ語ほど縮小箇所が増えます。下限50%でも収まらないケースは今回ゼロでしたが、収まらなければ None を返して処理を止め、翻訳文そのものを短くする判断を人間に戻す設計にしています。自動化が判断を奪わないよう、止まる場所を先に決めておくのが安全です。
数字の見せ方も言語をまたいで整理しました。小さなバッジは「100K+」、大きな見出しは「100,000+」と意図的に書き分けています。全16言語の対訳は、横並びで確認できるようにExcelへまとめました。表形式の集計やレポート生成をCowork側でやる手順は、Cowork × Python データ分析自動化 の流れがそのまま使えます。
作業の流れを5ステップに畳む
今回の一晩を振り返ると、再現可能な手順は次の5つに畳めます。次にスクショを差し替えるときは、この順番をそのままなぞれば迷いません。
正本の確定 : 受け取ったPSDが複数あるときは、解像度と容量で正本を1つに確定(今回は1440×13333px・220MB)
構造のダンプ : psd-toolsでアートボード数とテキストレイヤー数を計数(5枚 × 約40レイヤー)
字形と整形の事前判定 : cmapの照合と幅の二重測定で、各言語をraster・raster+eye・name-onlyに仕分け
小さなPSDで保存検証 : 入れ子グループへのレイヤー追加が再オープンで壊れないかを、ミニチュアで一往復
本番ビルドと書き出し : レイヤー名に訳文を入れて描画し、ストア別サイズへ書き出し
字形が問題なく出たのは15言語中13言語、約87%でした。残る韓国語・タイ語の2言語(約13%)は字形不足で名前のみとして残し、フォントが揃った環境での仕上げに回しています。
割合で見ておくと、どこまで自動化で到達し、どこから手作業が必要かの線引きが明確になります。私自身は、この「どこで止めて人間に渡すか」を最初に決めておくことを強く推奨します。完璧を目指して全言語を一気通貫で処理しようとすると、たいてい字形の壁で止まるからです。
Play Console と App Store Connect 用に書き出す
ブラッシュアップ後のPSDは、最終的に各ストアの推奨サイズへリサイズして書き出します。Google Play Console と App Store Connect では要求される縦横比やピクセル数が違うため、1枚のマスターから複数サイズを生成する処理を別途用意しておくと、次のバージョンのスクショ差し替えがそのまま使い回せます。
今回はマスターPSDを正本として保管し、書き出しは言語×サイズの掛け算で自動化する方針にしました。書き出した画像をストア側へ反映する工程は、Claude in Chrome で App Store Connect を自動化する で扱った手順に接続できます。
こうした「店頭に並べる作業」こそ、仕組み化の効果がいちばん大きいところです。アプリの中身は自分の手で作り、それを世界中の方に届けるための版組みは道具に任せて時間を生む——この線引きが、個人で多言語のストア運用を回す現実的な落としどころだと感じています。
その後の進捗 — 編集可能PSDという落としどころ
この記事を書いたあとも作業は続き、いくつか前進がありました。
最初の課題だった字形の問題は、PSDに直接ラスタライズして埋め込むのではなく、編集可能なテキストレイヤーを持つPSD(localized_editable.psd)として書き出す 方針に切り替えることで筋を通しました。各言語のグループをそのまま原本のスクショPSDにドラッグし、日本語レイヤーを非表示にして、フォントを差し替えてから書き出す、という流れです。グリフが出るかどうかは、最後にフォントを当てる工程の問題に閉じ込められます。
そのフォント差し替えのために、言語別のおすすめフォント早見表(FONT_GUIDE.md)を用意しました。デザインのBold・Medium・Lightの各ウェイトを、Noto Sans / Noto Sans KR・SC・TC・Thai・Arabic に、日本語はIPAexゴシックに対応づけています。多言語で統一感を出すなら、Notoファミリーで揃えるのが安全だと感じました。
最終的に全16言語を精査し、誤訳はなく、アプリ名もASOの正式名称に準拠していることを確認しています(ロシア語でカテゴリ名と本文の表記が軽く揺れる程度の差はありましたが、意味の誤りではありません)。
ここまで来て改めて感じるのは、限られた環境で完璧なラスタを作ろうとせず、「編集可能な状態 + フォント差し替えの指示書」という形で渡すほうが、結局いちばん速くて確実だということです。自動化の出口を「完成品」ではなく「人が仕上げられる状態」に置く。それだけで、詰まる場所がひとつ減ります。
次にやるなら、まずは手元の1枚のPSDをCoworkに渡して、テキストレイヤーの一覧をダンプさせてみてください。何枚のアートボードに、何個の文言が載っているのか。その数を見た瞬間に、手作業でやるべきでない理由がはっきりします。
私自身まだ手探りの部分が多いのですが、同じようにストア素材の多言語化で止まっている方の助けになれば嬉しいです。お読みいただきありがとうございました。