Reactで、画像に文字を入れて保存する方法

​​React​Canvas API,CSS Font Loading API,Playwright,TypeScript

画像に文字を入れて保存するには、画像を 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 での保存、縦書き、フォントの読み込みに失敗した場合は試していません。

文字を入れる欄、書体・位置・色・縁取りの設定、猫の絵の画像、「PNGで保存」ボタンが縦に並んだ画面。「ひなたぼっこ中」と入力すると、画像の下のほうに白い文字が黒く縁取られて入り、画像の枠がピンクになる。位置を「上」に変えると文字が上へ移る。保存を押すと「cat-text.png(1200×800)を保存しました」がピンクで出る。下の帯に、何回目の操作かが出ている
文字の入力、位置の変更、保存の3回の操作。文字を入れ終えたとき・位置を変えたとき・保存したときに、画像の枠や表示がピンクになる

文字を描く関数

描く処理は、React から切り離して関数にしておきます。

ts
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 の値で、見た目が大きく変わります。

青い背景に「猫の手 M」を3通りに描いて並べた画像。①塗ってから縁取りすると、文字が黒い線に埋もれて白い部分が見えず、角から黒いとげが伸びる。②縁取りしてから塗ると白い文字が見えるが、M の上下や「猫」の左に黒いとげが残る。③さらに lineJoin を round にすると、とげが消えて角が丸い縁取りになる
縁取りと塗りの順番、lineJoin の値を変えて描いた3通り

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行に分かれました。かっこの中は、各行の幅です。

text
窓ぎわでひなたぼっこを(1079px)
していたら、いつのまに(1079px)
か夕方になっていました(1076px)

幅だけで区切っているので、「いつのまにか」の「か」だけが次の行に送られるように、言葉の途中で行が分かれます。読点や句点を行の頭に置かないといった決まり(行頭の禁則)も、この関数では扱っていません。入力欄の改行は split('\n') で行を分けるので、「今日の一番」と「ひなたぼっこ中」の間で改行すれば、そのとおり2行になりました。

1文字ずつ分けるのに Intl.Segmenter を使っているのは、絵文字の途中で分けないためです。家族の絵文字「👨‍👩‍👧」は、Array.from() で分けると5つになり、Intl.Segmenter の grapheme で分けると1つでした。幅 100px で折り返すと、絵文字は1つのまま行に入りました。

text
["ね","こ","👨‍👩‍👧","ね","こ"]

Webフォントは、読み込み終わってから描く

検証環境では、ページの中の文字に使っていない Webフォントは、ページを開いた時点で unloaded(まだ読み込んでいない)でした。読み込む前に canvas の font にその名前を指定して描くと、font に並べた代わりのフォントで描かれます。

検証環境で、@font-face で "Demo Serif" という名前を付けた明朝体を用意し、canvas の font に bold 64px "Demo Serif", sans-serif を指定して、すぐに「猫の手も借りたい」を描いたところ、sans-serif だけを指定して描いたものと画素が完全に一致しました。描いた直後には、フォントの状態が loading に変わっていました。フォントの配信を遅らせない場合も、500ms 遅らせた場合も、結果は同じでした。

上下2段に並べた画像。上は「読み込む前に描いた(代わりのゴシック体になる)」で、「猫の手も借りたい」がゴシック体で描かれている。下は「document.fonts.load() を待ってから描いた」で、同じ文が明朝体で描かれている
同じ font の指定で、Webフォントを読み込む前と後に描いた文字

フォントを読み込み終わってから描くには、document.fonts.load() を使います。font と同じ書き方の文字列と、描く文字を渡すと、必要なフォントを読み込み、読み込みが終わると解決する Promise を返します。MDN の canvas の font の説明にも、canvas で使う前に、Font Loading API でフォントを読み込んでおく例があります。待ってから同じ指定で描くと、明朝体で描かれました。

文字を入れて保存するコンポーネント

tsx
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で、画像を回転・反転して保存する方法」で使ったものと同じです。

ts
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行で入れたときも、明朝体に切り替えたときも、同じ大きさで保存できています。

​Posted by 86