1
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で恋みくじの結果画像を生成し、Web Share APIで共有する

1
Posted at

診断、占い、クイズなどのWebコンテンツでは、結果を表示して終わるのではなく、「画像として保存したい」「スマートフォンから共有したい」という要件がよくあります。

この記事では、恋みくじの結果を題材にして、次の機能をブラウザ標準APIだけで実装します。

  • Canvas APIで1080×1350pxの結果カードを描画
  • 日本語の長文を指定幅で自動改行
  • canvas.toBlob()でPNGを生成
  • Web Share APIで対応端末の共有画面を開く
  • 非対応ブラウザではPNGダウンロードへフォールバック
  • 連打やBlob生成中の操作を防止
  • 外部画像を使う場合のCORS制約を理解する

フレームワークや画像生成ライブラリは使いません。

なぜ「スクリーンショットしてください」では不十分なのか

画面のスクリーンショットは簡単ですが、端末サイズ、ブラウザのUI、表示倍率によって見え方が変わります。結果以外のボタンや広告が写り込む場合もあります。

Canvasで共有専用画像を生成すると、次の利点があります。

  • 出力サイズを固定できる
  • 画面表示とは別のレイアウトを作れる
  • 不要なUIを含めずに済む
  • ファイル名や形式を制御できる
  • Web Share APIへファイルとして渡せる

実際の恋みくじサービスで、結果までの導線やスマートフォン上の見せ方を確認したい場合は、一禅堂の恋みくじ公式サイトのような公開例も参考になります。本記事のコードは特定サイトの内部実装を再現するものではなく、独立したサンプルです。

完成イメージとファイル構成

フォームから「運勢」「メッセージ」「今日のヒント」を入力し、プレビューを更新します。共有ボタンを押すと、対応端末では共有シートを開き、非対応環境ではPNGをダウンロードします。

text
result-card/
├── index.html
├── style.css
└── app.js

  1. HTMLを用意する

html:index.html

恋みくじ結果カード

恋みくじ結果カード

  <label>
    運勢
    <input id="rank" type="text" value="大吉" maxlength="12">
  </label>

  <label>
    メッセージ
    <textarea id="message" rows="3" maxlength="80">素直な一言が、二人の距離を近づける日</textarea>
  </label>

  <label>
    今日のヒント
    <textarea id="advice" rows="4" maxlength="120">考えすぎる前に、短くても自分の言葉で伝えてみましょう。</textarea>
  </label>

  <button id="update" type="button">プレビューを更新</button>
  <button id="share" type="button" disabled>画像を共有・保存</button>

  <p id="status" role="status" aria-live="polite"></p>
</section>

<section class="preview" aria-label="生成画像のプレビュー">
  <canvas id="card" width="1080" height="1350">
    お使いのブラウザはCanvasに対応していません。
  </canvas>
</section>

Canvasのwidthheight属性は、内部の描画解像度です。CSSだけで幅と高さを指定すると内部解像度が初期値の300×150pxのままになり、保存画像がぼやけます。

今回はSNS投稿でも扱いやすい縦長の1080×1350pxにします。画面上ではCSSで縮小表示します。

  1. CSSで編集画面とプレビューを整える

css:style.css
:root {
font-family:
system-ui, -apple-system, BlinkMacSystemFont,
"Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
color: #332b2f;
background: #fff7fa;
}

  • {
    box-sizing: border-box;
    }

body {
margin: 0;
padding: 24px;
}

.app {
width: min(100%, 1040px);
margin-inline: auto;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
gap: 32px;
align-items: start;
}

.editor,
.preview {
padding: 24px;
background: #fff;
border: 1px solid #efd0dc;
border-radius: 20px;
box-shadow: 0 12px 36px rgb(92 46 64 / 10%);
}

label {
display: grid;
gap: 8px;
margin-top: 18px;
font-weight: 700;
}

input,
textarea,
button {
font: inherit;
}

input,
textarea {
width: 100%;
padding: 12px;
border: 1px solid #be9aa8;
border-radius: 10px;
}

textarea {
resize: vertical;
}

button {
min-height: 46px;
margin: 20px 10px 0 0;
padding: 10px 18px;
color: #fff;
background: #9e3e62;
border: 0;
border-radius: 999px;
font-weight: 700;
cursor: pointer;
}

