画像を最適化する
貸し本棚は背景画像や表紙画像をアップロードすると、可能であればwebpに変換していますが、「webpにしてます!」と言っても知名度の問題で伝わらない可能性があったため「最適化してます!」というふわっとした説明をしています。
しかし、肝心の一言とノートに掲載する画像は変換をかけていませんでした。
一言の画像はサーバにアップロードされるので、これをwebpにするのはそれほど難しくありません。しかし、ノートにドロップされた画像の変換で少し悩みました。
webpとは?
大雑把にjpegより高品質、高圧縮率の画像ファイル形式で、アニメーションとか透過画像も作れます。ほとんどのブラウザが対応済み。
ノートの画像変換が難しい理由
ノートにはEditor.jsというブロックエディタを使っています。Editor.jsはノート以外にもお問い合わせや記事にも使われているので、問題の範囲が大きいです。
Editor.jsの画像ドロップにはSimpleImageというものが使われており、これはサーバサイドに依存せず、ブラウザ上ですべてを処理してしまうプラグインです。
ファイル自体をアップロードするプラグインもありますが、記事とファイルを分解してしまうとファイル管理が必要になるので、小規模の記事機能には向きません。
javascriptでwebp変換することも可能ですが、すでにバックエンドにある機能をフロントエンドにもう一つ作るのは悪手だと思います。
SimpleImageを調べる
都合がいいことに、貸し本棚ではこのSimpleImageをGyazo対応のためにGyazoImageという拡張クラスに継承していました。
Editor.js上に画像をドロップしたときの、SimpleImageの動作を確認してみます。
https://github.com/editor-js/simple-image/blob/master/src/index.js
/**
* On paste callback that is fired from Editor.
*
* @param {PasteEvent} event - event with pasted config
*/
onPaste(event) {
switch (event.type) {
case 'tag': {
const img = event.detail.data;
this.data = {
url: img.src,
};
break;
}
case 'pattern': {
const { data: text } = event.detail;
this.data = {
url: text,
};
break;
}
case 'file': {
const { file } = event.detail;
this.onDropHandler(file)
.then(data => {
this.data = data;
});
break;
}
}
}
なにかがドロップされると、onPaste(event)が呼ばれて、switch文でドロップされたのがタグなのか、既定パターンなのか、あるいはファイルそのものかを判定しています。タグならURLを取り出します。パターンは外部から設定できて、画像のURLやGyazoのURLを適切に変換することができます。
今回変換するのはファイルそのものなので、onDropHandler(file)で処理した結果をthis.dataに戻している部分の処理になります。このイベントハンドラを調べます。
/**
* Read pasted image and convert it to base64
*
* @static
* @param {File} file
* @returns {Promise<SimpleImageData>}
*/
onDropHandler(file) {
const reader = new FileReader();
reader.readAsDataURL(file);
return new Promise(resolve => {
reader.onload = (event) => {
resolve({
url: event.target.result,
caption: file.name,
});
};
});
}
やってることはシンプルで、FileReaderでfileをbase64に書き換えているだけですね。
FileReaderが変換を終えるまで待ってくれるようです。Promiseの中でゴニョゴニョできそうです。
改造する
このonDropHandler(file)をGyazoImageでオーバーライドします。
onDropHandler(file) {
const reader = new FileReader();
reader.readAsDataURL(file);
return new Promise(resolve => {
reader.onload = (event) => {
if (event.target.result.match(/^data:image\/webp;base64/)) {
// webpは変換しない
resolve({
url: event.target.result,
caption: file.name,
});
} else {
// webp以外はwebpに変換する
axios.post("/mypage/image/convert", {
base64: event.target.result
}).then(res => {
resolve({
url: "data:image/webp;base64," + res.data,
caption: file.name,
});
}).catch(err => {
// 失敗したらそのまま埋め込みする
resolve({
url: event.target.result,
caption: file.name,
});
});;
}
};
});
}
読み込みが終わった結果(event.target.result)はbase64エンコード済みの文字列なので、ヘッダが以下のようになっています。
data:image/{mime};base64,
{mime}の部分に、jpegとかpngとかgifが入るので、文字列判定で画像のフォーマットを判断することができます。
この画像ファイルがwebpではなかった場合、サーバにbase64エンコード文字列をそのまま送信して、webp変換をやってもらいます。戻って来るのはwebp変換済みのbase64エンコード文字列なので、これにヘッダをつけて返します。
あと、変換は失敗する可能性もあるので(svgやgif等)、失敗した場合はオリジナルをそのまま埋め込みます。
resolveが冗長に分散していますが、これくらいなら冗長なままにしておいた方が後で理解しやすいです。
今回webp対応したことで、ノートの画像埋め込みがやりやすくなったと思います。最大サイズ制限を少し緩和します。
動作確認
どれくらいファイルサイズが小さくなるか確認してみます。
308KB→54.8KBに縮小されました。base64になるとバイナリより1.5倍ほど大きくなるので、保存したサイズは72KBになります。
画像サイズ(px)は変更されていません。
一応、サーバ側で指定の最大サイズを超過したらサイズ(px)縮小するようにしてあります。
この縮小処理も少し変わったことをやっていて、指定サイズを超えたら即縮小するのではなく、指定サイズの閾値以上(例えば1.6倍)であった場合に、指定サイズに縮小しています。
既知の問題
深堀りしていませんが、8bit化されたpngはwebp変換できませんでした。svgやgifも無理です。8bit画像を展開してフルカラー化して変換しても失敗しますね。もうちょっと調べたら解決策がみつかりそうですがそもそも8bit画像を使う人があんまりいないのでそのままにしています。
まとめ
webpが主流になるかどうかはわかりませんが、画像品質を保ったまま高い圧縮率を期待できるので、貸し本棚では標準画像形式にしていこうと思います。
見出しは UnsplashのAlexander Schimmeckが撮影した写真