Sharpサーバーサイド画像処理

【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 のどれか
+ baseline, top, middle, bottom のどれか

"left baseline"
attributesSVG 属性

trackingは、よくわかりません。

attributesのfillは塗りつぶし色を、strokeは文字の外枠の色を指定しています。
fillにnoneを指定すると、白枠のみの文字を描画できます。

■描画サイズの取得

getMetricsは、描画後の情報を取得できます。


const {width,height} = textToSVG.getMetrics( 文字列 , svgOptions );

getMetricsは、次のプロパティを持つオブジェクトを返します。

プロパティ意味
x水平方向の開始位置
y垂直方向の開始位置
baselineベース位置
width
height高さ
ascenderbaselineから見た文字の上辺位置(プラス)
descenderbaselineから見た文字の下辺位置(マイナス)

文字列を分割して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です。
またはfalseを指定することで無効にできます

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から100false
size指定サイズを目標に最適化する。キロバイト(数値のみ)またはパーセント(1%から99%)で指定する
stripAllすべてのマーカーを取り除くtrue
stripComコメントマーカーを削除するtrue
stripExifExifマーカーを削除するtrue
stripIptcIPTC/Photoshop (APP13)マーカーを削除するtrue
stripIccICCプロファイルマーカーを削除するtrue
stripXmpXMPマーカーを削除する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

書いた人(管理人):けーちゃん

スポンサーリンク

記事の内容について

null

こんにちはけーちゃんです。
説明するのって難しいですね。

「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。

裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。

掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。

ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php

 

このサイトは、リンクフリーです。大歓迎です。