button:disabled {
cursor: wait;
opacity: 0.5;
}

button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 3px solid #276bd1;
outline-offset: 3px;
}

#status {
min-height: 1.5em;
color: #6e354a;
}

canvas {
display: block;
width: 100%;
height: auto;
border-radius: 12px;
box-shadow: 0 8px 24px rgb(64 34 46 / 16%);
}

@media (max-width: 760px) {
body {
padding: 14px;
}

.app {
grid-template-columns: 1fr;
}
}

  1. 角丸のカードを描画する

Canvasの描画処理から作ります。まず必要な要素を取得します。

js:app.js
const canvas = document.querySelector("#card");
const context = canvas.getContext("2d");

const rankInput = document.querySelector("#rank");
const messageInput = document.querySelector("#message");
const adviceInput = document.querySelector("#advice");
const updateButton = document.querySelector("#update");
const shareButton = document.querySelector("#share");
const status = document.querySelector("#status");

let currentBlob = null;

ブラウザ差を小さくするため、roundRect()に頼らず角丸パスを作る関数を用意します。

js:app.js
function roundedRectPath(ctx, x, y, width, height, radius) {
const r = Math.min(radius, width / 2, height / 2);

ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + width - r, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
ctx.lineTo(x + width, y + height - r);
ctx.quadraticCurveTo(
x + width,
y + height,
x + width - r,
y + height
);
ctx.lineTo(x + r, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}

背景とカードを描画します。

js:app.js
function drawBackground(ctx, width, height) {
const gradient = ctx.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, "#fff1f6");
gradient.addColorStop(1, "#f5d7e4");

ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);

ctx.save();
ctx.globalAlpha = 0.18;
ctx.fillStyle = "#d5789a";

for (const [x, y, radius] of [
[120, 180, 90],
[970, 190, 130],
[80, 1180, 120],
[940, 1120, 80]
]) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}

ctx.restore();
}

function drawCardSurface(ctx) {
ctx.save();
ctx.shadowColor = "rgb(82 39 56 / 20%)";
ctx.shadowBlur = 36;
ctx.shadowOffsetY = 18;

roundedRectPath(ctx, 100, 100, 880, 1150, 48);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.restore();

roundedRectPath(ctx, 125, 125, 830, 1100, 34);
ctx.strokeStyle = "#d9a2b6";
ctx.lineWidth = 3;
ctx.stroke();
}

  1. 日本語を幅に合わせて改行する

Canvasには、DOMのような自動改行がありません。measureText()で一文字ずつ幅を測り、指定幅を超える直前で行を確定します。

js:app.js
function wrapJapaneseText(ctx, text, maxWidth) {
const lines = [];

for (const paragraph of text.split(/\n/)) {
if (paragraph === "") {
lines.push("");
continue;
}

let line = "";

for (const character of Array.from(paragraph)) {
  const candidate = line + character;

  if (line && ctx.measureText(candidate).width > maxWidth) {
    lines.push(line);
    line = character;
  } else {
    line = candidate;
  }
}

if (line) lines.push(line);

}

return lines;
}

text.split("")ではなくArray.from()を使っています。サロゲートペアを含む文字を、UTF-16のコード単位で不自然に分割しにくくするためです。ただし、結合文字や絵文字シーケンスまで完全に一文字として扱いたい場合は、Intl.Segmentergranularity: "grapheme"を検討してください。

複数行を中央揃えで描く関数も作ります。

js:app.js
function drawWrappedText(
ctx,
text,
{ x, y, maxWidth, lineHeight, maxLines, color }
) {
const lines = wrapJapaneseText(ctx, text, maxWidth);
const visibleLines = lines.slice(0, maxLines);

if (lines.length > maxLines) {
let lastLine = visibleLines[maxLines - 1];

while (
  lastLine &&
  ctx.measureText(`${lastLine}…`).width > maxWidth
) {
  lastLine = Array.from(lastLine).slice(0, -1).join("");
}

visibleLines[maxLines - 1] = `${lastLine}…`;

}

ctx.fillStyle = color;
ctx.textAlign = "center";
ctx.textBaseline = "top";

visibleLines.forEach((line, index) => {
ctx.fillText(line, x, y + index * lineHeight);
});

return y + visibleLines.length * lineHeight;
}

