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.92cell * 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 は arcarcTo で描き、SVG は <circle><rect rx> で書く——命令の粒度が違いすぎて、無理に抽象化すると「どちらにとっても書きにくい中間層」ができるだけだと判断したためです。分岐が 3 つしかないので、素直に並べるほうが読めます。


#🔭 今後の拡張

  • 一括生成 — CSV を読ませて複数の QR をまとめて出す
  • フレーム / キャプション — 「読み取ってください」の枠を付けた状態で書き出す
  • URL 以外のプリセット — Wi-Fi 接続情報、vCard、メール下書きなどの定型フォーマット
  • 読み取りチェック — 生成した QR をその場でカメラで読ませて、成功するか確認する

最後のひとつは実は一番ほしい機能です。「デザインを盛ったら読めなくなった」を、印刷後ではなく生成時に気づけるので。


#💡 このサービスから言えること

QRコード生成は、サーバーが要る処理ではありません。 行列の計算も描画も、全部ブラウザでできる。それなのに主要なサービスの多くがサーバー経由なのは、技術的な必要というより、ユーザーを囲い込んで課金する導線として都合がいいからです。

サーバーを持たないと決めた瞬間に、「入力した URL はどこにも送られません」が説明ではなく事実になります。これは機能ではなく構造から出てくる性質で、あとから謳っても信用されにくい部分です。

もうひとつ。ライブラリが用意した「便利な出口」を使わず、一段手前のデータ構造(ここではモジュール行列)を取りに行くと、途端に自由度が上がります。画像を返してもらっていたら、ドット形状もグラデーションもロゴも実装できませんでした。ライブラリを選ぶときに「何を返してくれるか」だけでなく「途中の値を取れるか」を見るようになったのは、このサービスからの学びです。

[ ./next_action ]

読んだら、 qr-forge を実際に動かす。

この開発ログは qr-forge をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →