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

QRコードを小さくするためURL文字数を極限まで削った

0
Posted at

3Dプリンターで製作したアイテムにレーザー加工機で使用法を解説したページへのリンクを含むQRコードをマーキングしています。
QRコード適用例
今回、モデルのデザイン変更でQRコードを入れられるスペースに制限が出てきてしまい、単純に縮小すると一部のスマホカメラで読み取れない問題が発生。できる限りQRコードを小さく(セル数を少なく)すべく、URLの短縮を試みました。なおリサーチには主にGeminiを用いており、間違った情報を含んでいる可能性があります。

外部URL短縮サービスは使用しない方針

最初に思いつくのはURL短縮サービスの利用ですが、今回これは除外しました。将来的にサービスが終了したらリダイレクトされなくなってしまいますし、なかには一定期間後に課金しないと転送しないようにされるサイトもあると聞き、そういった社外ソリューションに依存しないことを前提にしました。

QRコードの冗長レベルを下げる

QRコードは一部が欠けたり汚損しても正常に読み取れるよう一定の冗長性が含まれています。この冗長レベルに段階があり、

  • L (Low): 汚れ約7%まで復元可能
  • M (Medium): 汚れ約15%まで
  • Q (Quartile): 汚れ約25%まで
  • H (High): 汚れ約30%まで
    QRコードジェネレーターに選択欄があり、これまでの使っていたQRコードはデフォルトでM(15%の冗長性)が適用されていたっぽいです。今回はプラスチックにレーザーマーキングで、使用状況からみても後から剥げたり欠けたりするリスクは高くないと判断し、最低のL(7%の冗長性)に下げることにしました。

URLの要件

現行のQRコードの形式は、
https://hogefuga.com/qr/?num=123
という形式で、/qr/index.phpでnumパラメーターを読み取って変換テーブルで意図したURLにリダイレクトしていました。ちなみにWordPress+WooCommerceの商品IDで、3桁または4桁を取りうる数字です(総文字数32文字)。
hogefuga.com部分は独自ドメインで文字数は8文字で固定。より短いドメイン名を取得するのもコストがかかるので除外します。

スキーム部分を省略する?(却下)

最初に思いつくのは「https」の部分を省略すること。ブラウザのアドレス欄に「hogefuga.com」と入れた場合勝手に補完してくれがち。ただQRコードリーダーがURLだと認識してくれないとそもそもブラウザに渡してくれない可能性もあるなと。GeminiとChatGPTで確認した感じ、QRコードリーダーの実装に依存するのでお勧めしないと言われました。「//hogefuga.com」のようにスラッシュx2だけというのでもダメぽい。せいぜいhttp://にしてサーバー側でリダイレクトするくらい?

パスやパラメーターを短縮?

パスを自由にできる自営サーバーなので、ディレクトリ名やスクリプト名でどうにかすることを検討しました。例えば、
https://hogefuga.com/q/?n=1234
など。これで30文字。劇的な短縮は見込めません。

QRコードには英数字モードというものがある

調べるうちに、QRコードの規格に「英数字モード」と「バイナリー(8bit byte)モード」というものがあるということを知りました。英数字モードは、「数字(0-9)、英大文字(A-Z)、記号(空白, $, %, *, +, -, ., /, :)」のみで表現されるモードで、これらの文字だけで構成するとより小さいセル数になるのだそう。それ以外の文字がひと文字でも混じるとバイナリーモードになり、1文字当たりのビット数が増えて全体としてガツンと大きなQRコードになってしまうとのこと。
なるほどいいことを聞いた!とURLを大文字で記述しようと考えたわけですが、よくみるとURLパラメーターに必要な?や=が対象外...

=や?を使わないでURLパラメーターを実現する

そこでGeminiに提案されたのは、.htaccessなどでmod_rewriteルールを使ってリダイレクトする方法。例えば、
HTTPS://HOGEFUGA.COM/QR1234
みたいなURLフォーマットにして、正規表現でqrに続く4桁の数字をパラメーターとし、本来のURLにリダイレクトするという方法です。もともとスキームやホスト部はケースインセンシティブなので、パスだけ大文字になるように配慮すれば対応可能です。
これで、生成されるQRコードが英数字モードになり、更にセル数が小さくできます。

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteRule ^/QR([A-Z0-9]{3})$ /qr/?id=$1 [NC,R=301,L,QSA]
</IfModule>

