Gravのサーバローカルな画像最適化

/ Grav

Markdownから画像を簡単に加工したりできるのはGravCMSの強いアピールポイントだろう。しかし、Grav単体ではMarkdownから画像を加工すると出力された画像に最適化が施されていない状態となる。

Webページの転送量における画像が占める割合は非常に大きいことはよく知られており、少しでも転送量を削減するためには画像の最適化は必須だ。Gravのリポジトリには、images/に画像が生成されるイベントを拾ってその画像を最適化するプラグインが存在しているが、今のところそれらはいずれも有料の画像最適化ウェブAPIを使用する。

Gravが動くようなサーバ環境があるなら、サーバローカルで画像最適化ツールを走らせることでこれらの有料サービスを使わずに同等の機能を実現できる。その方法をメモしておく。

Gravの画像処理

まずはじめに、少しだけGravにおけるWebページの画像の取り扱いについてみて置く必要がある。

GravではWebページのコンテンツは全てuser/pages以下で管理する。ユーザが画像を配置する方法には2通りある。1つにはページのディレクトリに直接画像を置く方法だ。この場合はMarkdownから単に画像のファイル名によってその画像を参照し、HTMLにはサーバ上のそのファイル位置がレンダリングされ、単なる静的ファイルとしてその画像がページの一部としてロードされる。

2つめの方法は画像を置くためのuser/pages/imagesページを作って全てのページの画像をそこに入れる。Gravではuser/pages以下のディレクトリは必ず1つのページに対応する。それが単に画像を格納するためのフォルダであればそのページのroutableをfalseに設定する。この場合はページのMarkdownからGravの絶対パスで/images/example.jpgを呼び出し、結果として同じようにHTMLにはサーバ上のそのファイルの位置がレンダリングされ、こちらも同じく単なる静的ファイルとしてその画像がページの一部としてロードされる。

これらの違いは単にページを見ただけではわからないがレンダリング結果のHTMLを見れば一目瞭然である。

Gravのマークダウンでは、画像を呼び出したときに属性部分をパースして画像をいろいろ加工することができる。Gravは加工したファイルをブラウザにロードさせるために、加工後の画像をサーバのどこかに配置しその位置をHTMLにレンダリングして静的ファイルとしてロードさせる必要がある。PHPプログラムが画像のバイナリデータを吐き出してロードさせるよりも高速なことは容易に想像が着くだろう。

そのようなファイルの配置場所として、Gravはuser/があるディレクトリと同じレベルにimages/ディレクトリを備えている。これは先ほどのuser/pages/imagesディレクトリとは根本的に異なり、ユーザではなくGravのPHPプログラムがuser/pages以下のMarkdownを読み取って動的に生成する。

Gravはimages/配下に画像を精製したタイミングでonImageMediumSavedイベントを各種テーマ/プラグインに送出するので、これを拾ってその生成された画像に対して最適化を施すことによって、Markdownから画像を加工してもその結果を最適化してブラウザにロードさせることができる。

実際、先に述べた有料APIを使うプラグインもこの仕組みで動いているのでonImageMediumSavedのタイミングで独自の方法を使って画像を最適化することに何の躊躇もいらないだろう。有料APIはこのイベント時にAPI-Key付きでWeb APIを呼び出して画像を最適化するが、それを単にサーバローカルな最適化に置き換えればいいだけだ。

spatie/image-optimizer

https://github.com/spatie/image-optimizer

ここではこのspatie/image-optimizerというPHPで使えるパッケージを用いる。このパッケージは、システムにインストールされている画像最適化系のコマンドラインツールを適切に利用して画像を最適化してくれる。具体的にはjpegoptim、optipng、pngquant、svgo、gifscale、cwebpコマンドをファイル名を与えるだけで適切に叩いてくれる。拡張子を調べてコマンドを走らせるのは別にこのパッケージ使わなくてもできそうだがせっかくあるので使っていくことにする。

