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

​​React​Canvas API,Playwright,TypeScript

画像の全面に斜めの透かしを入れるには、透かしの文字を描いた小さな canvas を createPattern() でパターンにし、そのパターンを回して canvas 全体を塗ります。薄くするときは、文字と縁取りを不透明で描いたあとで、パターンごとまとめて globalAlpha をかけます。保存は toBlob() で JPEG か PNG にします。JPEG は透明な部分を持てないので、下地を先に塗っておきます。

補足

検証環境は 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 で描いた絵を Chromium で JPEG・PNG にしたもので、カメラで撮った写真ではありません。Firefox、Safari、スマートフォンのブラウザ、写真の向き(Exif の回転の情報)、Webフォントで描く場合、WebP での保存は試していません。

透かしの文字の入力欄、濃さのつまみ、角度と形式の選択、猫の絵の画像、「保存」ボタンが縦に並んだ画面。文字を「猫の手 見本」に変えると、画像の全面に斜めに並んだ透かしが変わり、画像の枠がピンクになる。濃さのつまみを 60% まで動かすと透かしが濃くなり、角度を 45° にすると傾きが変わる。保存を押すと「cat-wm.jpg(1200×800、195KB)を保存しました」がピンクで出る。下の帯に、何回目の操作かが出ている
文字の変更、濃さの変更、角度の変更、保存の4回の操作。絵が変わったときに画像の枠が、保存したときに表示がピンクになる

透かしを描く関数

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

ts
export type WatermarkOptions = {
  text: string;
  opacity: number; // 0〜1
  angle: number; // 傾ける角度(度)。右上がりにしたいので、描くときは -angle で回す
  sizeRatio: number; // 画像の短い辺に対する文字の大きさ(0.06 なら短い辺の6%)
  background?: string; // JPEG で保存するときの下地の色
};

// 透かしの文字を不透明で描いた「タイル」。2行目は半分ずらして、れんが積みにする
export function makeTile(text: string, size: number): HTMLCanvasElement {
  const tile = document.createElement('canvas');
  const ctx = tile.getContext('2d')!;
  const font = `bold ${size}px sans-serif`;
  ctx.font = font;
  const w = Math.ceil(ctx.measureText(text).width) + size * 2; // 左右の間隔は文字2つ分
  const h = size * 2.5; // 1段の高さ(文字と上下の間隔を合わせて)
  tile.width = w;
  tile.height = h * 2;
  // canvas の大きさを変えると描く設定が初期値に戻るので、ここで設定し直す
  ctx.font = font;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.lineJoin = 'round';
  ctx.lineWidth = size * 0.12;
  ctx.strokeStyle = '#000000';
  ctx.fillStyle = '#ffffff';
  const put = (x: number, y: number) => {
    ctx.strokeText(text, x, y);
    ctx.fillText(text, x, y);
  };
  put(w / 2, h / 2);
  put(0, h * 1.5); // 左右の端で半分ずつ切れるが、並べるとつながる
  put(w, h * 1.5);
  return tile;
}

export function drawWatermark(canvas: HTMLCanvasElement, image: ImageBitmap, o: WatermarkOptions) {
  const { width, height } = image;
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d')!;
  if (o.background) {
    ctx.fillStyle = o.background;
    ctx.fillRect(0, 0, width, height);
  }
  ctx.drawImage(image, 0, 0);
  if (!o.text) return;

  const size = Math.max(12, Math.round(Math.min(width, height) * o.sizeRatio));
  const pattern = ctx.createPattern(makeTile(o.text, size), 'repeat')!;
  // 模様を画像の中心のまわりで回す。塗るのは canvas 全体なので、角まで埋まる
  pattern.setTransform(new DOMMatrix().translate(width / 2, height / 2).rotate(-o.angle));
  ctx.globalAlpha = o.opacity;
  ctx.fillStyle = pattern;
  ctx.fillRect(0, 0, width, height);
  ctx.globalAlpha = 1;
}

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);
}

makeTile() は、透かしの文字を描いた小さな canvas(タイル)を作ります。幅は、文字の幅を measureText() で測り、文字2つ分(size * 2)の間隔を足した大きさにします。1段の高さは文字2.5個分(size * 2.5)で、タイルはその2段分の高さです。1段目は中央に、2段目は左右の端に半分ずつ描き、並べたときにれんが積みになるようにしています。

タイルの大きさを決めたあとで font などを設定し直しているのは、canvas の width や height を変えると、描く設定が初期値に戻るためです。検証環境で font を bold 48px sans-serif、fillStyle を #ff0000、textAlign を center にしてから width を変えたところ、それぞれ 10px sans-serif、#000000、start に戻りました。

文字の大きさは、画像の短い辺に対する割合(sizeRatio)で決めています。0.06 にすると、1200×800 の画像では 48px、600×900 の縦長の画像では 36px になりました。

