▍AI Dev Lab
qr-forge

qr-forge ができるまで — デザイン QR を自前描画で作る

デザイン QR コード作成ツールの開発ログ。 qrcode-generator からドットの並びだけを取り出し、 形・色・グラデーション・中央ロゴを自前で描画。 作った QR を jsQR で読み直して壊れていないか検証しています。

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 は、ライブラリをモジュール行列(どのマスが黒か)の計算だけに使い、描画は全部自前でやることにしました。

ts
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 からスクリプトタグで読み込む方式にして、バンドルサイズには影響させていません。

ts
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 を保持しているのは、入力を打つたびに再生成が走るため。連続でスクリプトタグを増やさないための単純なガードです。


#🎨 描画:マス目をどう塗るか

行列が手に入れば、あとは二重ループで塗るだけ——なのですが、ここに壊してはいけない一線があります。

ts
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
 }
 }
}

三隅のファインダーパターン(位置検出マーク)だけは、どのスタイルでも正方形で描く。 ここを丸くすると見た目は可愛くなりますが、読み取り機がコードの向きと位置を特定できなくなり、スキャン率がはっきり落ちます。

ts
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% が失われても復元できる。

ts
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 をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ShadowTwin

立体パズルの別解を作る:同じ影と最少の積み木

ShadowTwinの開発記録。3方向の図から別々の最少配置を2つ探す仕組み、5問の独立全探索、スマホの横並び比較、保存・再編集と入力検査を解説します。実照明の計算や能力診断ではありません。

read log →
SlitCard

スリットアニメーションを作る:4コマの合成と透明原稿の設計

SlitCardの開発記録。4コマの帯を交互に合成し、すき間と黒帯で別のコマを見せる仕組みを解説。同縮尺の台紙・透明シートSVG、50mm確認線、手動プレビュー・初期非表示、再編集と実出力検査を整理します。

read log →
WindowLight

日当たりシミュレーションを作る:窓の光を床へ投影する幾何モデル

WindowLightの開発記録。太陽位置近似から窓の4隅を床へ投影し、凸多角形を部屋で切り取る設計を解説。国立天文台の16時刻との限定照合、大気差・低高度・実際の採光との違い、SVGと設定の再編集を整理します。

read log →
GridLights

電源構成の比較を作る:割合と実量を100個の電球で読み解く

電源構成比較GridLightsの開発記録。12か国の年次発電データを最大剰余法で100個の電球に変換し、割合とTWhを比較する設計を解説。欠損を0補完しない7分類、予想と実値、出典付きSVGと実データ検証を整理します。

read log →
SoundLoom

音のお絵かきを作る:実WAVから絵を測り直す設計

音のお絵かきの開発記録。描画を正弦波のPCMとWAVへ変換し、保存音声の周波数をGoertzel法で測って比較する仕組みを解説。音量・測定窓・量子化の限界と、編集・離脱時の古い結果を抑止する検証を整理します。

read log →
MazeLoom

宝探し迷路を作る:寄り道まで含めた最短ルートの探索

宝探し迷路の開発記録。通路を木として生成し、現在地と3つの宝の取得状態を組み合わせて最短ルートを探す仕組みを解説。問題コードの版固定、画面と解答付きPDFの一致、手数と印刷の検証を整理します。

read log →