てな感じで/QR1234/qr/index.php?id=1234にリダイレクトされるようにしました。

そもそもQRコードのセルサイズはリニアに変化するわけではない

そもそもQRコードは正方形に限定しているため、元の文字列が1文字増えるごとにリニアにサイズが大きくなるわけではないはず。なにかしら階段状に閾値をまたいだときにガタっとサイズが変化する仕組みがあるはず、ということで調べたところ、「バージョン」という概念があることがわかりました。最小サイズ帯でいうとこんな感じ。

バージョン セル数 文字数(英数字) 文字数(バイナリ)
1 21 x 21 25文字 17文字
2 25 x 25 47文字 32文字
3 29 x 29 77文字 53文字

つまり、英数字モードに含まれる文字のみで25文字以内で構成すれば、最小の21 x 21セルのQRコードが実現できることになります。つまり「大文字で?や=を使わない25文字のURL」が今回のゴールとなります。
HTTPS://HOGEFUGA.COM/で21文字なので、残りわずか4文字で3~4桁の値を表現しなけばなりません。そして既存サービスも色々動いているサーバーなので、なんとなく4文字をフルでID文字列に使うのには抵抗があります。そこで、HTTPS://HOGEFUGA.COM/Q123のように先頭が大文字のQならばrewriteで続く3文字をIDとして/qr.php?param=123にリダイレクトすることにしました。000~999まで千通りのIDが作れればいいかなと。WooCommerceのアイテムIDは(実際には50アイテムもないにも関わらず)既に4桁に到達しているので、なんらかの変換テーブルが必要になりますが仕方ないかなと、、、

Geminiから良提案。Base36エンコード!?

そんな方針をGeminiに相談していたらしれっとBase36というエンコードを採用しては?と提案されました。Base64は有名ですが36は初耳です。曰く、
英数字3文字で$36^3 = 46,656$ 通りのパラメーターが表現できるということです。まぁ、実際に1,000アイテムを超えることは現実的にはないのですが、適当なパラメーターから正解URLに到達されてしまうリスクは軽減できそうです(まぁそれで困ることも実質ないですが)。コード内にシード値のようなものが含まれており、これを知られない限り、変化ロジックを悪用されることはなさそうなのです。

結論として、HTTPS://HOGEFUGA.COM/QA4CのようにQ+英数字3文字でアクセスすると、A4Cを1234のような数字に展開して、https://hogefuga.com/qr/?id=1234にリダイレクトする、という仕様に確定しました。これでピッタリ25文字になるので、もっとも小さい21 x 21セルのQRコードで表現可能になります。

実装編

具体的な実装には3つのコードをGeminiに生成してもらいました。

  1. Apacheで設定するrewriteルール
  2. リダイレクトを受けたPHPスクリプト
  3. Base36エンコードを行うジェネレーター

1.は既に上に書いたので割愛します。最終的に/QRではなく/Qにしたくらい。
次に2.の英数3文字のパラメーターをBase36デコードして一定のルールで更にリダイレクトするPHPスクリプトです。

<?php
/**
 * 3文字のBase36コードをデコードし、本来のURLへリダイレクトする
 */

// --- 設定値 (適当な値を設定してください) ---
define('QR_MOD', 46656);      // 36の3乗 (3文字固定)
define('QR_OFFSET', 54321);   // 後述のfunctions.phpで指定したオフセット値B
define('PRIME_INV', 40933);   // 同上の素数Aから生成できる逆元値(A * 40933 % M == 1 となる数)
define('BASE_DOMAIN', 'https://store.hogefuga.com/'); //遷移先のホスト部

/**
 * 3文字のコードを元の数値IDに復元する
 */
function decrypt_id($code) {
    // Base36(36進数)を10進数に戻す
    $val = (int)base_convert(trim($code), 36, 10);
    
    // 数学的にシャッフルを逆転させる
    $res = (($val - QR_OFFSET) * PRIME_INV) % QR_MOD;
    while ($res < 0) $res += QR_MOD;
    
    return (int)$res;
}

// パラメーター取得 (例: index.php?id=ABC)
$code = $_GET['param'] ?? '';

if (strlen($code) === 3) {
    $item_id = decrypt_id($code);
    
    // WooCommerce等の商品ページ(/?p=ID)へリダイレクト
    // WordPressはID指定でアクセスすると、自動的にパーマリンクへ正規化リダイレクトされます
    $destination = BASE_DOMAIN . "?p=" . $item_id;
    
    header("Location: " . $destination, true, 301);
    exit;
}

