Reactで、画像に文字を入れて保存する方法
画像に文字を入れて保存するには、画像を canvas に描き、その上に fillText() で文字を描いてから、toBlob() で PNG にします。React では、入力した文字や位置を state に持ち、変わるたびに useEffect で描き直します。文字を読みやすくする縁取り、長い文の折り返し、Webフォントの読み込み待ちには、それぞれ気をつける点があります。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Playwright で画像を選んで文字を入れ、保存されたファイルを確かめています。試す画像は、SVG で描いた 1200×800 の絵を PNG にしたものです。Webフォントは、検証環境に入れた Noto Serif CJK JP Bold から使う文字だけを取り出した WOFF2 を、同じサーバーから配信しました。Firefox、Safari、スマートフォンのブラウザ、JPEG での保存、縦書き、フォントの読み込みに失敗した場合は試していません。

文字を描く関数
描く処理は、React から切り離して関数にしておきます。
export type Position = 'top' | 'middle' | 'bottom';
export type OverlayOptions = {
text: string;
fontFamily: string; // CSS の font-family の書き方
sizeRatio: number; // 画像の幅に対する文字の大きさ(0.08 なら幅の8%)
position: Position;
color: string;
outline: boolean;
};
export function fontFor(o: OverlayOptions, imageWidth: number): string {
return `bold ${Math.round(imageWidth * o.sizeRatio)}px ${o.fontFamily}`;
}
// 1文字ずつ足していき、幅を超えたら次の行へ送る。改行はそのまま行を分ける
export function wrapLines(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const lines: string[] = [];
for (const paragraph of text.split('\n')) {
let line = '';
for (const { segment } of segmenter.segment(paragraph)) {
if (line && ctx.measureText(line + segment).width > maxWidth) {
lines.push(line);
line = segment;
} else {
line += segment;
}
}
lines.push(line);
}
return lines;
}
export function drawOverlay(canvas: HTMLCanvasElement, image: ImageBitmap, o: OverlayOptions) {
// canvas の大きさは、表示の大きさではなく元の画像の大きさにする
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(image, 0, 0);
if (!o.text) return;
const size = Math.round(image.width * o.sizeRatio);
ctx.font = fontFor(o, image.width);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const lines = wrapLines(ctx, o.text, image.width * 0.9);
const lineHeight = size * 1.3;
const blockHeight = lineHeight * lines.length;
const margin = image.height * 0.06;
const top =
o.position === 'top' ? margin
: o.position === 'bottom' ? image.height - margin - blockHeight
: (image.height - blockHeight) / 2;
ctx.lineJoin = 'round';
ctx.lineWidth = size * 0.16;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.75)';
ctx.fillStyle = o.color;
lines.forEach((line, i) => {
const y = top + lineHeight * (i + 0.5);
if (o.outline) ctx.strokeText(line, image.width / 2, y); // 縁取りを先に描き、上から文字を塗る
ctx.fillText(line, image.width / 2, y);
});
}
canvas の width と height は、画面に表示する大きさではなく、元の画像の大きさにします。表示の大きさは CSS の width: 100%; height: auto; で決めます。検証環境では、canvas は 1200×800 のまま、画面には 448×299 で表示され、保存した PNG も 1200×800 でした。
文字の大きさは、画像の幅に対する割合(sizeRatio)で決めています。px で固定すると、大きな写真では文字が小さく、小さな画像では大きくなりすぎるためです。
文字の置き方は、textAlign を center、textBaseline を middle にして、fillText() に渡す座標が文字の真ん中になるようにしました。行の高さは文字の大きさの 1.3 倍で、上か下に置くときは、画像の高さの 6% の余白を空けます。検証環境で「ひなたぼっこ中」を入れて保存したところ、元の画像と画素が違う行は、位置が「下」のとき y=628〜742、「上」のとき y=49〜163 でした。
縁取りは、先に描いてから文字を塗る
写真の上の白い文字は、背景が明るいと読めなくなります。そこで strokeText() で文字の輪郭に線を引き、縁取りにします。このとき、描く順番と lineJoin の値で、見た目が大きく変わります。

