二维码批量生成工具 · 开发者手册

单文件 · 零依赖 · 纯前端的批量二维码生成器 —— 源码级技术文档
版本:Ver 1.0 | 文件:app.html | 约 1960 行 / 73 KB
E-mail:liyulai@qq.com | GitHub:@Locallabstide

📖 目录

  1. 项目概览
  2. 设计原则与约束
  3. 文件结构与代码分区
  4. 状态模型 state
  5. QR 生成内核
  6. 数据解析与 CSV 往返
  7. xlsx 轻量解析
  8. 渲染管线(Canvas / SVG)
  9. ZIP 打包器实现
  10. 预览清晰度系统
  11. 性能分析与量级
  12. 已知问题与待办
  13. 扩展指南
  14. 调试与自测

1项目概览

这是一个把「一张两列表格」批量转成「一堆带样式二维码图片」的纯前端工具。打包成单个 HTML 文件,双击即用,无需服务器、无需联网、无需安装任何依赖。

能力边界

维度说明
输入两列数据:{name, content}。来源:手动文本、.xlsx / .csv / .txt 导入
核心纯 JS 实现的 QR 生成内核(版本 1–40,UTF-8,L/M/Q/H,8 掩码择优)
样式尺寸、静区、前景/背景色、圆点样式、LOGO(10–25%)、上/下展示文字
输出PNG / JPG / SVG,单张下载或打包 ZIP
运行环境Chromium 103+ / Safari 16.4+ / Firefox 113+(依赖 CompressionStream)

代码规模

分区起始行职责
PART 1 QR 生成内核~451GF(256) 运算、容量表、分段编码、RS 纠错、矩阵构建
PART 2 xlsx 轻量解析~973ZIP 读取、sharedStrings、sheet XML、CSV/文本解析
PART 3 渲染~1159Canvas / SVG 绘制、布局计算、state、样式读取
PART 4 ZIP 打包~1415CRC32、deflateRaw、PKZIP 写入
PART 5 界面逻辑~1508事件绑定、预览、导出、模板下载、自检

2设计原则与约束

  1. 零依赖 —— 不引任何 CDN、npm 包。SheetJS、qrcode.js 这类常见库全部自研替代。
  2. 单文件 —— 一个 .html 即完整产品,方便通过邮件/U 盘分发,双击即用。
  3. 纯离线 —— 不发起任何网络请求,数据不离开本机。可断网验证。
  4. IIFE + 严格模式 —— 全部逻辑包在一个立即执行函数里,避免污染全局;"use strict" 强制显式声明。
  5. 渐进增强 —— CompressionStream 缺失时 ZIP 回退「存储模式」,功能不中断(仅体积变大)。
  6. 能力透明 —— UI 文案与代码行为严格一致;不支持的组合(如 SVG 不含 LOGO)要在界面上明说。
为什么不用现成库? 因为是「单文件离线分发」场景:引 CDN 会破坏离线;内联大库会显著增大文件体积并引入不必要的依赖面。自研 + 按需实现是这里的最优解。

3文件结构与代码分区

文件自上而下分为:<head> 内的样式 → <body> 的 DOM 骨架 → 单个 <script> 内的五个 PART。

<!DOCTYPE html>
<html>
  <head>
    <style>  ... 全部 CSS(约 440 行)  </style>
  </head>
  <body>
    <!-- 左栏:4 个步骤卡片;右栏:预览 + 文件清单 -->
    <script>
      "use strict";
      (function(){                      // 唯一入口 IIFE
        /* PART 1  QR 生成内核   */
        /* PART 2  xlsx 解析     */
        /* PART 3  渲染          */
        /* PART 4  ZIP 打包      */
        /* PART 5  界面逻辑      */
        (function init(){ ... })();       // 自检 + 首次渲染
      })();
    </script>
  </body>
</html>
阅读建议: 想改生成逻辑看 PART 1;想改解析看 PART 2;想改界面/样式看 PART 3 + PART 5;想改打包看 PART 4。

4状态模型 state

const state = {
  data: [],          // [{ name, content }]  ← 双列模型,核心数据结构
  current: 0,        // 预览中的下标
  logoImg: null,     // 已加载的 LOGO Image 对象
  nameMode: "index", // 名称为空时的命名策略:index | content | custom
  textPos: "none",   // 展示文字位置:none | top | bottom
  fmt: "png"         // 输出格式:png | jpg | svg
};

