QRコード生成サービスは山ほどあります。それでも作ったのは、既存のものを使っていて毎回同じところで引っかかったからです。
- 無料と書いてあるのに、ロゴを入れようとすると会員登録を求められる
- 生成した QR がサービス側のドメインを経由する短縮 URL になっていて、そのサービスが止まったら死ぬ
- デザインを変えられるものは、たいてい有料
そして何より、QRコードにしたい文字列をよそのサーバーに送っているという点。社内の URL や個人の連絡先を、なぜ知らない会社のサーバーに投げなければいけないのか。
qr-forge は、この 3 つをまとめて潰すために作りました。デザインは自由、URL はそのまま埋め込む静的 QR、そして生成は全部ブラウザの中。
#🎯 「静的QR」であることの意味
まず設計上いちばん大事にしたのがここです。
QRコードには大きく 2 種類あります。
| 種類 | 中身 | 特徴 |
|---|---|---|
| 静的QR | 目的の URL を直接埋め込む | 発行元が消えても動く。中身は変えられない |
| 動的QR | 中継サービスの短縮 URL を埋め込む | あとから転送先を変えられる。中継が止まると全滅 |
チラシや名刺に刷った QR が、数年後にサービス終了で読めなくなる——動的 QR ではこれが起こり得ます。qr-forge は静的 QR のみです。生成した画像の中身は目的の URL そのもので、qr-forge が明日消えても、印刷済みの QR は何も影響を受けません。
「あとから転送先を変えたい」という需要には応えられませんが、その代わり失効しない。個人開発のサービスとして、後者を選ぶのが誠実だと判断しました。
#🏗️ 技術選定:ライブラリに描かせない
QRコード生成ライブラリの多くは「文字列を渡すと画像が返ってくる」形をしています。手軽な反面、見た目をいじる余地がほとんどない。
そこで qr-forge は、ライブラリをモジュール行列(どのマスが黒か)の計算だけに使い、描画は全部自前でやることにしました。
export async function buildMatrix(text: string, ec: ErrorLevel): Promise<QrMatrix> {
const qrcode = await loadQr
const qr = qrcode(0, ec) // 0 = auto type
qr.addData(text)
qr.make
const count = qr.getModuleCount
return { count, isDark: (r, c) => qr.isDark(r, c) }
}欲しいのは getModuleCount(一辺のマス数)と isDark(row, col)(そのマスが黒か)だけ。この 2 つさえあれば、あとは Canvas に好きな形で描けます。
使ったのは kazuhikoarase 氏の qrcode-generator。QRコードの規格実装として枯れていて、型番の自動選択(0 を渡すとデータ量に応じて決めてくれる)まで面倒を見てくれます。これも CDN からスクリプトタグで読み込む方式にして、バンドルサイズには影響させていません。
const QR_CDN = "https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.js"
function loadQr: Promise<QrFactory> {
if (typeof window !== "undefined" && window.qrcode) return Promise.resolve(window.qrcode)
if (!loadPromise) {
loadPromise = new Promise<QrFactory>((resolve, reject) => {
const s = document.createElement("script")
s.src = QR_CDN
s.async = true
s.onload = => {
if (window.qrcode) resolve(window.qrcode)
else reject(new Error("qrcode unavailable after load"))
}
s.onerror = => reject(new Error("Failed to load qrcode-generator"))
document.head.appendChild(s)
})
}
return loadPromise
}loadPromise を保持しているのは、入力を打つたびに再生成が走るため。連続でスクリプトタグを増やさないための単純なガードです。
#🎨 描画:マス目をどう塗るか
行列が手に入れば、あとは二重ループで塗るだけ——なのですが、ここに壊してはいけない一線があります。
for (let row = 0; row < n; row++) {
for (let col = 0; col < n; col++) {
if (!matrix.isDark(row, col)) continue
const x = (col + margin) * cell
const y = (row + margin) * cell
// ファインダー (隅 の 位置検出 マーク) は 形 を 崩す と スキャン 不能 に なる ため 常に 正方形
if (style.dot === "square" || isFinder(row, col, n)) {
ctx.fillRect(x, y, cell, cell)
} else if (style.dot === "dot") {
ctx.beginPath
ctx.arc(x + r, y + r, r * 0.92, 0, Math.PI * 2)
ctx.fill
} else {
roundRect(ctx, x + cell * 0.06, y + cell * 0.06, cell * 0.88, cell * 0.88, cell * 0.3)
ctx.fill
}
}
}三隅のファインダーパターン(位置検出マーク)だけは、どのスタイルでも正方形で描く。 ここを丸くすると見た目は可愛くなりますが、読み取り機がコードの向きと位置を特定できなくなり、スキャン率がはっきり落ちます。
function isFinder(row: number, col: number, n: number): boolean {
return (
(row < 7 && col < 7) ||
(row < 7 && col >= n - 7) ||
(row >= n - 7 && col < 7)
)
}左上・右上・左下の 7×7 を判定して除外するだけの単純な関数ですが、これがあるかないかで「おしゃれだけど読めない QR」と「おしゃれで読める QR」が分かれます。
ドット形状は 3 種類。square(規格どおり)、dot(円)、rounded(角丸、既定)。円と角丸はどちらもセルより少し小さく描いていて(r * 0.92、cell * 0.88)、隣り合うマスの間にわずかな隙間ができます。これがデザイン的な軽さになる一方、詰めすぎるとコントラストが落ちるので、この係数はスキャンを試しながら決めました。
#🏷️ 中央ロゴが成立する理由
QRコードの真ん中にロゴを置けるのは、誤り訂正があるからです。QR は一部が汚れたり隠れたりしても復元できるよう、冗長なデータを持っています。誤り訂正レベルは L / M / Q / H の 4 段階で、H なら約 30% が失われても復元できる。
if (logo) {
const ls = size * 0.2
const lx = (size - ls) / 2
const ly = (size - ls) / 2
// 白縁 で 視認性
ctx.fillStyle = style.bg
roundRect(ctx, lx - ls * 0.08, ly - ls * 0.08, ls * 1.16, ls * 1.16, ls * 0.16)
ctx.fill
ctx.save
roundRect(ctx, lx, ly, ls, ls, ls * 0.12)
ctx.clip
ctx.drawImage(logo, lx, ly, ls, ls)
ctx.restore
}ロゴのサイズを全体の 20% に固定しているのは意図的です。面積比で 4% 程度なので、誤り訂正レベル M でも十分復元範囲に収まる。ここをユーザーが自由に広げられるようにすると「大きくしたら読めなくなった」という事故が必ず起きます。
ロゴの周りに背景色の縁を敷いているのは、ロゴとコードが視覚的にくっついて境界が曖昧になるのを防ぐため。clip で角丸にくり抜いてから drawImage しているので、正方形でないロゴでも縁が破綻しません。
#😓 苦労したところ
#quiet zone を削りたくなる誘惑
QRコードの周囲には「クワイエットゾーン」と呼ばれる余白が必要で、規格上は 4 モジュール分。デザイン的にはこの余白が邪魔に見えるので、削りたくなります。
削ると、背景と QR の境界が読み取り機に判別できなくなり、特に背景色を暗くしたときに途端に読めなくなる。既定値は 4 のまま残し、変更はできるが初期値では触らせない、という形にしました。
#反転 QR(白い QR)は動く端末と動かない端末がある
プリセットに「反転(白QR)」を入れています。黒背景に白いモジュールという配色です。
規格上、QR は「明るい背景に暗いモジュール」が前提で、反転は本来サポート外。ところが現代のスマホカメラの多くは反転も読んでくれます。**「読める端末が多いが、保証はない」**という中途半端な状態なので、プリセットとしては用意しつつ、印刷前に必ず実機で試してほしいものだと位置づけています。
#PNG と SVG で描画コードが二重になる
Canvas への描画と SVG 文字列の生成は、同じ行列から別の出力を作る処理です。素直に書くと、ドット形状の分岐が両方に現れて二重管理になります。
結局、共通化は諦めました。Canvas は arc や arcTo で描き、SVG は <circle> や <rect rx> で書く——命令の粒度が違いすぎて、無理に抽象化すると「どちらにとっても書きにくい中間層」ができるだけだと判断したためです。分岐が 3 つしかないので、素直に並べるほうが読めます。
#🔭 今後の拡張
- 一括生成 — CSV を読ませて複数の QR をまとめて出す
- フレーム / キャプション — 「読み取ってください」の枠を付けた状態で書き出す
- URL 以外のプリセット — Wi-Fi 接続情報、vCard、メール下書きなどの定型フォーマット
- 読み取りチェック — 生成した QR をその場でカメラで読ませて、成功するか確認する
最後のひとつは実は一番ほしい機能です。「デザインを盛ったら読めなくなった」を、印刷後ではなく生成時に気づけるので。
#💡 このサービスから言えること
QRコード生成は、サーバーが要る処理ではありません。 行列の計算も描画も、全部ブラウザでできる。それなのに主要なサービスの多くがサーバー経由なのは、技術的な必要というより、ユーザーを囲い込んで課金する導線として都合がいいからです。
サーバーを持たないと決めた瞬間に、「入力した URL はどこにも送られません」が説明ではなく事実になります。これは機能ではなく構造から出てくる性質で、あとから謳っても信用されにくい部分です。
もうひとつ。ライブラリが用意した「便利な出口」を使わず、一段手前のデータ構造(ここではモジュール行列)を取りに行くと、途端に自由度が上がります。画像を返してもらっていたら、ドット形状もグラデーションもロゴも実装できませんでした。ライブラリを選ぶときに「何を返してくれるか」だけでなく「途中の値を取れるか」を見るようになったのは、このサービスからの学びです。
[ ./next_action ]
読んだら、 qr-forge を実際に動かす。
この開発ログは qr-forge をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。