0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画像へのテキスト・透かし追加をCanvasで実装する — textAlignでの位置決めと、回転した透かしを敷き詰める座標計算

0
Posted at

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 を計算して…と面倒になる。日本語や絵文字が混じると幅の計算も怪しくなる。

このツールは幅を測らない。代わりに textAligntextBaseline を位置ごとに切り替え、アンカーとなる座標だけを渡してブラウザに揃えさせる。

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.width0 から 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 にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?