// 該当なし
header("HTTP/1.1 404 Not Found");
echo "Invalid QR Code.";

WooCommerceは個別に指定してスラッグとは別に、商品IDを/?p=1234で指定してページを開くことができるようなので、最終的には、https://store.hogeguga.com/?q=1234に飛ばすことにしています。
冒頭のQR_MOD、PRIME、OFFSETがシード値で、これを改変しておけば変換ロジックを固有のものにできるようです。PRIME_INVはエンコードに使う値のメモでこのスクリプト内では使用されません。ここの値はこのままコピペせず、AIに別個に生成してもらってください(もちろん私が実際にサイトで運用しているものとも違います)。

そして3.のジェネレーターですが、WooCommerceのアイテムIDから生成するので、WordPress上で実装することにしました。商品一覧画面にカスタムカラムを追加し、クリックすると生成されたURLをクリップボードにコピーするボタンを配置しています。これをQRコードジェネレーターにペーストすればOK。

画面例
この辺りは用途次第だと思いますが、一応WordPressの子テーマのfunctions.phpに入れたコードを例示しておきます。

<?php
// ==========================================
// WooCommerce 商品一覧:QR URLコピーボタン統合版
// ==========================================

// 1. カラムの追加(「名前」の直後に挿入)
add_filter('manage_edit-product_columns', function($columns) {
    $new_columns = [];
    foreach ($columns as $key => $title) {
        $new_columns[$key] = $title;
        if ($key === 'name') {
            $new_columns['qr_code_copy'] = 'QR URL';
        }
    }
    return $new_columns;
});

// 2. カラムの内容(ボタン)を描画
add_action('manage_product_posts_custom_column', function($column, $post_id) {
    if ($column === 'qr_code_copy') {
        // --- エンコードロジック(/qr/index.phpと揃える) ---
        $mod    = 46656; // 36^3で3文字を規定
        $prime  = 17389; // 任意の素数A
        $offset = 54321; // 任意のオフセットB
        
        $shuffled = ($post_id * $prime + $offset) % $mod;
        $code = str_pad(strtoupper(base_convert($shuffled, 10, 36)), 3, '0', STR_PAD_LEFT);
        $full_url = 'HTTPS://HOGEFUGA.COM/Q' . $code;

        // ボタンとステータス表示用要素を出力
        printf(
            '<div style="position:relative;">
                <button type="button" class="button button-small qr-copy-btn" data-url="%s">
                    <span class="dashicons dashicons-admin-page" style="font-size:14px; vertical-align:middle; margin-top:4px;"></span> 
                    Q%s
                </button>
                <div class="copy-status">Copied!</div>
            </div>',
            esc_attr($full_url),
            esc_html($code)
        );
    }
}, 10, 2);

// 3. スタイルとJavaScriptの注入
add_action('admin_head', function() {
    ?>
    <style>
        /* QR列の幅を固定 */
        .column-qr_code_copy {
            width: 100px !important;
            white-space: nowrap;
            vertical-align: middle !important;
        }
        .qr-copy-btn {
            width: 85px !important;
            font-weight: bold !important;
            display: flex !important;
            align-items: center;
            justify-content: space-around;
        }
        /* コピー完了通知のスタイル(ボタンの上に浮かせます) */
        .copy-status {
            position: absolute;
            top: -25px;
            left: 50%;
            transform: translateX(-50%);
            background: #46b450;
            color: #fff;
            padding: 2px 8px;
            border-radius: 4px;
            font-size: 11px;
            display: none;
            z-index: 100;
        }
        .copy-status::after { /* 吹き出しのしっぽ */
            content: "";
            position: absolute;
            top: 100%;
            left: 50%;
            margin-left: -5px;
            border-width: 5px;
            border-style: solid;
            border-color: #46b450 transparent transparent transparent;
        }
    </style>
    <script>
    jQuery(function($){
        $(document).on('click', '.qr-copy-btn', function(e) {
            e.preventDefault();
            var $btn = $(this);
            var url = $btn.data('url');
            var $status = $btn.next('.copy-status');

            if (navigator.clipboard) {
                navigator.clipboard.writeText(url).then(function() {
                    $status.stop().fadeIn(200).delay(800).fadeOut(200);
                });
            } else {
                // Clipboard API非対応(古いブラウザ用フォールバック)
                var $temp = $("<input>");
                $("body").append($temp);
                $temp.val(url).select();
                document.execCommand("copy");
                $temp.remove();
                $status.stop().fadeIn(200).delay(800).fadeOut(200);
            }
        });
    });
    </script>
    <?php
});

