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?

CSVからQRコードを一括生成してZIP保存する — ファイル名衝突と容量超過をブラウザで捌く実装

0
Posted at

3行まとめ

  • CSVの1列をQRコードに一括変換し、PNG/SVGをZIPでまとめて保存するツールをぱんだツールズに追加した。商品URLや会員番号のリストをそのまま放り込める
  • 実装の大半は「QRを描く」部分ではなく、ファイル名の安全化と衝突回避(Windows予約名・大文字小文字を区別しないOS)と、容量超過の2段階チェック(計画時のバイト数判定+生成時の例外捕捉)に費やしている
  • 1行の失敗で全体を止めず、行番号つきでスキップ理由を返す設計にした。ZIPは無圧縮(STORE)、25件ごとにメインスレッドを譲って進捗とキャンセルを効かせている

QRコードを1個作るツールはいくらでもあるが、「商品100点分のQRを作って、商品IDのファイル名で保存したい」となると途端に面倒になる。1個ずつ生成して名前を付けて保存、を100回繰り返すことになるからだ。ぱんだツールズにCSVからQRコードを一括生成するツールを追加した。

単体のQR生成・読み取りの実装は以前の記事で書いたので、この記事では一括処理ならではの問題、つまりファイル名の衝突・容量超過・大量件数の処理をどう捌いたかを中心に書く。

何をするツールか

CSVを読み込むと、QRコードにする列(内容列)とファイル名に使う列を選べる。たとえばこういうCSVなら、

商品ID,商品名,URL
A001,りんご,https://example.com/items/a001
A002,みかん,https://example.com/items/a002
A003,ぶどう,https://example.com/items/a003

URL列がQRの中身、商品ID列がファイル名として自動選択され、A001.png・A002.png・A003.pngの入ったqrcodes.zipがダウンロードされる。

  • 出力形式: PNG(256 / 512 / 1024px)または SVG
  • 誤り訂正レベル: L / M(デフォルト)/ Q / H
  • ファイル名: 列の値、または連番(001.png〜)。先頭に付けるプレフィックスも指定できる
  • 文字コード: UTF-8 / Shift_JIS を自動判定
  • 入力: CSVファイルの投入か、テキストの貼り付け

処理はすべてブラウザ内で完結し、CSVはサーバーに送らない。顧客名簿や会員番号のような、外部に出したくないデータでもそのまま使える。

使っているライブラリは、CSV解析が Papa Parse、QR生成がqrcode、ZIP作成が JSZip。

「計画」と「描画」を分ける

コードは大きく2つに分けた。CSVの行から「どの内容を、どのファイル名で出すか」を決める純粋関数planQrBatchと、その計画に従ってQRを描いてZIPに詰めるbuildQrZipだ。

src/lib/csv/planQrBatch.ts
export interface QrBatchItem {
  /** 拡張子を除いたファイル名 */
  name: string
  /** QR コードにする文字列 */
  content: string
  /** CSV 上の行番号(ヘッダーを 1 行目とする) */
  lineNumber: number
}

export interface QrBatchPlan {
  items: QrBatchItem[]
  skipped: SkippedRow[]
}

計画側はReactのuseMemoで列の選択やプレフィックスが変わるたびに再計算され、「何件生成されるか」「何行がスキップされるか」を生成ボタンを押す前に表示できる。純粋関数なのでテストも書きやすく、planQrBatch.test.tsには(ファイル名の安全化・列推定も含めて)30件近いケースを書いている。ファイル名まわりの泥臭い処理はほぼすべてこちらに寄せた。

ファイル名の安全化:Windows で展開できない名前を作らない

CSVの値をそのままファイル名にすると、いろいろ壊れる。/や:が入っていればパスとして解釈されるし、WindowsではCON.pngのような予約名のファイルはZIPから展開できない。sanitizeFilenameで順に潰している。

src/lib/csv/planQrBatch.ts
const MAX_FILENAME_LENGTH = 50
const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i

export function sanitizeFilename(raw: string): string {
  const cleaned = raw
    .replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, '_')
    .trim()
    .replace(/^\.+/, '')
    .replace(/[. ]+$/, '')
  const truncated = Array.from(cleaned).slice(0, MAX_FILENAME_LENGTH).join('')
  // Windows の予約名(CON.png など)は展開に失敗するため先頭に _ を付ける
  return WINDOWS_RESERVED_NAME.test(truncated) ? `_${truncated}` : truncated
}