MDN の Canvas のチュートリアルにある lineWidth の説明のとおり、線は輪郭を中心に、内側と外側へ線の太さの半分ずつ引かれます。fillText() のあとに strokeText() を描くと、内側に入った線が文字の上に重なり、白い部分を覆ってしまいます。検証環境で 120px の文字に 19px の線を引き、白い画素の数を数えたところ、塗ってから縁取りしたときは 0、縁取りしてから塗ったときは 18,188 でした。
lineJoin の既定の値は miter で、線の角をとがらせてつなぎます。miterLimit の既定は 10 なので、太い線では角から長いとげが伸びます。上の画像の②でも、M の角から上へ長いとげが伸びています。120px の文字に 19px の線を引いて測ったところ、黒い部分は miter では canvas の上の端(y=0)まで届き、round にすると y=22 から始まりました。
長い文は、measureText で幅を測って折り返す
fillText() は、文字列が canvas の幅を超えても折り返しません。検証環境で、幅 3233px になる長い文を 1200px の canvas の中央に描くと、文字は高さ 91px の1行のまま左右の端(x=0〜1199)で切れました。そこで wrapLines() で1文字ずつ足しながら measureText() で幅を測り、画像の幅の 90% を超えたら次の行に送っています。日本語は単語の間に空白がないので、空白で区切らずに文字単位で折り返します。
1200px の画像に 96px の文字で長い文を入れたところ、次の3行に分かれました。かっこの中は、各行の幅です。
窓ぎわでひなたぼっこを(1079px)
していたら、いつのまに(1079px)
か夕方になっていました(1076px)
幅だけで区切っているので、「いつのまにか」の「か」だけが次の行に送られるように、言葉の途中で行が分かれます。読点や句点を行の頭に置かないといった決まり(行頭の禁則)も、この関数では扱っていません。入力欄の改行は split('\n') で行を分けるので、「今日の一番」と「ひなたぼっこ中」の間で改行すれば、そのとおり2行になりました。
1文字ずつ分けるのに Intl.Segmenter を使っているのは、絵文字の途中で分けないためです。家族の絵文字「👨👩👧」は、Array.from() で分けると5つになり、Intl.Segmenter の grapheme で分けると1つでした。幅 100px で折り返すと、絵文字は1つのまま行に入りました。
["ね","こ","👨👩👧","ね","こ"]
Webフォントは、読み込み終わってから描く
検証環境では、ページの中の文字に使っていない Webフォントは、ページを開いた時点で unloaded(まだ読み込んでいない)でした。読み込む前に canvas の font にその名前を指定して描くと、font に並べた代わりのフォントで描かれます。
検証環境で、@font-face で "Demo Serif" という名前を付けた明朝体を用意し、canvas の font に bold 64px "Demo Serif", sans-serif を指定して、すぐに「猫の手も借りたい」を描いたところ、sans-serif だけを指定して描いたものと画素が完全に一致しました。描いた直後には、フォントの状態が loading に変わっていました。フォントの配信を遅らせない場合も、500ms 遅らせた場合も、結果は同じでした。