两列数据模型(架构要点)

state.data 是 {name, content} 对象数组,而非字符串数组。这一点贯穿所有模块:

数据流(单向)

textarea.value
   │  parseTextRows()          // 唯一解析入口
   ▼
state.data ──┬─→ renderList()    // 文件清单
             ├─→ updateStats()   // 统计(重复/超长/缺内容)
             ├─→ updatePreview() // 预览单条
             └─→ exportZip()     // 批量导出

样式控件 ── readStyle() ──→ st(传给所有渲染函数)
单一数据源: textarea 是唯一「真相来源」,state.data 由它派生。所有整理按钮(替换/去重等)都通过 setRowsToText() 写回 textarea 再重新解析,保证「界面显示的 = 实际使用的」。

5QR 生成内核

PART 1 是一个完整的 QR Code 生成器,思路参考 Nayuki 的算法结构,用纯 JS 重写。

5.1 GF(256) 有限域运算

// 预计算 exp/log 表,把乘法转成加法(查表)
const GF_EXP = new Uint8Array(512), GF_LOG = new Uint8Array(256);
(function(){
  let x = 1;
  for(let i = 0; i < 255; i++){
    GF_EXP[i] = x; GF_LOG[x] = i;
    x <<= 1; if(x & 0x100) x ^= 0x11D;   // 本原多项式 x^8+x^4+x^3+x^2+1
  }
  for(let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255];
})();

5.2 容量表(关键数据)

两张 41×4 的常量表,取自 ISO/IEC 18004 官方数据:

function getNumDataCodewords(ver, ecl){
  return Math.floor(getNumRawDataModules(ver) / 8)
    - ECC_CODEWORDS_PER_BLOCK[ver][idx] * NUM_ERROR_CORRECTION_BLOCKS[ver][idx];
}
⚠️ 曾经踩过的坑(务必引以为戒): 最初把 ECC_CODEWORDS_PER_BLOCK 手工误抄成了「每版本纠错码字总数」而非「每块纠错码字数」。结果 getNumDataCodewords 在版本 ≥ 10 时返回负数,高版本全部生成失败。
正确做法: 这类 40×4 的常量表绝不手工转录,应从权威来源机读提取,并用公式自校验:
rawCw − ECC×blocks === totalData(160 组全部成立才算通过)。

5.3 分段编码

按内容自动选择最省的模式:

模式判定每字符位宽
数字 Numeric纯数字10 / 7 / 4(按 3/2/1 位分组)
字母数字 Alnum仅 0-9 A-Z $%*+-./:11 / 6
字节 Bytes其余(含中文)UTF-8,每字节 8 位

关键函数:isNumeric / isAlnum / makeSegments / getTotalBits / numCharCountBits。

5.4 纠错与交织

addEccAndInterleave 对每块数据计算 Reed-Solomon 纠错码,然后按列交织(interleave)成最终码字序列。配套函数:rsGenPoly、rsComputeDivisor、rsComputeRemainder。

5.5 矩阵构建与掩码

依次放置:定位图案(三个角)→ 校正图案 → 时序图案 → 格式信息 → 版本信息 → 数据。生成 8 种掩码各算惩罚分(PENALTY_*),选最低分的那个。

5.6 对外接口

function generateQR(text, ecl) → {
  version,          // 1–40
  size,             // 模块边长 = version*4 + 17
  mask,             // 选中的掩码 0–7
  getModule(x, y)   // → true(黑) / false(白)
};
内核是纯函数:同样的 (text, ecl) 一定得到同样的矩阵,无副作用、无全局状态。这使它可以被单独提取出来做 Node 端测试。

6数据解析与 CSV 往返

6.1 解析入口 parseTextRows

把 textarea 的纯文本转成 {name, content}[]。分隔符优先级:Tab > 逗号 > 整行即内容。

function parseTextRows(text){
  const lines = splitCsvLines(text);   // ← 引号感知的行切分
  for(const line of lines){
    if(line.indexOf("\t") >= 0){      // Tab 优先
      const p = line.split("\t");
      name = p[0].trim(); content = p.slice(1).join("\t").trim();
    }else if(line.indexOf(",") >= 0){ // 逗号次之
      const cells = splitCsvLine(line);
      name = cells[0].trim(); content = cells.slice(1).join(",").trim();
    }else{ content = line.trim(); }   // 单列 → 全部当内容
  }
}