やっていることは5つ。

  1. Windowsでファイル名に使えない記号と制御文字を_に置換
  2. 先頭のドットを除去(.hiddenのような隠しファイル化や..を防ぐ)
  3. 末尾のドットとスペースを除去(Windowsは末尾のドット・スペースを黙って落とすため、別名同士が同じ名前になりうる)
  4. 50文字に切り詰め。sliceではなくArray.fromで分割しているのは、絵文字などのサロゲートペアを途中で切らないため
  5. 予約名なら先頭に_を付ける

衝突回避:大文字小文字を区別しない OS を前提にする

商品名をファイル名に選ぶと、同じ値が複数行に出てくることはよくある。ZIP内に同名ファイルを2つ入れると、展開時に上書きされて1件消える。重複した名前には_2、_3を付けて回避している。

src/lib/csv/planQrBatch.ts
const serial = String(index + 1).padStart(digits, '0')
const fromColumn =
  filenameColumn === null ? '' : sanitizeFilename(row[filenameColumn] ?? '')
const base = `${safePrefix}${fromColumn || serial}`

// 大文字小文字を区別しない OS(Windows / macOS)でも衝突しないよう小文字で比較する
let name = base
for (let n = 2; usedNames.has(name.toLowerCase()); n++) {
  name = `${base}_${n}`
}
usedNames.add(name.toLowerCase())

ポイントは2つある。

1つ目は小文字化して比較していること。ZIPの中ではApple.pngとapple.pngは別ファイルだが、WindowsやmacOSの標準設定では同じ名前として扱われ、展開すると片方が消える。生成側で衝突を防ぐなら、展開先のOSの基準に合わせる必要がある。

2つ目は、採番した名前自体がほかの行の名前とぶつかるケース。たとえばファイル名列がA、A、A_2と並んでいると、2行目はA_2に採番され、3行目のA_2はそれと衝突する。usedNamesに採番後の名前を入れてから次の行を処理しているので、3行目はA_2_2になって衝突しない。衝突しないことは専用のテストケースで確認している。

ファイル名列が空欄の行(サニタイズ後に空になった場合も含む)は連番にフォールバックする。連番の桁数はMath.max(3, String(件数).length)で、1000件以上なら4桁になる。lsやZIPビューアのように辞書順で並べる環境で、1000.pngが101.pngの前に来るような崩れ方を防ぐためだ。

容量超過は「計画時」と「生成時」の2段階で弾く

QRコードに入れられるデータ量には上限がある。最大のバージョン40・誤り訂正レベルLで、バイナリなら2,953バイトだ。

src/lib/csv/planQrBatch.ts
/** QR コード(バージョン40・誤り訂正 L)のバイナリ最大容量 */
export const QR_MAX_BYTES = 2953

function utf8ByteLength(text: string): number {
  return new TextEncoder().encode(text).length
}

計画時は、内容をUTF-8にエンコードしたバイト数でこの上限を判定し、超えた行は理由付きでスキップリストに入れる。文字数ではなくバイト数で見るのが大事で、日本語はUTF-8で1文字3バイトなので、上限は約980文字になる。

ただし2,953バイトは誤り訂正レベルLのときの値で、M・Q・Hと上げていくと容量は小さくなる。そこで計画時はレベルに依存しない上限だけを見ておき、レベルごとの実際の容量オーバーは生成時にqrcodeライブラリが投げる例外で拾うようにした。

src/app/tools/csv-to-qr/CsvToQrClient.tsx
for (let i = 0; i < items.length; i++) {
  try {
    await addQrToZip(zip, items[i], settings)
    generated += 1
  } catch (err: unknown) {
    const detail = err instanceof Error ? err.message : '不明なエラー'
    failed.push({ lineNumber: items[i].lineNumber, reason: `生成できませんでした(${detail})` })
  }
  // ...
}