フォントを読み込み終わってから描くには、document.fonts.load() を使います。font と同じ書き方の文字列と、描く文字を渡すと、必要なフォントを読み込み、読み込みが終わると解決する Promise を返します。MDN の canvas の font の説明にも、canvas で使う前に、Font Loading API でフォントを読み込んでおく例があります。待ってから同じ指定で描くと、明朝体で描かれました。
文字を入れて保存するコンポーネント
import { useEffect, useId, useRef, useState } from 'react';
import { canvasToBlob, downloadBlob, drawOverlay, fontFor, type OverlayOptions, type Position } from './drawText';
type Loaded = { bitmap: ImageBitmap; name: string };
const FONTS = [
{ label: 'ゴシック体', value: 'sans-serif' },
{ label: '明朝体(Webフォント)', value: '"Demo Serif", sans-serif' },
];
export function TextOverlay() {
const [image, setImage] = useState<Loaded | null>(null);
const [text, setText] = useState('');
const [fontFamily, setFontFamily] = useState(FONTS[0].value);
const [position, setPosition] = useState<Position>('bottom');
const [color, setColor] = useState('#ffffff');
const [outline, setOutline] = useState(true);
const [saved, setSaved] = useState('');
const canvasRef = useRef<HTMLCanvasElement>(null);
const id = useId();
// 画像を選び直したら、前の ImageBitmap を解放する
useEffect(() => () => image?.bitmap.close(), [image]);
useEffect(() => {
const canvas = canvasRef.current;
if (!image || !canvas) return;
const options: OverlayOptions = { text, fontFamily, sizeRatio: 0.08, position, color, outline };
let ignore = false;
// Webフォントは、読み込み終わってから描く
document.fonts.load(fontFor(options, image.bitmap.width), text || ' ').then(() => {
if (!ignore) drawOverlay(canvas, image.bitmap, options);
});
return () => {
ignore = true;
};
}, [image, text, fontFamily, position, color, outline]);
async function handleFile(file: File | undefined) {
if (!file) return;
setImage({ bitmap: await createImageBitmap(file), name: file.name });
setSaved('');
}
async function save() {
const canvas = canvasRef.current;
if (!image || !canvas) return;
const blob = await canvasToBlob(canvas, 'image/png');
const filename = image.name.replace(/\.[^.]+$/, '') + '-text.png';
downloadBlob(blob, filename);
setSaved(`${filename}(${canvas.width}×${canvas.height})を保存しました`);
}
return (
<div className="overlay">
<p>
<label htmlFor={`${id}-file`}>画像</label>
<input id={`${id}-file`} type="file" accept="image/*" onChange={(e) => handleFile(e.target.files?.[0])} />
</p>
<p>
<label htmlFor={`${id}-text`}>入れる文字</label>
<textarea id={`${id}-text`} rows={2} value={text} onChange={(e) => setText(e.target.value)} />
</p>
<p className="row">
<label>
書体
<select value={fontFamily} onChange={(e) => setFontFamily(e.target.value)}>
{FONTS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
</select>
</label>
<label>
位置
<select value={position} onChange={(e) => setPosition(e.target.value as Position)}>
<option value="top">上</option>
<option value="middle">中央</option>
<option value="bottom">下</option>
</select>
</label>
<label>
色
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} />
</label>
<label>
<input type="checkbox" checked={outline} onChange={(e) => setOutline(e.target.checked)} />
縁取り
</label>
</p>
{image && <canvas ref={canvasRef} aria-label="文字を入れた画像" />}
<p>
<button type="button" onClick={save} disabled={!image}>PNGで保存</button>
</p>
<p className="saved" aria-live="polite">{saved}</p>
</div>
);
}
state に持つのは、選んだ画像と、文字・書体・位置・色・縁取りの設定です。描いた結果は state に持たず、canvas そのものに残します。設定が変わるたびに、useEffect の中で元の画像から描き直します。前に入れた文字の上に描き足すのではなく、毎回 drawImage() で元の画像から描くので、位置を変えても前の文字は残りません。
描く前には、毎回 document.fonts.load() を待ちます。待っている間に設定が変わったときは、クリーンアップで ignore を true にし、古い設定で描かないようにしています。React の公式ドキュメントにある、データを取得する Effect でのクリーンアップと同じ書き方です。
フォントの配信を 800ms 遅らせて、書体を明朝体に切り替えたところ、200ms 後の canvas はゴシック体のときと同じ絵のままでした。フォントを読み込んだあと、切り替えてから約 1000ms の時点では明朝体で描き直されていて、そのまま保存できました。
画像は createImageBitmap() で読み込み、選び直したら前の ImageBitmap を close() で解放します。useEffect のクリーンアップに書いておくと、image が変わったときと、コンポーネントが画面から取り除かれる(アンマウントされる)ときの両方で呼ばれます。React の公式ドキュメントにあるクリーンアップが呼ばれるタイミングの説明のとおりです。
PNG に書き出して、ファイル名を付けて保存する
保存に使う2つの関数は、次のとおりです。以前の記事「Reactで、画像を回転・反転して保存する方法」で使ったものと同じです。
export function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('画像を書き出せませんでした'))), type, quality);
});
}
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
canvas を toBlob() で PNG の Blob にし、download 属性を付けたリンクをクリックしてダウンロードさせます。検証環境で cat.png に文字を入れて保存すると、cat-text.png という名前の 1200×800 の PNG がダウンロードされました。長い文を中央に3行で入れたときも、明朝体に切り替えたときも、同じ大きさで保存できています。





