blog
開発メモ画像の中の文字は、フォントが分からなくても書き換えられる——PSDが無い現場で試した「移植」という方法
10年近く前に作られたサイトで、見出し・ボタン・バナーがすべて画像で組まれたページの文言変更を頼まれました。当時のPSDも、使ったフォントの記録も残っていません。画像は2x(Retina用)で、背景色も箇所によって白とベージュが混在していました。
先に結論——文字は「描く」のではなく、どこかから「拾ってくる」
最初に狙ったのは「フォントを特定して、同じ設定で描き直す」という素直な方針でした。候補フォント(Yu Gothic R/M/L、Meiryo、BIZ UDGothic、MS Gothic、Noto Sans JPの可変ウェイト)で同じ文字列をレンダリングし、原本のインクマスクと重ねて平均絶対差を取る総当たりを書きました。
結果は決め手になりませんでした。差分スコアの上位が0.13前後で団子になり、Noto Sans JP w300〜350とYu Gothic Regularが事実上同点。5倍拡大の目視でも判別がつきません。原因はおそらく、原本がPhotoshopで文字詰め(palt相当)をかけて組まれており、フォントの素の字送りと一致しないことでした。字形が似ていても配置が合わなければ、どのフォントでもスコアは等しく悪くなります。
ここで「たぶんNotoでいいだろう」と決め打ちすると、変更した文字だけウェイトが違うという、いちばん恥ずかしい壊れ方をします。1文字だけ浮いている画像は素人目にも分かるので、ここは粘るべきところだと判断しました。
方針を変えるきっかけは、依頼の中身を見直したことでした。依頼の多くは、実は同じページの別の画像に既にある文字への差し替えだったのです。
- ボタンA「新規のお問い合わせ」→「商品のお問い合わせ」……「商品」は同じページの別のボタン画像(同サイズ・同フォント)に存在する
- 見出し「破損・誤送のご連絡」→「破損のご連絡」……「・誤送」を消して詰めるだけ。1文字も描かなくていい
- 電話番号A→B……番号Bは別セクションのバナーに同じ級数で存在する
フォントを同定する必要はそもそも無く、元の画素をそのまま運べばいい。これに気づいてから、作業は「タイポグラフィ」ではなく「測量」になりました。
字送り(em)を線形回帰で求める——プロポーショナル文字の扱い方
移植先に文字を正しい間隔で並べるには、まず原本の字送り(em)を測る必要があります。インク画素を列方向に投影し、ゼロの区間で切ると1文字ずつのインク境界は出ますが、インク幅は字形ごとに違うため、これをそのまま字送りにはできません。
効いたのは、インク開始位置を文字インデックスに対して線形回帰するやり方でした。傾きがそのままemになります。
ここで中黒「・」のようなプロポーショナル字が混じると回帰がずれます。「・」を半角(0.5em)と仮定してスロット番号を0, 1, 2, 2.5, 3.5…と振り直したところ、残差が1px台に収まりました。この時点で仮定が正しいと確定できます。「合わないから諦める」のではなく、「合わない量から仕組みを逆算する」判断がここで効きました。
切断点についても、目分量で切るとアンチエイリアスの裾を切り落として文字が痩せます。低い閾値(差分8程度)で列ごとのインク量を出し、ゼロが連続する列を探してその中央で切ると、2x画像なら大抵見つかりました。
背景をまたぐ移植には、アルファの復元が要る
見出し(ベージュ背景)から取った文字を、ボタン(白背景)へ運ぶ必要がありました。矩形コピーでは背景ごと運んでしまいます。
画面に見えている1画素は、背景色とインク色が「被覆率(α)」の割合で混ざったものです。式にするとこうなります。
pixel = bg * (1 – α) + ink * α
# 背景色とインク色が分かれば、被覆率αを逆算できる
α = (bg – pixel) / (bg – ink)
背景色とインク色は分かっているので、この式を逆に解けば被覆率が復元できます。チャンネルごとに解いて平均すると安定しました。これで背景から切り離した「文字レイヤ」が得られ、任意の背景へ合成できます。縮小もこのαに対してかければ、白フチは出ません。
サイズ比についても、見出しの文字をボタンサイズへ縮小する必要があり、em比(35.6→26.9=0.755)で計算できますが、これは検算が要ります。幸い「連絡」という文字が見出しにもボタンにも出てきたので、そのインク矩形の比を取ると0.750〜0.761に収まり、em比と一致しました。二つの独立した経路で同じ数字が出れば、安心して次に進めます。
1xの小さい画像だけは、移植が成立しなかった
PC用の小さいボタン(横173px、字送り12.4px)では、インクがゼロになる列が一箇所もありませんでした。隣接文字のアンチエイリアスが必ず重なります。無理に切ると、隣の文字の尻尾が「意味のない灰色の点」として残りました——これはスクリーンショットで初めて気づきました。
ここは方針を変え、その1行だけ全部レンダリングし直しました。1行まるごと同じフォントで描けば、少なくとも行内でウェイトは揃います。他のボタンとの微妙な差は、12pxでは誰も気づきません。「全部移植」に固執せず、破綻する寸法では素直に描き直すという線引きが必要でした。
もう一つの罠——基準線をアイコンの上端と取り違えた
描き直した行を貼る縦位置を、画像全体のインク上端(y=14)に合わせてしまいました。実はそれはアイコンの上端で、文字の上端はy=18でした。結果、差し替えた2文字だけが4px浮いた状態で出力されました。
全体の投影から得た行位置を、そのまま「文字の位置」と思い込んだのが原因です。アイコンと文字が同じ行に並ぶレイアウトでは、必ず列方向で切り分けてから縦位置を測る必要があります。これも目視のスクリーンショットで発見しました。数値だけ見ていたら、そのまま通していたはずです。
検証は数値ではなく、ブラウザで見る
最終確認は、本番のHTMLをそのまま取得し、変更をテキスト置換で当て、画像のURLだけローカルファイルに差し替えたうえで、ヘッドレスブラウザで実寸のスクリーンショットを撮る方法で行いました。
CSSも周辺のマークアップも本番そのままなので、実際に納品される見た目とほぼ同一のものが手元で確認できます。上で書いた2つの不具合(1x画像での破綻、基準線のズレ)は、どちらもこの工程でしか見つかりませんでした。画素単位の検算をいくら積み上げても、最後に人間の目(あるいはそれに相当する視覚確認)を通さないと落ちるということです。
ついでに見つけた、地味だが致命的な穴——画像だけキャッシュが30日
差し替え対象の画像のうち、ボタンと見出しの画像にだけキャッシュ用のクエリ文字列が付いていませんでした。サーバーは画像に30日のキャッシュを返しています。つまり一度でも訪れたことのある人には、最大1か月ふるい画像が出続ける状態でした。
バナー画像には秒単位のクエリ(毎回キャッシュ無効)が付いていて、「キャッシュ対策はされている」ように見えたことが、かえってこの見落としを招きました。差し替えた画像にだけ固定のクエリを足して解決しましたが、画像を差し替えるときは必ずキャッシュヘッダを実際に叩いて確認する、という手順を先に入れておくべきでした。
「反映したつもりが、実は古いまま」という壊れ方は、反映したはずなのに本番が変わらなかった話とも根が同じです。設定やファイルは「書いた通りに効いているはず」で終わらせず、実際のレスポンスで確かめる。画像の差し替えも例外ではありませんでした。設定が実際に効いているかを推測ではなく実測で確かめるという点は、書いたはずの.htaccessが子ディレクトリには効いていなかった話とも共通しています。
持ち帰れる判断基準
- 画像内テキストの改変は、フォント同定から入ると詰む。まず「その文字、どこかに既にないか」を探す
- 総当たり比較のスコアが団子になったら、それはフォントが違うのではなく、組み方(字詰め)が違うサインと疑う
- 数値検算と目視検証は代替できない。両方やる
- 破綻する寸法(インクが途切れない小さな画像など)では、移植に固執せず素直に描き直す
- 画像を差し替えるときは、キャッシュヘッダを実際に叩いて確認する
※本記事の内容は2026年8月時点のものです。