レベルごとの容量表を自前で持って事前判定することもできるが、qrcodeは内容に応じて数字・英数字・バイトの各モードを混ぜて符号化するので、正確な容量は実際に符号化してみないと分からない。計画時は「どのレベルでも絶対に入らないもの」だけを安く弾き、境界の判定はライブラリに任せる分担にした。数字だけの長い内容なら数字モードで2,953バイト以上入るが、計画時はバイト数で一律に判定するので、そこは保守側に倒している。

どちらで弾かれた場合も、CSV上の行番号と理由を画面に出す。1行の失敗で全体を止めず、生成できた分だけでZIPを作る。1件も生成できなかった場合だけ、列の選択か誤り訂正レベルを見直すよう促すエラーにしている。

大量件数:無圧縮 ZIP と、25件ごとの譲り合い

件数の上限は設けていない。そのぶん、大量件数でもタブが固まらないようにしている。

src/app/tools/csv-to-qr/CsvToQrClient.tsx
const YIELD_INTERVAL = 25

if ((i + 1) % YIELD_INTERVAL === 0 || i === items.length - 1) {
  onProgress(Math.round(((i + 1) / items.length) * 90))
  await nextTick()
  if (isCancelled()) return null
}

nextTickはsetTimeout(resolve, 0)を待つだけの関数で、25件ごとにメインスレッドをブラウザに返す。これで進捗バーの描画が更新され、ボタン操作も受け付けられる。

キャンセルは「世代番号」で実装した。useRefで持つカウンタを、生成開始・新しいCSVの読み込み・入力モードの切り替えのたびにインクリメントし、生成ループは自分が開始したときの番号と現在の番号を比べて、ずれていたら途中で抜ける。生成中に別のCSVを放り込まれても、古い生成結果が後から画面に出てくることがない。

ZIPの生成はcompression: 'STORE'(無圧縮)にした。

const blob = await zip.generateAsync({ type: 'blob', compression: 'STORE' }, (meta) => {
  if (!isCancelled()) onProgress(90 + Math.round(meta.percent / 10))
})

PNGはそれ自体がDeflateで圧縮された形式なので、ZIPでもう一度Deflateをかけてもほとんど縮まず、CPU時間だけを使う。SVGはテキストなので圧縮は効くが、QRのSVGは1個あたり小さいので、件数が多いときの待ち時間を優先した。

FAQには目安として「PNG 512pxで1,500件の生成に約30秒」と書いていて、1,000件を超えると画面に注意を出す。

列の自動推定と文字コード判定

CSVを読み込んだ時点で、ヘッダー名から内容列とファイル名列を推定している。

src/lib/csv/planQrBatch.ts
const CONTENT_KEYWORDS = ['url', 'リンク', 'link', '内容', 'text', 'テキスト', 'コード', 'code', 'データ', 'data']
const FILENAME_KEYWORDS = ['ファイル名', 'filename', '名前', '氏名', 'name', '商品名', 'id', '番号']

内容列を先に決め、ファイル名列はそれ以外の列から探す。どちらも見つからなければ内容は先頭列、ファイル名は連番になる。推定はあくまで初期値で、セレクトボックスには各列の値の例を表示して、ユーザーが選び直せるようにしている。

文字コードは、まずTextDecoder('utf-8', { fatal: true })で厳密にデコードを試み、不正なバイト列で例外が出たら Shift_JIS で読み直す。Excelの従来の「CSV(コンマ区切り)」形式で保存すると Shift_JIS になるので、この2択で日本の実務のCSVはほぼカバーできる。

まとめ

  • 一括生成ツールの実装は、QRの描画そのものより「1件ずつなら起きない問題」を潰す作業が中心だった
  • ファイル名は記号・制御文字・前後のドット・長さ・Windows予約名を順に処理し、衝突判定は展開先OSに合わせて小文字で比較する。採番後の名前も衝突チェックの対象に入れる
  • 容量超過は、計画時にUTF-8バイト数でレベル非依存の上限を弾き、レベル依存の境界は生成時の例外で拾う2段構え。失敗は行番号つきで返し、全体は止めない
  • 大量件数は無圧縮ZIP・25件ごとのyield・世代番号によるキャンセルで、タブを固めずに捌く

ぱんだツールズ では他にもPDF・画像・CSV・テキスト処理などの開発者向けツールを110個以上公開中。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

0
0
1

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?