drawWatermark() では、タイルを createPattern() で repeat(縦横にくり返す)のパターンにします。パターンの向きは setTransform() に渡す DOMMatrix で決めます。画像の中心に移してから回すので、模様は中心のまわりで回ります。rotate() の角度の単位は度です。MDN の Canvas のチュートリアルにある座標の説明のとおり、canvas の原点は左上で、y は下へ向かって増えます。そのため、-angle で回すと文字が右上がりになります。

回した文字は、canvas 全体を塗らないと角が空く

文字を斜めに並べるだけなら、ctx.rotate() で canvas を回してから、fillText() を二重のループで並べる書き方もあります。ただし、画像の幅と高さの範囲だけ並べると、回したぶん画像の角に文字が届きません。

上下2段に並べた画像。上は「中心で回してから、画像の範囲だけ並べた」で、左上と右下の角に透かしのない三角形の部分がある。下は「パターンを回して、canvas 全体を塗った」で、角まで透かしが並んでいる
同じ文字・同じ間隔・同じれんが積みで、並べる範囲だけを変えて描いた2通り(30°)

検証環境で、1200×800 の canvas を 100px 角の 96 マスに分け、文字が1画素も入らないマスを数えました。回してから画像の範囲だけ並べた場合は、30° で 10 マス、45° で 12 マスが空きました。空いたマスは、どちらも画像の左上と右下の角に集まっています。パターンを回して canvas 全体を fillRect() で塗った場合は、30° でも 45° でも空いたマスは 0 でした。

パターンは canvas のどこを塗ってもくり返して続くので、回したあとに並べる範囲を計算しなくて済みます。

薄くするのは、縁取りと文字を描き終えてから

透かしは、明るい背景でも暗い背景でも見えるように、白い文字に黒い縁取りを付けています。縁取りは strokeText() で文字の輪郭に線を引き、その上から fillText() で文字を塗ります。

ここで、globalAlpha を 0.3 にしてから縁取りと文字を描くと、2つが重なったところで色が混ざります。MDN の Canvas のチュートリアルにある lineWidth の説明のとおり、線は輪郭を中心に、内側と外側へ半分ずつ引かれます。そのため文字の内側では、薄い黒の線の上に、薄い白の文字が重なります。

上下2段に並べた灰色の画像。上は「縁取りと文字を、それぞれ濃さ 30% で描いた」で、「© 猫の手」の文字の中が背景に近い灰色でくすんでいる。下は「不透明で描いたタイルを、濃さ 30% で重ねた」で、文字の中が明るい灰色にそろっている
同じ文字を、薄くするタイミングだけを変えて描いた2通り