Gravでイベントを拾うにはPluginかThemeのphpを使う必要がある、ここではThemeを使うことを想定する。以下user/themes/current-theme/以下に居るとする(current-themeの部分は適宜置き換えるとする)。

composer require spatie/image-optimizer

もしくは同じことだがWindowsの場合

php composer.phar require spatie/image-optimizer

でuser/themes/current-theme/vendor配下にphpファイルがダウンロードされる。次にautoloadの設定をする。composer.jsonを開いて

{
    "require": {
        "spatie/image-optimizer": "^1.2"
    },
    "autoload": {
        "psr-4": {
            "Spatie\\ImageOptimizer\\": "vendor/spatie/image-optimizer/src"
        }
    }
}

のように"autoload"の部分に追記する。これで一度

composer dump-autoload

を走らせておく。次にThemeのPHP(ここではuser/themes/current-theme/current-theme.phpとなる)の最初の方で以下を記述。

<?php
namespace Grav\Theme;

require_once __DIR__ . '/vendor/autoload.php';

use Grav\Common\Grav;
use Grav\Common\Theme;
use RocketTheme\Toolbox\Event\Event;

use Spatie\ImageOptimizer\OptimizerChainFactory;

--以下略--

マーカのある個所が追記した箇所になる。autoloadによってcomposer.jsonに指定したクラスが名前空間上にロードされる。そのあとuseでパッケージ名を省略できるようにしている。次にonImageMediumSavedイベントを拾う。この辺りはGravのphpを書いたことがあるならば難しいことは何もない。

class CurrentTheme extends Theme
{

  public static function getSubscribedEvents() {
    return [
      'onThemeInitialized' => ['onThemeInitialized', 0],
    ];
  }

  public function onThemeInitialized()
  {
    if ($this->isAdmin()) {
      $this->active = false;
      return;
    }

    $this->enable([
      'onImageMediumSaved' => ['onImageMediumSaved', 0],
    ]);
  }

  public function onImageMediumSaved(Event $event)
  {
    $path = $event['image'];

    $optimizerChain = OptimizerChainFactory::create();

    $optimizerChain->optimize($path);
  }
}

以上でGravがimages/以下に画像ファイルを作るたびに、その画像の種類によってシステムのjpegoptimやpngquantをその画像に対して走らせることが出来るようになった。それぞれのコマンドに渡されるパラメータはImageOptimizerのREADMEで説明されており、いずれも満足できる範囲内だ。

ここまでは、Gravが利用できるサーバ環境ならば確実に行えるカスタマイズだ。なぜなら単純にphpファイルを修正しているだけなのでこれが不可ならGravそのものが使えていない。

問題はサーバのシステムにjpegoptimやpngquantなどのコマンドがインストールされていない場合で、通常はこれらのインストールには特別な権限がいる場合がある。特に便利なパッケージ管理システムなどを用いる場合は十中八九そうだ。先に挙げている有料APIはサーバローカルに新たなバイナリを設置したりすることが不可能な場合の代替手段となる(逆にそれくらいしか利点が考えられない。だって最適化の性能は変わらないんだから)。

実際にはインストールして単体のコマンドとして使用できなくても、ローカルにpngquantなどのバイナリを自前で配置できれば十分だ。ImageOptimizerに対してどこにバイナリがあるかを設定するだけで通常どおり利用できる。さらに、個別のコマンドに対するパラメータも設定できる。以下ではその説明を残す。

pngquant

user/themes/current-theme/pngquant/以下にpngquantのバイナリを配置したとする。つまり今current-themeディレクトリに居るなら

pngquant/pngquant

によってpngquantのヘルプ画面が呼び出せる状態になっているとする。これをImageOptimizerに対して設定するには、onImageMediumSaved()内で次のようにする。

use Spatie\ImageOptimizer\OptimizerChain;
use Spatie\ImageOptimizer\Optimizers\Pngquant;

