PHP 8.5にしたらOGP画像の動的生成が壊れた

  • URLをコピーしました!

WordPressを7.1に、PHPを8.5に上げた翌日のことです。サイトを眺めていて、記事一覧のサムネイル画像がリンク切れになっていることに気づきました。

壊れていたのは、以前このブログで紹介した「OGP画像を自動で生成する」という自作の機能です。アイキャッチ画像を設定していない記事に対して、タイトル文字を載せた画像をPHPで動的に生成する仕組みでした。

結論から言うと、原因は imagettftext() にフォントを相対パスで渡していたことでした。PHP 8.2では動いていたものが、8.5では動かなくなっています。

そして原因を突き止めるまでに、一度間違った結論に辿り着いています。その過程も含めて書いておきます。

目次

症状

記事一覧のサムネイル画像が表示されず、リンク切れの状態になっていました。

該当するのは、アイキャッチ画像を設定していない記事だけです。この機能は「アイキャッチがなければタイトル文字入りの画像を自動生成する」というものなので、生成処理そのものが失敗していると考えられました。

画像のURLを直接開いてみると、真っ白なページが返ってきます。

https://example.com/wp-content/themes/子テーマ/og/img.php?text=テスト

なお、この不具合には公開当日に気づけませんでした。おそらくブラウザにキャッシュが残っていて、以前の画像が表示されていたのだと思います。

何を疑ったか

作業内容から、原因の候補は絞れていました。

直前にやったのは、WordPressの5.9から7.1へのアップグレードと、PHPの8.2から8.5への更新です。

WordPressについては、6.0 → 6.2 → 6.4 → 6.6 → 6.8 → 7.0 → 7.1 と段階的に上げながら、その都度サイトを目視で確認していました。途中で壊れていれば気づいたはずなので、こちらはあまり疑いませんでした。

一方PHPは、7.1に到達したあと最後に上げたものです。しかも壊れているのは自作のPHPコードが動いている部分だけ。PHPのバージョンアップが原因だろうと当たりをつけました。

結果的にこの見立ては当たっていたのですが、途中で一度ひっくり返ります。

切り分け

ローカルでは動く

まずローカル環境(DDEV)で同じURLを開いてみました。

こちらは問題なく画像が表示されます。コードの問題ではなく環境の問題、という切り分けができました。

パーミッションを確認

本番でファイルの権限を見てみます。

ls -la wp-content/themes/子テーマ/og
-rw-r--r-- 1 user group 4691408 NotoSansJP-Bold.otf
-rw-r--r-- 1 user group    1027 img.php
-rw-r--r-- 1 user group    9536 og_base.png

644なので、Webサーバーから読める状態です。問題ありません。

エラーログを確認

Xserverはドキュメントルートに error_log を出力します。

tail -30 error_log

ファイル自体が存在しませんでした。 致命的なエラーが起きていれば記録されるはずですが、何も残っていません。

ここで少し困りました。真っ白な画面が返ってくるのに、ログには何も出ない。

GD拡張の状態を確認

画像生成にはGDライブラリを使っています。拡張が有効かどうかを確認するスクリプトを一時的に置いてみました。

<?php
header('Content-Type: text/plain');
echo "PHP: " . PHP_VERSION . "\n";
var_dump(extension_loaded('gd'));
print_r(gd_info());

結果です。

PHP: 8.5.9
bool(true)

Array
(
    [GD Version] => bundled (2.1.0 compatible)
    [FreeType Support] => 1
    [FreeType Linkage] => with freetype
    [PNG Support] => 1
    ...
)

GDは有効。日本語フォントの描画に必要なFreeTypeも有効です。ここも問題なし。

1行ずつ実行してみる

エラーが表示されないなら、表示させればいい。エラー出力を有効にして、処理を1行ずつ確認するスクリプトを書きました。

<?php
ini_set('display_errors', '1');
error_reporting(E_ALL);
header('Content-Type: text/plain');

$fontFamily = 'NotoSansJP-Bold.otf';

echo "1. start\n";
$img = imagecreatefrompng('og_base.png');
echo "2. imagecreatefrompng: "; var_dump($img);

$color = imagecolorallocate($img, 51, 51, 51);
echo "3. imagecolorallocate: "; var_dump($color);

$r = imagettftext($img, 36, 0, 216, 216, $color, $fontFamily, 'テスト');
echo "4. imagettftext: "; var_dump($r);

実行すると、ようやく原因が出てきました。

1. start
2. imagecreatefrompng: object(GdImage)#1 (0) {
}
3. imagecolorallocate: int(137)

Warning:  imagettftext(): Could not find/open font in ... on line 20
4. imagettftext: bool(false)

imagettftext() がフォントを開けていません。

腑に落ちない点

フォントファイルは同じディレクトリにあり、パーミッションも644です。実際、こう確認しても問題ありませんでした。

var_dump(is_readable('NotoSansJP-Bold.otf'));  // bool(true)

is_readable() は true を返すのに、imagettftext() は開けない。

しかも同じディレクトリにある og_base.png は、相対パスのまま imagecreatefrompng() で問題なく開けています。同じ場所にある2つのファイルで、片方だけ扱いが違う。