検証環境で、灰色(#808080、赤の値は 128)の上に 120px の「© 猫の手」を描き、文字の塗りにあたる 16,177 画素について、赤の値を調べました。縁取りと文字をそれぞれ 0.3 で描いた場合は、16,159 画素が 139 で、背景の 128 とほとんど変わりません。不透明のまま描いたものを 0.3 で重ねた場合は、すべての画素が 166 にそろいました。

記事の関数では、タイルの中では不透明の白と黒で描き、globalAlpha はパターンで塗るときにだけかけています。こうすると、縁取りと文字が重なった部分も含めて、透かし全体が同じ割合で薄くなります。

JPEG で保存するときは、透明な部分を先に塗る

保存は toBlob() で行います。2つ目の引数(type)に image/jpeg を渡すと JPEG、image/png を渡すと PNG になります。JPEG のときは、3つ目の引数(quality)で品質を 0 から 1 の範囲で指定できます。

MDN の画像形式のガイドにあるとおり、JPEG は透明な部分を持てません。背景が透明な PNG をそのまま canvas に描いて JPEG にしたところ、透明だった部分は黒(rgba(0,0,0,255))になりました。drawWatermark() の background に #ffffff を渡し、画像を描く前に白で塗っておくと、同じ場所は白(rgba(255,255,255,255))になりました。コンポーネントでも、形式が JPEG のときだけ background を渡しています。

形式と品質で、ファイルの大きさは大きく変わります。写真のようなざらつきを付けた 1200×800 の画像に透かしを入れ、検証環境の Chromium で書き出したところ、次のようになりました。

形式と品質 大きさ
PNG 970KB
JPEG(品質の指定なし) 151KB
JPEG 0.92 151KB
JPEG 0.9 139KB
JPEG 0.8 95KB
JPEG 0.6 59KB

品質を指定しないときは、ブラウザの既定の値が使われます(MDN の toBlob() の説明)。検証環境の Chromium では、品質を指定しないときと 0.92 を指定したときで、書き出したバイト列が完全に一致しました。コンポーネントでは 0.9 を指定しました。

透かしを入れて保存するコンポーネント

tsx
import { useEffect, useId, useRef, useState } from 'react';
import { canvasToBlob, downloadBlob, drawWatermark } from './drawWatermark';

type Loaded = { bitmap: ImageBitmap; name: string };
type Format = 'image/jpeg' | 'image/png';

export function Watermark() {
  const [image, setImage] = useState<Loaded | null>(null);
  const [text, setText] = useState('© example.com');
  const [opacity, setOpacity] = useState(0.3);
  const [angle, setAngle] = useState(30);
  const [format, setFormat] = useState<Format>('image/jpeg');
  const [saved, setSaved] = useState('');
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const id = useId();

  useEffect(() => () => image?.bitmap.close(), [image]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!image || !canvas) return;
    drawWatermark(canvas, image.bitmap, {
      text,
      opacity,
      angle,
      sizeRatio: 0.06,
      // JPEG は透明を持てないので、透明な部分を白で塗ってから描く
      background: format === 'image/jpeg' ? '#ffffff' : undefined,
    });
  }, [image, text, opacity, angle, format]);

  async function handleFile(file: File | undefined) {
    if (!file) return;
    setImage({ bitmap: await createImageBitmap(file), name: file.name });
    setFormat(file.type === 'image/png' ? 'image/png' : 'image/jpeg'); // 元の形式に合わせる
    setSaved('');
  }

  async function save() {
    const canvas = canvasRef.current;
    if (!image || !canvas) return;
    const blob = await canvasToBlob(canvas, format, format === 'image/jpeg' ? 0.9 : undefined);
    const ext = format === 'image/jpeg' ? 'jpg' : 'png';
    const filename = `${image.name.replace(/\.[^.]+$/, '')}-wm.${ext}`;
    downloadBlob(blob, filename);
    setSaved(`${filename}(${canvas.width}×${canvas.height}、${Math.round(blob.size / 1024)}KB)を保存しました`);
  }

  return (
    <div className="watermark">
      <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>
        <input id={`${id}-text`} value={text} onChange={(e) => setText(e.target.value)} />
      </p>
      <p className="row">
        <label>
          濃さ
          <input type="range" min={0.1} max={0.8} step={0.05} value={opacity}
            onChange={(e) => setOpacity(Number(e.target.value))} />
          <output>{Math.round(opacity * 100)}%</output>
        </label>
        <label>
          角度
          <select value={angle} onChange={(e) => setAngle(Number(e.target.value))}>
            <option value={0}>0°</option>
            <option value={30}>30°</option>
            <option value={45}>45°</option>
          </select>
        </label>
        <label>
          形式
          <select value={format} onChange={(e) => setFormat(e.target.value as Format)}>
            <option value="image/jpeg">JPEG</option>
            <option value="image/png">PNG</option>
          </select>
        </label>
      </p>
      {image && <canvas ref={canvasRef} aria-label="透かしを入れた画像" />}
      <p>
        <button type="button" onClick={save} disabled={!image}>保存</button>
      </p>
      <p className="saved" aria-live="polite">{saved}</p>
    </div>
  );
}

state に持つのは、選んだ画像と、透かしの文字・濃さ・角度・保存する形式です。設定が変わるたびに、useEffect の中で描き直します。前の透かしの上に描き足すのではなく、毎回 drawImage() で元の画像から描きます。

保存する形式は、画像を選んだときに元のファイルの形式(file.type)に合わせています。検証環境で cat.jpg を選ぶと JPEG、sticker.png を選ぶと PNG が選ばれました。形式の選択は、あとから変えられます。

画像は createImageBitmap() で読み込み、画像を選び直したときや、コンポーネントが画面から取り除かれるときに、useEffect のクリーンアップで前の ImageBitmap を close() します。

canvas の width と height は元の画像の大きさにし、画面での大きさは CSS の width: 100%; height: auto; で決めています。検証環境では、canvas は 1200×800 のまま画面には 448×299 で表示され、保存した画像も 1200×800 でした。

保存のときは、canvasToBlob() で Blob にし、downloadBlob() で download 属性を付けたリンクをクリックしてダウンロードさせます。検証環境で、1200×800 の cat.jpg に濃さ 60%、角度 45° で「猫の手 見本」を入れて保存すると、cat-wm.jpg という名前の 1200×800 の JPEG(195KB)がダウンロードされました。形式を PNG に変えて保存すると、cat-wm.png(1,065KB)になりました。背景が透明な sticker.png を JPEG で保存した sticker-wm.jpg では、左上の透明だった部分が白に近い rgba(253,253,253,255) になっていました。

画像に文字を1か所だけ入れる方法や、Webフォントを読み込み終わってから描く方法は、以前の記事「Reactで、画像に文字を入れて保存する方法」に書きました。

​Posted by 86