6.2 两个 CSV 工具函数

函数作用
splitCsvLine(line, delim)单行按分隔符切分,正确处理引号内的分隔符与 "" 转义
csvQuote(s)写回文本时给含 , " \n 的字段加引号并转义
关键坑:引号内的换行。 早期版本直接 text.split("\n") 切行 —— 结果一个单元格内容里若含换行,会被拆成两行,数据静默损坏。正确做法是先做引号感知的行切分:
function splitCsvLines(text){
  const out = []; let cur = "", inQ = false;
  for(let i = 0; i < text.length; i++){
    const c = text[i];
    if(c === '"'){ inQ = !inQ; cur += c; }
    else if((c === "\n" || c === "\r") && !inQ){
      out.push(cur); cur = "";
      if(c === "\r" && text[i+1] === "\n") i++;  // 吃掉 \r\n
    }else cur += c;
  }
  out.push(cur); return out;
}

6.3 往返对称性(不变量)

必须满足:parseTextRows(setRowsToText(rows)) === rows。setRowsToText 要给名称和内容都做引号转义(早期只转义了内容,导致名称含逗号时结构被破坏)。

已实测的往返用例(全部通过): 普通、名称含逗号、内容含逗号、名称含引号、内容含换行、中文+逗号。

7xlsx 轻量解析

.xlsx 本质是个 ZIP。解析流程不依赖 SheetJS:

file.arrayBuffer()
   ▼  unzip()              // 解析 ZIP 中央目录,取所需条目
   ▼  inflateRaw()         // 用原生 DecompressionStream('deflate-raw') 解压
   ├─ xl/sharedStrings.xml → parseSharedStrings()  // 共享字符串表
   └─ xl/worksheets/sheet1.xml → parseSheet()      // 单元格
   ▼  readXlsxRows()       // → [{name, content}],跳表头

关键点

8渲染管线(Canvas / SVG)

8.1 布局计算 layoutMetrics

function layoutMetrics(qr, st, textLine){
  const modules   = qr.size + st.quietZone * 2;   // 模块数(含静区)
  const modulePx  = st.sizePx / modules;          // 每模块像素
  const textPx    = textLine ? Math.round(st.textSize * 1.6) : 0;
  return { modules, modulePx, W: st.sizePx, H: st.sizePx + textPx };
}

8.2 Canvas 路径

renderCanvas(row, st, scale) → {canvas, qr}。绘制顺序:

  1. drawModules —— 遍历矩阵画模块,支持三种样式(方形 / 圆点 / 圆角,圆角用 roundRect)
  2. drawLogo —— 居中画 LOGO,按 logoRatio × sizePx 定尺寸;logoPad 为真时先垫一块白底
  3. drawText —— 上/下绘制展示文字,超长自动缩字(fitText)

8.3 SVG 路径

renderSVG(row, st) 输出字符串。合并相邻模块为整段 <rect> 以减小体积;文字用 <text> + escapeXml() 转义。

注意 SVG 的能力差异: SVG 是纯矢量二维码,不含 LOGO、不含展示文字(设计如此)。界面上已明示这一限制。

8.4 三处调用的一致性

调用方倍率用途
updatePreviewmin(4, max(2, dpr))屏幕预览
exportOne3×单张下载
exportZip3×批量打包

三处共用同一 renderCanvas,保证「预览所见 = 导出的样子」。

9ZIP 打包器实现

手写 PKZIP 格式,零第三方库。结构:本地文件头 + 数据 + 中央目录 + 中央目录结尾。

9.1 CRC32

const CRC_TABLE = (() => { ... })();   // 预计算 256 项查表
function crc32(buf){
  let c = 0xFFFFFFFF;
  for(let i = 0; i < buf.length; i++)
    c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
  return (c ^ 0xFFFFFFFF) >>> 0;
}

9.2 压缩 deflateRaw

async function deflateRaw(buf){
  if(typeof CompressionStream === "undefined") return null;  // → 调用方回退存储模式
  const cs = new CompressionStream("deflate-raw");
  const w = cs.writable.getWriter(); w.write(buf); w.close();
  return new Uint8Array(await new Response(cs.readable).arrayBuffer());
}

9.3 关键字段

字段值说明
版本202.0,足够
通用标志0x0800bit 11 = UTF-8 文件名(支持中文名)
压缩方法8 或 0deflate / store
时间戳dosDateTime()DOS 格式两字节时间 + 两字节日期

