Node.jsでブログサムネを自動生成|sharp + canvasで画像にテキスト描画

概要

Node.jsで画像にテキストを描画してサムネイルを自動生成する方法について解説していきます。

ブログを書いていると毎回アイキャッチ用のサムネイルを用意することになるのですが、「画像編集ツールを開く → 文字を置く → 書き出す → アップロードする」という一連の作業がとにかく面倒なんですよね(- -;
記事を量産しようとするほど、この地味な手作業がボトルネックになってきます。

そこで今回は背景画像の上に日本語テキストを焼き込んで、コマンド一発でサムネイル画像を吐き出すスクリプトをNode.jsで自作しました。
使うのは画像変換の定番sharpと、文字描画用のcanvasの2つだけです。

実装としてはcanvasで背景+テキストを合成し、最後にsharpでJPEGとして書き出す、という流れになります。
自動改行や複数行の中央寄せまで入れているので、タイトルが長くてもそれなりに見られるサムネが出来上がります。

この仕組みを一度作ってしまえば、毎回のサムネ作成作業を実質ゼロにできるのでかなりお勧めです。
それではやっていきましょう!

目次

サムネ自動生成に使うライブラリ

画像にテキストを描画する処理は、大きく分けて「文字を描く部分」と「画像を書き出す部分」の2つで構成します。
このブログでは前者にcanvas、後者にsharpを使っています。

  • canvas
    • ブラウザのCanvas APIと同じ感覚でNode.js上に図形やテキストを描画できるライブラリ。日本語フォントの登録(registerFont)もできます
  • sharp
    • 高速な画像変換ライブラリ。今回はcanvasで作った画像をJPEGとして圧縮・書き出すために使います

どちらもサーバーサイドの画像処理では定番なので、覚えておいて損はないですね。

実装手順

1.npmでライブラリをインストール

今回の実装で使用するライブラリをnpmでインストールします。
sharpとcanvasを使用してます。

コマンド

1
npm install sharp @napi-rs/canvas

2.使用したいフォントを取得

画像に配置する文字のフォントをダウンロードします。
自分の好きなフォントでOKです!

自分の場合は「SourceHanSansJP-Bold.otf」を使用しました。

ファイルの形式はttfやotfなら無問題です(^^b

3.JavaScriptを書く

JavaScriptで下記のようにソースコードを書きます。

image_text.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
import sharp from "sharp";
import { createCanvas, registerFont, loadImage } from "canvas";
import path from "path";
import { fileURLToPath } from "url";

// ------------------------------------------------------
// 定数定義
// ------------------------------------------------------
const bgPath = "background.jpg";
const fontPath = "SourceHanSansJP-Bold.otf";
const outPath = "C:\\cover";
const fontSize = 128;

// 半角判定(英数字・記号)
function isHalfWidth(ch) {
return /^[\x00-\x7F]$/.test(ch);
}

// ------------------------------------------------------
// 1行分を1文字ずつ描画する関数
// ------------------------------------------------------
function drawTextLine(ctx, text, xCenter, y, fontSize) {
let x = xCenter - ctx.measureText(text).width / 2;

for (const ch of text) {
const w = ctx.measureText(ch).width;

ctx.fillText(ch, x + w / 2, y);

x += w;
}
}

// ------------------------------------------------------
// テキスト内に"\n"という文字列があった場合、改行を付与
// ------------------------------------------------------
function splitTextWithNewline(ctx, text, maxWidth) {
const rawLines = text.split("\\n"); // ← "\\n" という文字列で区切る
const result = [];

rawLines.forEach(segment => {
const wrappedLines = wrapText(ctx, segment, maxWidth);
result.push(...wrappedLines);
});

return result;
}

// ------------------------------------------------------
// テキストを画像幅に合わせて自動改行
// ------------------------------------------------------
function wrapText(ctx, text, maxWidth) {
const lines = [];
let line = "";

for (const char of text) {
const testLine = line + char;
const { width: testWidth } = ctx.measureText(testLine);

if (testWidth > maxWidth && line !== "") {
lines.push(line);
line = char;
} else {
line = testLine;
}
}
if (line) lines.push(line);

return lines;
}

// ------------------------------------------------------
// メイン処理
// ------------------------------------------------------
async function drawTextOnImage(backgroundFile, text, ouputPath,filename, fontFile) {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const backgroundPath = path.join(__dirname, backgroundFile);

const image = sharp(backgroundPath);
const { width, height } = await image.metadata();

const fontPath = path.join(__dirname, fontFile);
registerFont(fontPath, { family: "SHSJP" });

const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");

const bgImage = await loadImage(backgroundPath);
ctx.drawImage(bgImage, 0, 0, width, height);

ctx.font = `${fontSize}px 'SHSJP'`;
ctx.fillStyle = "black";
ctx.textAlign = "center";
ctx.textBaseline = "middle";

// --------------------------------------------------
// 自動改行処理(最大横幅は余裕を持たせて 90% に設定)
// --------------------------------------------------
const maxTextWidth = width * 0.9;
const lines = splitTextWithNewline(ctx, text, maxTextWidth);

// --------------------------------------------------
// すべての行を中央に配置するための Y 計算
// --------------------------------------------------
const lineHeight = fontSize * 1.2;
const totalHeight = lines.length * lineHeight;
const startY = height / 2 - totalHeight / 2 + lineHeight / 2;

// --------------------------------------------------
// 行ごとに描画
// --------------------------------------------------
// メイン描画部分に組み込む
lines.forEach((line, i) => {
const y = startY + i * lineHeight;
drawTextLine(ctx, line, width / 2, y, fontSize);
});

// --------------------------------------------------
// 画像に出力
// --------------------------------------------------
// const filename = filename.replace(/[\\/:*?"<>|]/g, "_");
const outputImagePath = path.join(ouputPath, filename + ".jpg");

const outBuffer = canvas.toBuffer("image/png");
await sharp(outBuffer)
.jpeg({ quality: 90 })
.toFile(outputImagePath);

console.log("出力完了:", outputImagePath);
}

// ------------------------------------------------------
// 実行
// ------------------------------------------------------
const [, , filename, text] = process.argv;

if (!filename || !text) {
console.log("使い方: node image_text.js <出力ファイル名> <テキスト>");
process.exit(1);
}

drawTextOnImage(bgPath, text, outPath,filename, fontPath);

少しだけ処理のポイントを補足しておきますね。

  • 1文字ずつ描画している(drawTextLine)
    • fillTextで行をまとめて描くと文字間が詰まりがちなので、1文字ずつ幅を測って並べることで字間を安定させています
  • 自動改行と明示的な改行の両対応(wrapText / splitTextWithNewline)
    • 画像幅の90%を超えたら自動で折り返しつつ、テキスト中に\nという文字列があればそこでも改行します
  • 複数行を縦中央に寄せる
    • 行数×行高から全体の高さを求め、画像の中央に来るよう開始Y座標を計算しています
  • 最後にsharpでJPEG化
    • canvasのバッファをsharpに渡してquality: 90でJPEG出力。背景画像の上に文字が乗った状態で書き出されます

また定数定義部分については、個人の環境によってご変更ください

  • 変更箇所
    • const bgPath = “[背景に使用する画像]”;
    • const fontPath = “[フォント(otfなど)]”;
    • const outPath = “[完成画像の出力フォルダ]”;

4.フォルダに配置

任意のフォルダに「手順.2のフォント」と「手順.3のプログラム」を配置します。

自分の場合は下記の画像のようなフォルダ構成にしています。

フォント・プログラム・背景画像を同じフォルダに配置した構成
サムネイル生成スクリプトのフォルダ構成
この記事のプログラムをそのまま使用するなら、 同じフォルダにすべてのファイルを配置しましょう!

5.プログラムを実行

プログラムを実行して画像を生成してみます。

コマンド

1
node C:/tool/image_text.js "test_test" "テストですよ\nテスト123テストhogeテスト123テスト"
実行時に自分の場合は「couldn't load font」エラーが発生しました。
うまくフォントが読み込めて無いようです。。。
本当は修正したいのですが、今回の場合は問題なく画像出力できたのでここでは無視してます。

6.確認

出力された画像を確認します。
無事出力されてそうですね!

背景画像に日本語テキストを描画したサムネイルの出力結果
背景画像に日本語テキストを描画したサムネイルの出力結果

canvasが動作しない場合の対処

環境によってはcanvasのインストール直後にうまく動かないことがあります。
そういうときはcanvasを入れ直すと直るケースが多いので、まずは再インストールを試してみてください。

コマンド

1
2
npm uninstall @napi-rs/canvas
npm install canvas

canvasはネイティブモジュールに依存しているため、OSやNode.jsのバージョン差で読み込みに失敗することがあります。
再インストールしても直らない場合は、Node.jsのバージョンを揃えるか、ビルドツールが入っているかを確認してみると良いですね。

締め

こんな感じでサムネ生成ツールを作ってみました。

結構サムネイルを作る効率が上がったのではないでしょうか?

10分程度の作業時間が1分にできるんですから、塵積で大幅な時間短縮になりますよね!

最初はpythonで実装しようとしたのですが、自分のブログでの使用言語がjsだったので、こっちで実装してみました。
結構苦労はしましたが、無事実装できてよかったです(^^

こんな感じの便利ツールは実際の業務とかでも作ったりしているので、できる範囲でどんどん記事にしてきますね!

今回は以上となります。
ツール作りは楽しいので、是非皆さんもやりましょう!
お疲れさまでした。