少しややこしいのが、

        $mod    = 46656; // 36^3で3文字を規定
        $prime  = 17389; // 任意の素数A
        $offset = 54321; // 任意のオフセットB

の部分と、先のデコードスクリプトの

define('QR_MOD', 46656);      // 36の3乗 (3文字固定)
define('QR_OFFSET', 54321);   // 後述のfunctions.phpで指定したオフセット値B
define('PRIME_INV', 40933);   // 同上の素数Aから生成できる逆元値(A * 40933 % M == 1 となる数)

の関係です。
modは3文字である以上固定です。どちらも共通で46656を指定。
エンコード側にのみ記載するprimeは所謂シード値として素数を指定。より厳密には「「$mod$ の因数である 2 と 3 を含まない数(互いに素な数)を選ぶ。手っ取り早いのは、2 と 3 以外の素数」ということらしい。例えば、13337、17389、40009、44449などがあるそうです。これはそれこそ生成AIに投げて決めてもらうのがいいかも。
offsetも適当な数字を両方揃えて入れます。
デコード側のPRIME_INVはエンコード側の3つの数値から計算して決まる数値です。シード値となるprimeの値をデコードスクリプト側に含めないことでセキュリティを高めている感じです。算出コードは、
$prime_inv = gmp_strval(gmp_powm($prime, 15551, $mod));
という感じらしい。15551は固定値。これで算出した値をPRIME_INVとし、$mod値はデコードスクリプト内に書かないのが推奨らしいです。この辺り、自分も100%理解できてないのでわかりにくかったらごめんなさい。

まとめ

WooCommerceのアイテムIDを/?p=1234のような形式で指定して遷移するURLを、英数3文字で表現して、8+3文字のホスト名とあわせて25字に押さえて最小サイズのQRコードを生成する運用ロジックを作成しました。
ホスト名、ドメイン名がもっと短ければもう少しシンプルなロジックで直接p=1234形式のURLを単純にQRコード化できるでしょう。逆に、あと少しでも長いホスト名だと25字に押さえるのは難しいかも知れません。それでもBase36エンコードで英字を使うことで、

  • 3桁数字(000~999の1,000通り)→3桁英数字(4.6万通り)
  • 2桁数字(00~99の100通り)→2桁英数字(1,300通り)

と大幅にパターンを詰め込めるでしょう。

最後に今回縮小できたQRコードを比較してみます。

オリジナル

https://hogefuga.com/qr/?num=1234
バイナリーモード
冗長化(誤り訂正)レベルM(15%冗長)
→29x29セル

改善後

HTTPS://HOGEFUGA.COM/QF3G
英数字モード
冗長化(誤り訂正)レベルL(7%冗長)
→21x21セル

これでより小さいサイズで印刷/マーキングしてもセル解像度を保て、スキャン耐性が担保できるでしょう。

オマケ:リーダー実装毎のスキャン耐性

今回、実際にマーキングしたものをスマホの標準カメラアプリでスキャンした場合、iPhoneでは元の29x29セルを小さくしたものもきちんと読み込めました。一方、Androidというか(センサーサイズの大きな)Xiaomi 15 Ultraでは読み込めなかったのが発端です。その後、Gemini曰く、本来のQRコードは白字に黒が基本で、冒頭写真のような黒い地に白っぽいQRコードをプリントしたものを読むのは実装依存と言われました(ChatGPTには言われなかったので、真実は不明)。またQRコードの周りに一定の余白(クワイエットゾーン)を設けることも規定されている関係で、余白がないギリギリサイズのプリントも実装によっては対応しきれないこともあるようです。白黒反転させて余白をつけたり色々テストした中で、iPhone 16 Pro Maxはほぼ読み取りました。Androidで、Xiaomi 15 Ultra、Samsung Galaxu S25 Ultra、Pixel 10 Pro XL辺りでは読めたり読めなかったり。またサードパーティのQRリーダーアプリなら読めたりとカメラ性能だけでなくソフトウェア依存の部分も大きいなという結果でした。参考として付記しておきます。

0
0
0

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