診断、占い、クイズなどの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
- 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のwidthとheight属性は、内部の描画解像度です。CSSだけで幅と高さを指定すると内部解像度が初期値の300×150pxのままになり、保存画像がぼやけます。
今回はSNS投稿でも扱いやすい縦長の1080×1350pxにします。画面上ではCSSで縮小表示します。
- 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;
}
}
- 角丸のカードを描画する
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();
}
- 日本語を幅に合わせて改行する
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.Segmenterのgranularity: "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で切り、最後に省略記号を付けます。
- 結果カード全体を描く
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として実行されません。ただし、出力画像を公開するサービスでは、不適切な文言、個人情報、長すぎる入力などを別途どう扱うか決める必要があります。
- 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フォントを利用する構成へ変更した場合も、フォント読込前の代替書体で画像が確定する問題を減らせます。
- 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();
- 外部画像を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へ描けることと、画像を再配布してよいことは別問題です。著作権、商標、人物写真の利用条件も確認します。
- 実運用で追加したい改善
#入力値をURLへ入れない
結果文に個人的な内容が含まれる場合、安易にクエリ文字列へ保存すると、アクセスログや共有URLに残る可能性があります。共有に必要なのが画像だけなら、入力内容をサーバーへ送らずブラウザ内で完結させる設計も選択できます。
#Blobを作り直すタイミングを制御する
入力のたびに1080×1350pxのPNGを生成すると負荷が増えます。今回のように「更新」ボタンで明示的に生成するか、debounceして入力が止まってから生成します。
#画像だけに情報を閉じ込めない
Canvasの内容は通常のHTMLテキストとして読めません。結果をCanvasだけに表示せず、画面上には見出しや本文として同じ情報を用意し、Canvasは共有用の補助機能にするのが望ましいです。
#共有失敗を正常系として扱う
利用者が共有画面を閉じるとAbortErrorになる場合があります。これは障害ではないため、エラーログを大量に送らず「共有をキャンセルしました」と表示します。
#サーバー生成との使い分け
クライアント側Canvasは、即時プレビューと個人端末での保存に向いています。一方、SNSクローラー向けのOGP画像、誰が開いても同じURLで同じ画像を返す機能、改ざん防止が必要な証明書などは、サーバー側での画像生成が適しています。
まとめ
ブラウザだけでも、CanvasとWeb Share APIを組み合わせれば、結果カードの生成から共有まで実装できます。
今回の要点は次のとおりです。
- Canvasの内部解像度を
width・height属性で指定する -
measureText()で日本語を幅に合わせて改行する -
toDataURL()ではなくtoBlob()でPNGを生成する - 共有前に
navigator.canShare()でファイル対応を確認する - 非対応環境ではダウンロードへ切り替える
- Blobを事前生成し、一時的なユーザー操作を失いにくくする
- 外部画像を使う場合はCORSと利用許諾を確認する
- Canvasとは別に、読めるHTMLの結果も用意する
この仕組みは恋みくじだけでなく、性格診断、クイズ結果、学習記録、イベント参加証、レシピカードなどにも応用できます。