入力文字数をHTML側で制限していますが、長文が来てもカードからはみ出さないようmaxLinesで切り、最後に省略記号を付けます。

  1. 結果カード全体を描く

js:app.js
function drawResultCard({ rank, message, advice }) {
const { width, height } = canvas;

context.clearRect(0, 0, width, height);
drawBackground(context, width, height);
drawCardSurface(context);

context.textAlign = "center";
context.textBaseline = "top";

context.fillStyle = "#8f3457";
context.font = '700 34px system-ui, "Yu Gothic", sans-serif';
context.fillText("TODAY'S LOVE FORTUNE", width / 2, 190);

context.fillStyle = "#352a2f";
context.font = '800 118px system-ui, "Yu Gothic", sans-serif';
context.fillText(rank || "―", width / 2, 275);

context.strokeStyle = "#d9a2b6";
context.lineWidth = 3;
context.beginPath();
context.moveTo(270, 455);
context.lineTo(810, 455);
context.stroke();

context.font = '700 54px system-ui, "Yu Gothic", sans-serif';
const messageBottom = drawWrappedText(context, message || " ", {
x: width / 2,
y: 520,
maxWidth: 700,
lineHeight: 84,
maxLines: 3,
color: "#352a2f"
});

const adviceY = Math.max(810, messageBottom + 60);

context.font = '700 30px system-ui, "Yu Gothic", sans-serif';
context.fillStyle = "#9e3e62";
context.fillText("今日のヒント", width / 2, adviceY);

context.font = '500 38px system-ui, "Yu Gothic", sans-serif';
drawWrappedText(context, advice || " ", {
x: width / 2,
y: adviceY + 65,
maxWidth: 680,
lineHeight: 60,
maxLines: 3,
color: "#5c4650"
});

context.font = '500 25px system-ui, "Yu Gothic", sans-serif';
context.fillStyle = "#9b7b88";
context.fillText("結果は、今日の気持ちを見つめるヒントとして。", width / 2, 1160);
}

ユーザー入力はCanvasのfillText()へ渡されるため、HTMLとして実行されません。ただし、出力画像を公開するサービスでは、不適切な文言、個人情報、長すぎる入力などを別途どう扱うか決める必要があります。

  1. CanvasをPNGのBlobへ変換する

canvas.toDataURL()でも画像化できますが、Base64文字列を生成するため、大きな画像ではメモリ効率がよくありません。ファイル共有やダウンロードにはtoBlob()が扱いやすいです。

js:app.js
function canvasToBlob(canvas, type = "image/png", quality) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("画像の生成に失敗しました。"));
}
}, type, quality);
});
}

プレビュー更新時にBlobまで生成し、共有ボタンが押された時点ですぐ使えるようにします。

js:app.js
async function updatePreview() {
updateButton.disabled = true;
shareButton.disabled = true;
status.textContent = "画像を生成しています…";

try {
await document.fonts?.ready;

drawResultCard({
  rank: rankInput.value.trim(),
  message: messageInput.value.trim(),
  advice: adviceInput.value.trim()
});

currentBlob = await canvasToBlob(canvas);
shareButton.disabled = false;
status.textContent = "画像を生成しました。";

} catch (error) {
console.error(error);
currentBlob = null;
status.textContent = "画像を生成できませんでした。";
} finally {
updateButton.disabled = false;
}
}

document.fonts.readyを待つことで、Webフォントを利用する構成へ変更した場合も、フォント読込前の代替書体で画像が確定する問題を減らせます。

  1. Web Share APIとダウンロードを実装する

共有用のFileを作り、ファイル共有に対応しているかnavigator.canShare()で確認します。

js:app.js
function downloadBlob(blob, fileName) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");

anchor.href = url;
anchor.download = fileName;
document.body.append(anchor);
anchor.click();
anchor.remove();

window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}