9.4 偏移量与重名

offset 累计每个本地头 + 数据的长度,写进中央目录。重名文件自动追加 (2)、(3) 后缀,不覆盖。

已验证: CRC32 对齐标准向量("123456789" → cbf43926);ZIP 经 Python zipfile 校验,中文文件名与内容无损。

10预览清晰度系统

二维码是高频黑白图形,用 CSS 强行放大一定会糊。解法是高倍率渲染 + 等比缩小。

const boxW = Math.max(240, Math.min(560, elSheet.parentElement.clientWidth - 72));
const cssScale = Math.min(1, boxW / st.sizePx);        // 显示尺寸
const showW   = Math.round(st.sizePx * cssScale);
const dpr     = window.devicePixelRatio || 1;
const renderScale = Math.min(4, Math.max(2, dpr));     // 渲染倍率,2×–4×

const r = renderCanvas(row, st, renderScale);          // 画布按高倍率渲染
r.canvas.style.width = showW + "px";                   // CSS 缩到显示尺寸

关键设计:sizePx 与 scale 解耦

旧实现用固定 1× 渲染再 CSS 放大 → 模糊;现在按 min(4, max(2, dpr)) 渲染,像素量提升 4–16 倍后降采样,放大看也锐利。

11性能分析与量级

在 Node + DOM 桩环境下实测(真机通常更快,仅作量级参考):

操作规模耗时单条
generateQR(URL,ECC M)1000 条~500 ms0.50 ms
generateQR(中文,ECC H)1000 条~955 ms0.96 ms
renderSVG500 条~255 ms0.51 ms
renderCanvas(scale=4)500 条~280 ms0.56 ms
parseTextRows1000 行~1.5 ms—
parseTextRows10000 行~10 ms—
crc322000 次~1.4 ms—
buildZip500 个 SVG~300 ms—

结论

主线程阻塞的缓解措施

12已知问题与待办

12.1 设计上的取舍(非缺陷)

12.2 潜在风险点

12.3 可优化方向

13扩展指南

13.1 新增一种点样式(例:菱形)

  1. 在 #dotStyle 的 <select> 里加一个 <option value="diamond">菱形</option>
  2. 在 drawModules 的样式分支里加 else if(st.dotStyle === "diamond") 分支,用 ctx 画菱形路径
  3. SVG 路径(renderSVG)同步加对应逻辑,保证两种输出一致

13.2 新增一种导出格式(例:WebP)

  1. 导出区域加 <button data-v="webp">WebP</button>
  2. state.fmt 会通过 seg 的通用事件自动更新
  3. 在 exportOne / exportZip 里把 MIME 分支补上 "image/webp"
  4. 确认 canvasToBlob 支持该类型(现代浏览器均支持)

13.3 新增一格「数据整理」按钮

$("btnXxx").addEventListener("click", () =>
  applyToRows(a => a.map(r => ({ ...r, content: 你的变换(r.content) })))
);

applyToRows 内部会调用 setRowsToText 写回并触发 refreshData,你只需提供纯变换函数。

13.4 代码风格约定

14调试与自测

14.1 语法检查

把 <script> 内容抽出后交给 Node:

node --check script.js

14.2 内核自检(打开即运行)

页面初始化时自动生成一个测试二维码,控制台输出:

[自检] QR 内核正常,版本 2,尺寸 25

14.3 id 连通性检查

用正则抽取 HTML 的 id 与 JS 的 $("..."),两边做集合比对,应「无缺、无余」。当前为 51 / 51 完全配对。

14.4 二维码正确性(权威验证)

把 generateQR 的矩阵导出为位图,交给主流扫码 App 同款算法 ZXing 解码比对原文:

import zxingcpp
res = zxingcpp.read_barcode(image)
assert res.text == original
不要用 OpenCV 的 QRCodeDetector 判断生成器对错。 它在版本 11+ 的密集二维码上会大量误判 —— 曾出现「本工具生成 10/50 失败」的假警报,实际换成 ZXing 后 50/50 全通过。这是 OpenCV 解码器弱,不是生成器错。

14.5 往返自测(CSV 不变量)

const rows = [ {name:'a,b', content:'x,y'},
               {name:'q"t', content:'line1\nline2'} ];
setRowsToText(rows);
deepEqual(parseTextRows(elDataInput.value), rows);  // 必须相等

14.6 在 Node 里跑主脚本的要点