調べてみると、imagettftext() のフォントパス解決は他のファイル関数と異なります。相対パスを渡された場合、カレントディレクトリを見に行くのではなく、GDFONTPATH 環境変数やシステムのフォントディレクトリを探しに行きます。

つまり、フォントを相対パスで渡すこと自体が本来は不安定な書き方だったわけです。

PHPのバージョンを疑って検証する

ここで当初の見立てに戻ります。PHP 8.5に上げたタイミングで壊れたのだから、PHPのバージョンが影響しているはずです。

ローカル環境ならバージョンを切り替えて試せます。DDEVなら数十秒で切り替わります。

まずPHP 8.5で、相対パスと絶対パスを比較するスクリプトを実行しました。

$fontRel = 'NotoSansJP-Bold.otf';
$fontAbs = __DIR__ . '/NotoSansJP-Bold.otf';

echo "相対パス: ";
var_dump(imagettftext($img, 36, 0, 100, 100, $color, $fontRel, 'テスト') !== false);
echo "絶対パス: ";
var_dump(imagettftext($img, 36, 0, 100, 200, $color, $fontAbs, 'テスト') !== false);
PHP: 8.5.7
相対パス: bool(false)
絶対パス: bool(true)

再現しました。では8.2ではどうか。

ddev config --php-version=8.2
ddev restart
PHP: 8.2.31
相対パス: bool(false)
絶対パス: bool(true)

8.2でも失敗しました。

ここで私は「PHPのバージョンは関係ない」と判断しました。8.2でも8.5でも同じ結果なのだから、原因は別にある。ではなぜ本番では動いていたのか。ローカルと本番の環境差だろうか。

本番で検証する

ローカルで再現しないなら本番で試すしかありません。幸いXserverはサーバーパネルからPHPのバージョンを切り替えられます。数分で戻せるので、一時的に8.2に落として同じスクリプトを実行してみました。

まず現状のPHP 8.5。

PHP: 8.5.9

is_readable(相対): bool(true)
is_readable(絶対): bool(true)
imagecreatefrompng(相対): bool(true)

--- imagettftext ---
相対パス: bool(false)
絶対パス: bool(true)

次に、サーバーパネルでPHPを8.2に切り替えて再実行。

PHP: 8.2.33

is_readable(相対): bool(true)
is_readable(絶対): bool(true)
imagecreatefrompng(相対): bool(true)

--- imagettftext ---
相対パス: bool(true)
絶対パス: bool(true)

8.2では相対パスでも動きました。

同じサーバー、同じファイル、同じコード。変えたのはPHPのバージョンだけです。これで、PHP 8.5への更新が原因だと確定しました。

なお、どちらのバージョンでもカレントディレクトリは正しくスクリプトの置き場所を指していました。作業ディレクトリの違いが原因ではありません。

ローカルでの検証はなぜ外れたのか

ローカルのPHP 8.2でも失敗していたのは、GDのビルド設定がXserverと違うためでしょう。DDEVが使っているDockerイメージと、Xserverが提供しているPHPでは、GDやFreeTypeのコンパイルオプションが同じとは限りません。

ローカルで再現しなかったことをもって「環境依存だから原因は特定できない」と結論づけるところでした。本番で確かめたから分かったことです。

対処法

フォントを絶対パスで渡すようにします。__DIR__ を使えば、ファイルの位置を基準に解決されるので環境に左右されません。

修正前。

$fontFamily = 'NotoSansJP-Bold.otf';

修正後。

$fontFamily = __DIR__ . '/NotoSansJP-Bold.otf';

1行だけです。これで本番でも正常に画像が生成されるようになりました。

なお imagecreatefrompng() のほうは相対パスのままでも動いていますが、同じ理由でいつ壊れるか分かりません。揃えておいたほうが安全でしょう。

$img = imagecreatefrompng(__DIR__ . '/og_base.png');

まとめ

  • imagettftext() にフォントを相対パスで渡すと、PHP 8.5では Could not find/open font になる
  • imagettftext() のフォント解決はカレントディレクトリを見ない。他のファイル関数とは扱いが違う
  • is_readable() が true でも、imagettftext() が開けるとは限らない
  • 同じディレクトリのPNGは imagecreatefrompng() で相対パスのまま開ける。並べて書いてあると余計に気づきにくい
  • __DIR__ を付けて絶対パスにすれば解決する

エラーログには何も出ませんでした。真っ白な画面が返ってくるだけで、手がかりがない。こういうときは、エラー表示を有効にしたスクリプトを一時的に置いて、処理を1行ずつ確認するのが結局いちばん早い方法でした。

もうひとつ。ローカルでの検証結果だけを見て「PHPは関係ない」と判断しかけたのは反省点です。ローカルと本番でミドルウェアのビルド設定が違うことは十分あり得るので、本番の挙動は本番で確かめるしかありません。

GDで画像を生成している方は、フォントのパスが相対になっていないか確認しておくとよさそうです。PHPのバージョンを上げた日に、SNSのOGP画像が全部真っ白になっているかもしれません。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

Web制作会社にてフロントエンド業務に携わる一方、個人でブログを書いたりサイト制作やシステム構築にいそしむ。元家具職人。
https://rcwas.com/

目次