Reactで、画像に文字の透かしを入れて保存する方法
画像の全面に斜めの透かしを入れるには、透かしの文字を描いた小さな 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 での保存は試していません。

透かしを描く関数
描く処理は、React から切り離して関数にしておきます。
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() を二重のループで並べる書き方もあります。ただし、画像の幅と高さの範囲だけ並べると、回したぶん画像の角に文字が届きません。

検証環境で、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 の説明のとおり、線は輪郭を中心に、内側と外側へ半分ずつ引かれます。そのため文字の内側では、薄い黒の線の上に、薄い白の文字が重なります。

検証環境で、灰色(#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 を指定しました。
透かしを入れて保存するコンポーネント
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で、画像に文字を入れて保存する方法」に書きました。





