3行まとめ
- 画像に著作権表示などのテキストを1か所、または「SAMPLE」等の透かしを全面タイル状に入れるブラウザ完結ツールを作った。Canvas 2D の
fillTextだけで実装できる - 6か所の位置決めは、テキスト幅を測らず
textAlign/textBaselineを切り替えてブラウザに揃えさせる。アンカー座標を渡すだけで左寄せ/中央/右寄せが決まる - 回転した透かしを敷き詰めるとき、
ctx.rotate後は描画すべき範囲が広がる。対角線長√(w²+h²)を基準にキャンバスより広くループして塗り残しを防ぐ
写真に著作権表示(© 名前)を入れる、資料に「社外秘」の透かしを敷く——画像へのテキスト追加は地味に需要があるのに、そのために画像編集ソフトを起動するのは大げさ。
ぱんだツールズの画像テキスト・透かし追加ツールは、アップロードした画像に、位置指定の1テキスト(テキストモード)か、全面タイル状の透かし(透かしモード)を入れられるツール。処理はすべてブラウザ内の Canvas API で完結し、画像はサーバーに送信されない。
やっていることは Canvas への文字描画だが、「テキストの位置決め」と「回転した透かしの敷き詰め」に、素朴にやるとハマる座標計算のポイントがある。実装ベースで解説する。
位置決めは幅を測らず textAlign に任せる
「右下に配置」を素朴にやろうとすると、ctx.measureText(text).width でテキスト幅を測り、canvasWidth - textWidth - padding を計算して…と面倒になる。日本語や絵文字が混じると幅の計算も怪しくなる。
このツールは幅を測らない。代わりに textAlign と textBaseline を位置ごとに切り替え、アンカーとなる座標だけを渡してブラウザに揃えさせる。
function getTextPosition(ctx, position, canvasWidth, canvasHeight, fontSize, text) {
const padding = Math.max(fontSize, 16)
ctx.font = `${fontSize}px sans-serif`
switch (position) {
case 'top-left':
return { x: padding, y: padding + fontSize, textAlign: 'left', textBaseline: 'alphabetic' }
case 'top-center':
return { x: canvasWidth / 2, y: padding + fontSize, textAlign: 'center', textBaseline: 'alphabetic' }
case 'top-right':
return { x: canvasWidth - padding, y: padding + fontSize, textAlign: 'right', textBaseline: 'alphabetic' }
case 'bottom-left':
return { x: padding, y: canvasHeight - padding, textAlign: 'left', textBaseline: 'alphabetic' }
// bottom-center / bottom-right も同様
}
}
ポイントは textAlign の使い方。
- 左寄せ:
textAlign: 'left'で、渡した x がテキストの左端になる → x はpadding - 中央:
textAlign: 'center'で、x がテキストの中心になる → x はcanvasWidth / 2 - 右寄せ:
textAlign: 'right'で、x がテキストの右端になる → x はcanvasWidth - padding
つまり x はいつも「揃えたい辺の座標」を渡すだけでよく、テキストの実幅を知る必要がない。中央寄せに至っては、テキストが何文字でも canvasWidth / 2 を渡せば勝手に中央に来る。縦位置は textBaseline: 'alphabetic'(既定のベースライン)のまま、上側は y = padding + fontSize(ベースラインをフォント高さ分下げて上端付近に収める)、下側は y = canvasHeight - padding で処理する。padding はフォントサイズと16pxの大きい方にして、小さい文字でも端に貼り付きすぎないようにしている。
色と透明度を分けて rgba にまとめる
テキストの色はカラーピッカー(HEX)、透明度はスライダー(0〜100%)という別々のUIで受け取る。描画時にこの2つを rgba() に合成する。
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha / 100})`
}
// ...
ctx.fillStyle = hexToRgba(color, opacity) // 例: "rgba(255, 255, 255, 0.8)"
色と透明度を別々のstateで持ち、描画の直前に1つの fillStyle へ合成する。透かしとして薄く入れたいときは透明度20〜40%あたりが実用的で、この alpha が fillText の描画にそのまま乗る。globalAlpha を触らずに fillStyle 側の alpha で済ませているので、他の描画(元画像の drawImage)に透明度が波及しない。
回転した透かしを敷き詰める — 対角線でループ範囲を広げる
透かしモードは、同じテキストを斜め(-30度)に回転させて画像全面にタイル状に繰り返す。ここが座標計算の山場。
ctx.save()
const tileSpacingX = Math.max(fontSize * 6, 200)
const tileSpacingY = Math.max(fontSize * 3, 100)
const angle = -30 * (Math.PI / 180)
ctx.rotate(angle)
// 回転後のキャンバス範囲をカバーするため広めに描画
const diagLen = Math.sqrt(canvas.width * canvas.width + canvas.height * canvas.height)
for (let x = -diagLen; x < diagLen * 2; x += tileSpacingX) {
for (let y = -diagLen; y < diagLen * 2; y += tileSpacingY) {
ctx.textAlign = 'left'
ctx.textBaseline = 'alphabetic'
ctx.fillText(text, x, y)
}
}
ctx.restore()
素朴に「0 から canvas.width、0 から canvas.height の範囲でループして敷き詰める」とやると、透かしが画像の隅で欠ける。理由は ctx.rotate が座標系ごと回してしまうから。回転後の座標系で [0, w] × [0, h] を塗っても、それは画面上では傾いた平行四辺形の領域にしかならず、キャンバスの四隅(回転で座標系の外にはみ出した部分)に塗り残しが出る。
そこで、ループ範囲をキャンバスの対角線長 √(w² + h²) を基準に広げる。対角線はキャンバスに収まる最長の直線なので、どの角度で回転させても、-diagLen から diagLen * 2 の範囲を塗れば回転後のキャンバス全域を必ず覆える。多少の無駄打ち(キャンバス外への描画)は出るが、fillText で範囲外に描いても単に切り取られるだけなので実害はない。「回転してから敷き詰めるときは、可視範囲より広く塗る」——これが回転タイルの定石。
タイル間隔はフォントサイズに連動(fontSize * 6 と200pxの大きい方)させて、文字が大きいときに透かし同士が重ならないようにしている。save / restore で回転行列をこのループだけに閉じ込め、その後の toBlob に影響しないようにしている点は前提の作法。
出力はPNG固定 — 半透明とアンチエイリアスを保つ
出力は toBlob(…, 'image/png') でPNG固定。
canvas.toBlob((blob) => { /* ... */ }, 'image/png')
テキストは fillText でアンチエイリアス(縁のなめらかな階調)付きで描かれ、さらに透明度が乗っている。PNGは可逆+アルファチャンネルを持てるので、この半透明のなめらかな縁をそのまま保存できる。JPEGだとアルファを持てず背景に合成されてしまい、透かしの薄さや文字の縁が崩れる。可逆圧縮なので元がJPEGだと出力サイズは増えるが、透かし・テキストの見た目を正しく残すにはPNGが正解。
なお、こうして描き込んだテキストはピクセルに焼き込まれるので、後からテキストだけを綺麗に剥がすことはできない。透かしとしてはこれが利点(簡単には除去されない)だが、テキストモードでうっかり位置を間違えると元に戻せないので、ダウンロード前のプレビュー確認を前提にしている。元画像の Object URL と処理結果の Object URL は useRef で保持して useEffect のクリーンアップと差し替え時に revokeObjectURL し、プレビューを繰り返してもリークしないようにしている。
まとめ
- テキストの位置決めは幅を測らず、
textAlign(left/center/right)とtextBaselineを切り替えてアンカー座標だけ渡す。中央寄せはcanvasWidth / 2を渡すだけで文字数によらず中央に来る - 色(HEX)と透明度(%)は別UIで受け取り、描画直前に
rgba()へ合成する。fillStyle側の alpha なら他の描画に波及しない - 回転した透かしを敷き詰めるときは、
ctx.rotateで座標系が傾く分、対角線長√(w²+h²)を基準にキャンバスより広くループして塗り残しを防ぐ - 半透明・アンチエイリアスを保つため出力はPNG固定。描き込んだ文字はピクセルに焼き込まれる
写真の著作権表示や資料の透かし入れにどうぞ。画像はブラウザの外に出ない。
ぱんだツールズ では他にも 画像圧縮・リサイズ・回転・モザイク・PDF処理など、ブラウザ完結で使える画像・ファイルツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。