async function shareOrDownload() {
if (!currentBlob) {
status.textContent = "先に画像を生成してください。";
return;
}

const fileName = "love-fortune.png";
const file = new File([currentBlob], fileName, {
type: currentBlob.type
});

const shareData = {
files: [file],
title: "今日の恋みくじ",
text: "今日の恋みくじ結果"
};

const canShareFile =
typeof navigator.share === "function" &&
typeof navigator.canShare === "function" &&
navigator.canShare(shareData);

if (!canShareFile) {
downloadBlob(currentBlob, fileName);
status.textContent = "PNG画像をダウンロードしました。";
return;
}

try {
await navigator.share(shareData);
status.textContent = "共有画面を開きました。";
} catch (error) {
if (error.name === "AbortError") {
status.textContent = "共有をキャンセルしました。";
return;
}

console.error(error);
downloadBlob(currentBlob, fileName);
status.textContent = "共有できなかったため、画像をダウンロードしました。";

}
}

Web Share APIはHTTPSなどのセキュアコンテキストと、ユーザー操作を要求します。ページ表示直後に自動でnavigator.share()を呼ぶのではなく、クリックイベントから呼び出します。

また、Blob生成を共有クリック後に始めると、処理中に一時的なユーザーアクティベーションが失われ、共有に失敗する環境があります。このサンプルではプレビュー更新時にBlobを作り、共有ボタンのクリック時には既存BlobからFileを組み立てるだけにしています。

イベントを登録し、初期描画を行います。

js:app.js
updateButton.addEventListener("click", updatePreview);
shareButton.addEventListener("click", shareOrDownload);

updatePreview();

  1. 外部画像をCanvasへ描くときの注意

ロゴや背景画像を追加する場合、Canvas特有のセキュリティ制約があります。

別オリジンの画像をCORSの許可なく描画するとCanvasが「汚染」された状態になり、toBlob()toDataURL()で内容を取り出せなくなります。

js
const image = new Image();
image.crossOrigin = "anonymous";
image.src = "https://example.com/background.png";

この指定だけで解決するわけではありません。画像を配信するサーバー側も、適切なAccess-Control-Allow-Originヘッダーを返す必要があります。

自分で管理できない外部画像へ依存するより、次の方法が安全です。

  • 同一オリジンから画像を配信する
  • 自分が管理するCDNでCORSを設定する
  • CSS風の装飾をCanvasの図形描画で作る
  • 利用許諾と再配布条件を確認した素材だけを使う

Canvasへ描けることと、画像を再配布してよいことは別問題です。著作権、商標、人物写真の利用条件も確認します。

  1. 実運用で追加したい改善

#入力値をURLへ入れない

結果文に個人的な内容が含まれる場合、安易にクエリ文字列へ保存すると、アクセスログや共有URLに残る可能性があります。共有に必要なのが画像だけなら、入力内容をサーバーへ送らずブラウザ内で完結させる設計も選択できます。

#Blobを作り直すタイミングを制御する

入力のたびに1080×1350pxのPNGを生成すると負荷が増えます。今回のように「更新」ボタンで明示的に生成するか、debounceして入力が止まってから生成します。

#画像だけに情報を閉じ込めない

Canvasの内容は通常のHTMLテキストとして読めません。結果をCanvasだけに表示せず、画面上には見出しや本文として同じ情報を用意し、Canvasは共有用の補助機能にするのが望ましいです。

#共有失敗を正常系として扱う

利用者が共有画面を閉じるとAbortErrorになる場合があります。これは障害ではないため、エラーログを大量に送らず「共有をキャンセルしました」と表示します。

#サーバー生成との使い分け

クライアント側Canvasは、即時プレビューと個人端末での保存に向いています。一方、SNSクローラー向けのOGP画像、誰が開いても同じURLで同じ画像を返す機能、改ざん防止が必要な証明書などは、サーバー側での画像生成が適しています。

まとめ

ブラウザだけでも、CanvasとWeb Share APIを組み合わせれば、結果カードの生成から共有まで実装できます。

今回の要点は次のとおりです。

  • Canvasの内部解像度をwidthheight属性で指定する
  • measureText()で日本語を幅に合わせて改行する
  • toDataURL()ではなくtoBlob()でPNGを生成する
  • 共有前にnavigator.canShare()でファイル対応を確認する
  • 非対応環境ではダウンロードへ切り替える
  • Blobを事前生成し、一時的なユーザー操作を失いにくくする
  • 外部画像を使う場合はCORSと利用許諾を確認する
  • Canvasとは別に、読めるHTMLの結果も用意する

この仕組みは恋みくじだけでなく、性格診断、クイズ結果、学習記録、イベント参加証、レシピカードなどにも応用できます。

1
0
1

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
1
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?