【Node.js】 既存画像に文字を描画し圧縮して出力する
更新日:2021/03/27
ウェブサイトを作成していると、時々OGP画像をセットしたほうがいいような気がしてきます。
しかしサイトの記事が多い状態から一つ一つ画像を作成していくのは、いまさら無理なお話です。
そこで、画像を一枚だけ用意して、その画像に記事タイトルを描画することで妥協することにしました。
ただし、画像サイズが大きいのは不利な面があるので、しっかり最適化します。
文字描画の流れ
今回は次の既存画像の中央の枠内に、文字を描画してみます。
文字は一度SVGデータに変換してから、既存画像に描画します。
おおまかな処理の流れは次のようになります。
(1) 文字をSVGに変換する
(2) SVGを既存画像に描画する
(3) 画像を最適化して出力する
上手くできるかわかりませんが、とにかくやってみます。
SVGデータの作成
文字列のSVG化にはtext-to-svgを使用します。
text-to-svgのインストール
まずはnpmでtext-to-svgをインストールします。
npm install text-to-svg
text-to-svgの使い方
text-to-svgの使い方を簡単に解説します。
■text-to-svgのロード
ロードはrequireで。
お約束ですね。
const TextToSVG = require("text-to-svg");
■フォントのロード
loadSyncを使ってフォントを指定します。
フォントを指定しないと、IPAフォントを使用されます。
const textToSVG = TextToSVG.loadSync( フォントのパス );
拡張子がttcのフォントはエラーになります。
windows10の日本語フォントはほとんどがttcなので、少し使いにくいです。
フリーのttfフォントを探してくるのも、いいですね。
■SVGデータの取得
getSVGメソッドで、SVGデータを取得します。
const svgOptions = {x: 0, y: 0, fontSize: 68, anchor: "top", attributes: {fill: "none", stroke: "white"}};
const svgData = textToSVG.getSVG( 文字列 , svgOptions );
オプション値は、次のプロパティを持つオブジェクトです。
オプション | 意味 | 規定値 |
---|---|---|
x | 水平方向の開始位置 | 0 |
y | 垂直方向の開始位置 | 0 |
fontSize | フォントサイズ | 72 |
kerning | カーニング情報を考慮するかどうか (true/false) | true |
letterSpacing | 文字間隔 1:1文字分 | |
tracking | トラッキング値 | |
anchor | 文字の配置 left, center, right のどれか | "left baseline" |
attributes | SVG 属性 |
trackingは、よくわかりません。
attributesのfillは塗りつぶし色を、strokeは文字の外枠の色を指定しています。
fillにnoneを指定すると、白枠のみの文字を描画できます。
■描画サイズの取得
getMetricsは、描画後の情報を取得できます。
const {width,height} = textToSVG.getMetrics( 文字列 , svgOptions );
getMetricsは、次のプロパティを持つオブジェクトを返します。
プロパティ | 意味 |
---|---|
x | 水平方向の開始位置 |
y | 垂直方向の開始位置 |
baseline | ベース位置 |
width | 幅 |
height | 高さ |
ascender | baselineから見た文字の上辺位置(プラス) |
descender | baselineから見た文字の下辺位置(マイナス) |
文字列を分割してSVGデータに変換するコード
今回は既存画像の中央に文字を描画します。
長い文字は、一行に収まらないので行替えをする必要があります。
しかしSVGデータは折り返しての描画ができません。
仕方がないので、一行ごとにSVGデータを作成します。
const fontFile = "c:\\Windows\\Fonts\\TA_marugo_gf_1.ttf";
const textText = "【Node.js】 既存画像に文字を描画し圧縮して出力しようと思うがどうだろうか?";
const TextToSVG = require("text-to-svg");
const textToSVG = TextToSVG.loadSync(fontFile);
const svgOptions = {x: 0, y: 0, fontSize: 68, anchor: "left top",
attributes: {fill: "none", stroke: "white"}};
const MAXWIDTH = 690;
const MAXHEIGHT = 422;
const LINESPACING = 15;
const textSVGs =
[...textText].reduce(
( acc , val )=> {
// 終了(描画範囲から溢れた)確認
if( acc[0].lastText === null ) return acc;
// 前回確認した文字列と結合し、行内に収まるか確認
const text = acc[0].lastText + val;
const {width,height} = textToSVG.getMetrics( text , svgOptions );
if( width > MAXWIDTH ) { // 行内に収まらなかった
// 前回までの文字列をSVG化し配列に追加
acc[0].svgBuffer.push( {svg:textToSVG.getSVG(acc[0].lastText, svgOptions),
top:acc[0].allHeight} );
// 次回確認する文字列を更新
acc[0].lastText = val;
// これまでの総高さを更新
acc[0].allHeight += height + LINESPACING;
// 次回追加予定の行が描画範囲外になるなら、処理終了
if( acc[0].allHeight + height > MAXHEIGHT ) acc[0].lastText = null;
}else{ // 行内に収まる
// 次回確認する文字列を更新
acc[0].lastText = text;
}
return acc;
},[{lastText:"",allHeight:0,svgBuffer:[]}]
).reduce( ( n , svgData ) =>{
// 残った文字列をSVG化し、配列に追加
const {lastText,allHeight,svgBuffer,} = svgData;
if( lastText !== null && lastText.length > 0 ) {
svgBuffer.push( {svg:textToSVG.getSVG(lastText, svgOptions),
top:allHeight} );
svgData.allHeight = allHeight+textToSVG.getMetrics( lastText , svgOptions ).height;
}
return svgData;
},null);
やっていることは単純です。
一文字ずつ連結しながらgetMetricsメソッドで長さを確認しています。
コード中の[...textText] は、サロゲートペアを考慮した文字分割をおこなっています。
textText.split("")などを使うよりもお手軽です。
続くreduceメソッドは、初期値として [ { lastText:"", allHeight:0, svgBuffer:[ ] } ] を与えています。
lastTextは確認中の文字列。
allHeightは、作成済みSVGデータの高さと行間(LINESPACING)を合計したもの。
svgBufferは{ svg:SVGデータ,top:上辺位置 }を要素に持つ配列です。
reduceメソッドのコールバック関数は、この初期値として与えた配列を操作してSVGデータを構築していきます。
実際にはreduceではなくてforEachを使用する方がわかりやすいかもしれません。
しかしその場合は、lastTextなどの変数を外部でlet宣言する必要があります。
それが嫌だったので、無理やりreduceを使用しています。
初期値をわざわざ配列にしているのは、reduceをメソッドチェーンするためです。
このreduceのコールバック関数は第一引数を無視し、第二引数のみ処理をしています。
既存画像に文字を描画
SVGデータを既存画像に描画するために、今回はsharpというライブラリを使用します。
sharpのインストール
まずはnpmでsharpをインストールします。
npm install sharp
sharpの使い方
使い方の解説が長くなってしまったので、別の記事に分割しました。
次のページをご覧ください。
■【Node.js】 画像処理モジュールsharpの使い方を網羅してみました
文字列と既存画像を合成するコード
文字列を分割してSVGデータに変換するコードの続きです。
ここでは縦方向の位置調整と、下地となる画像に文字列を描画しています。
const sharp = require("sharp");
const TOPX = 182;
const LEFTY = 232;
// 描画範囲の垂直方向の中央に描画するように、
// 開始上位置の計算をする
const newTopX = (MAXHEIGHT - textSVGs.allHeight)/2;
// sharpのcompositeメソッドに渡す引数値を作成
const compositeArgs = textSVGs.svgBuffer.map(
svgData => ({
input: Buffer.from(svgData.svg),
top:Math.floor(TOPX + svgData.top + newTopX),
left:LEFTY,
})
);
const imgBuffer =
await sharp( "inputimage.png" )
.composite( compositeArgs )
.png({compressionLevel: 9 ,quality:0})
.toBuffer();
画像データを圧縮して出力
sharpにはtoFileという画像データをファイル出力するメソッドがあります。
しかし最適化をしてくれないので、画像サイズが大きくなってしまいます。
そこでimagemin-pngquantモジュールを使って最適化後、ファイルに出力します。
ちなみに、jpegを最適化したいときはimagemin-jpegoptimが使用できそうです。
imagemin-pngquantのインストール
まずはnpmでimagemin-pngquantをインストールします。
npm install imagemin-pngquant
imagemin-pngquantの使い方
imagemin-pngquantは次のように使用します。
const imageminPngquant = require("imagemin-pngquant");
(async ()=>{
await imageminPngquant( オプション )( 入力バッファ );
・・・続きの処理
})();
オプションは、次のプロパティを持つオブジェクトです。
プロパティ名 | 意味 | 規定値 |
---|---|---|
speed | 処理速度。1から11。 値が低いほど遅くなるが、出力サイズが小さくなる | 4 |
strip | メタデータを削除するかどうか | false |
quality | 最適化後の品質を二つの要素を持つ配列で指定します。 値は0.0から1.0の範囲で指定します。 | [0.3, 0.5] |
dithering | ディザリングレベルを設定します。 値は0.0から1.0です。 | 1 |
posterize | チャネルごとに指定されたビット数を切り捨てます。 0から4 | 0 |
verbose | 詳細なステータスメッセージを出力します | false |
画像データを圧縮して出力するコード
文字列と既存画像を合成するコードの続きです。
const imageminPngquant = require("imagemin-pngquant");
const fs = require('fs').promises;
(async ()=>{
const pngBuffer =
await imageminPngquant({
speed: 1,
quality: [0.5,0.8]
})(imgBuffer);
await fs.writeFile("outputimage.png", pngBuffer);
})();
補足:JPEGフォーマットを最適化する
JPEG画像を取り扱うケースもあるので、補足しておきます。
JPEGは、imagemin-jpegoptimで最適化します。
const imageminJpegoptim = require("imagemin-jpegoptim");
(async ()=>{
await imageminJpegoptim( オプション )( 入力バッファ );
・・・続きの処理
})();
オプションは、次のプロパティを持つオブジェクトです。
プロパティ名 | 意味 | 規定値 |
---|---|---|
progressive | プログレッシブへのロスレス変換をするかどうか | false |
max | 最大画質係数。0から100 | false |
size | 指定サイズを目標に最適化する。キロバイト(数値のみ)またはパーセント(1%から99%)で指定する | |
stripAll | すべてのマーカーを取り除く | true |
stripCom | コメントマーカーを削除する | true |
stripExif | Exifマーカーを削除する | true |
stripIptc | IPTC/Photoshop (APP13)マーカーを削除する | true |
stripIcc | ICCプロファイルマーカーを削除する | true |
stripXmp | XMPマーカーを削除する | true |
全ソースコードと実行結果
ということで、これまでのソースを一つにまとめました。
いろいろ整理してあるので、単純に結合したものではないのはご容赦ください。
(async ()=>{
const TextToSVG = require("text-to-svg");
const sharp = require("sharp");
const imageminPngquant = require("imagemin-pngquant");
const fs = require('fs').promises;
const fontFile = "c:\\Windows\\Fonts\\TA_marugo_gf_1.ttf";
const textText = "【Node.js】 既存画像に文字を描画し圧縮して出力しようと思うがどうだろうか?";
const textToSVG = TextToSVG.loadSync(fontFile);
const svgOptions = {x: 0, y: 0, fontSize: 68, anchor: 'left top', attributes: {fill: "none", stroke: "white"}};
const MAXWIDTH = 690;
const MAXHEIGHT = 422;
const LINESPACING = 0;
const textSVGs =
[...textText].reduce(
( acc , val )=> {
if( acc[0].lastText === null ) return acc;
const text = acc[0].lastText + val;
const {width,height} = textToSVG.getMetrics( text , svgOptions );
if( width > MAXWIDTH ) {
acc[0].svgBuffer.push( {svg:textToSVG.getSVG(acc[0].lastText, svgOptions),
top:acc[0].allHeight} );
acc[0].lastText = val;
acc[0].allHeight += height + LINESPACING;
if( acc[0].allHeight + height > MAXHEIGHT ) acc[0].lastText = null;
}else{
acc[0].lastText = text;
}
return acc;
},[{lastText:"",allHeight:0,svgBuffer:[]}]
).reduce( ( n , svgData ) =>{
const {lastText,allHeight,svgBuffer,} = svgData;
if( lastText !== null && lastText.length > 0 ) {
svgBuffer.push( {svg:textToSVG.getSVG(lastText, svgOptions),
top:allHeight} );
svgData.allHeight = allHeight+textToSVG.getMetrics( lastText , svgOptions ).height;
}
return svgData;
},null);
const TOPX = 182;
const LEFTY = 232;
const newTopX = (MAXHEIGHT - textSVGs.allHeight)/2;
const compositeArgs = textSVGs.svgBuffer.map(
svgData => ({
input: Buffer.from(svgData.svg),
top:Math.floor(TOPX + svgData.top + newTopX),
left:LEFTY,
})
);
const imgBuffer =
await sharp("inputimage.png")
.composite(compositeArgs)
.png({compressionLevel: 9 ,quality:0})
.toBuffer();
const pngBuffer =
await imageminPngquant({
speed: 1,
quality: [0.5,0.8]
})(imgBuffer);
await fs.writeFile("outputimage.png", pngBuffer)
})();
これを実行すると、次のようになりました。
どうやら成功したようです。
更新日:2021/03/27
関連記事
スポンサーリンク
記事の内容について
こんにちはけーちゃんです。
説明するのって難しいですね。
「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。
裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。
掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。
ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php
このサイトは、リンクフリーです。大歓迎です。