--中略--

$pngquant = new Pngquant([
  '--skip-if-larger',
  '--force',
  '--strip',
  '-s1', '256'
]);

$pngquant->binaryPath = __DIR__ . '/pngquant/';

$optimizerChain = (new OptimizerChain)
  ->addOptimizer($pngquant);

$optimizerChain->optimize($path);

バイナリの場所を指定しているのはマーカーの部分だ。__DIR__はこのphpファイルがあるディレクトリになり、そのあとのドットは文字列の結合を意味する。加えて、コンストラクタでpngquantのオプションを与えているがこの辺りは適宜自由に設定したらよい。

jpegoptim

こちらも同じくuser/themes/current-theme/jpegoptim/以下にjpegoptimのバイナリを配置したとする。ほぼ同じことだが次のようにする。

use Spatie\ImageOptimizer\OptimizerChain;
use Spatie\ImageOptimizer\Optimizers\Pngquant;
use Spatie\ImageOptimizer\Optimizers\Jpegoptim;

--中略--

public function onImageMediumSaved(Event $event)
{
  $path = $event['image'];

  $pngquant = new Pngquant([
    '--skip-if-larger',
    '--force',
    '--strip',
    '-s1', '256'
  ]);

  $jpegoptim = new Jpegoptim([
    '--max=90',
    '--strip-all',
    '--all-normal'
  ]);

  $pngquant->binaryPath = __DIR__ . '/pngquant/';
  $jpegoptim->binaryPath = __DIR__ . '/jpegoptim/';

  $optimizerChain = (new OptimizerChain)
    ->addOptimizer($pngquant)
    ->addOptimizer($jpegoptim);

  $optimizerChain->optimize($path);
}

これが完成形だ。この状態ではpngquant/jpegoptimしかImageOptimizerに使わせることが出来ないが、インストールされていないことがわかっているならばこれで十分だ。gifやwebpなどをページにアップしないことがわかっている場合もこれで十分だ。また、SVGにはMarkdownからの加工が効かないのでアップロードする前にローカルのsvgoをかけて置けばいい。

svgoだけはnodeが必要なのでサーバローカルで最適化するにはややハードルが高い。

最適化の確認

ここまでやった後にやりたいことは最適化コマンドが動いたかどうかの確認だ。ImageOptimizerパッケージはそれ自身でログを残す機能を備えているが、ここでは単にGravのimages/ディレクトリを見る。

Markdownで次の形式で画像をページに埋め込む

![テスト画像](test.jpg?cache&classes=img-responsive)

cache属性がキモだ。resize=960などを指定すると画像は加工されてimages/ディレクトリに回されるが、単に何もせずにimages/ディレクトリに送る(つまりonImageMediumSaved()を呼び出させる)場合はcacheを指定する。これによって比較ができる。

これでimages/以下の対応する画像のサイズが元画像よりも小さくなっていれば最適化が施されていることが確認できる。当然だがuser/pages/以下にアップする画像は未最適化のものでないと効果はわからないことに注意が必要。2回最適化すると誤差の範囲で逆にサイズが増えることもあるので--skip-if-largerというオプションがpngquantにある。

終わりに

テンプレートによってはページヘッダの画像をリサイズで作ったりするのでimages/以下に最適化が効いているかは重要。最初に述べたようにGravには現状そのようなプラグインが不足しているようだ。有料APIを不必要に用いるプラグインはそれだけで利用するに値しない。サーバであれ普通のPCであれローカルで出来るものをWeb上で行う理由は相手の商売目的以外には全く分からない。

ここで行った方法は、binaryPathやbinaryNameをYAMLからいじれるようにしておけばプラグインにpngquantやjpegoptimのバイナリを同梱しなくて済むし面倒な部分はImageOptimizerがやってくれる。プラグイン化することは容易だ。しかし、誰かがそれをしてくれることを望む。