这是一个把「一张两列表格」批量转成「一堆带样式二维码图片」的纯前端工具。打包成单个 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 生成内核 | ~451 | GF(256) 运算、容量表、分段编码、RS 纠错、矩阵构建 |
| PART 2 xlsx 轻量解析 | ~973 | ZIP 读取、sharedStrings、sheet XML、CSV/文本解析 |
| PART 3 渲染 | ~1159 | Canvas / SVG 绘制、布局计算、state、样式读取 |
| PART 4 ZIP 打包 | ~1415 | CRC32、deflateRaw、PKZIP 写入 |
| PART 5 界面逻辑 | ~1508 | 事件绑定、预览、导出、模板下载、自检 |
.html 即完整产品,方便通过邮件/U 盘分发,双击即用。"use strict" 强制显式声明。CompressionStream 缺失时 ZIP 回退「存储模式」,功能不中断(仅体积变大)。文件自上而下分为:<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>
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} 对象数组,而非字符串数组。这一点贯穿所有模块:
parseTextRows / parseCsvRows / readXlsxRows → 产出 {name, content}[]fileNameFor(row, idx) → 依 row.name 或命名策略生成文件名getTextFor(row, st) → 依文字来源返回展示文字renderCanvas(row, st, scale) / renderSVG(row, st) → 接收 row 而非纯字符串textarea.value
│ parseTextRows() // 唯一解析入口
▼
state.data ──┬─→ renderList() // 文件清单
├─→ updateStats() // 统计(重复/超长/缺内容)
├─→ updatePreview() // 预览单条
└─→ exportZip() // 批量导出
样式控件 ── readStyle() ──→ st(传给所有渲染函数)
textarea 是唯一「真相来源」,state.data 由它派生。所有整理按钮(替换/去重等)都通过 setRowsToText() 写回 textarea 再重新解析,保证「界面显示的 = 实际使用的」。
PART 1 是一个完整的 QR Code 生成器,思路参考 Nayuki 的算法结构,用纯 JS 重写。
// 预计算 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];
})();
两张 41×4 的常量表,取自 ISO/IEC 18004 官方数据:
ECC_CODEWORDS_PER_BLOCK[ver][eclIdx] —— 每块的纠错码字数NUM_ERROR_CORRECTION_BLOCKS[ver][eclIdx] —— 纠错块数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 时返回负数,高版本全部生成失败。rawCw − ECC×blocks === totalData(160 组全部成立才算通过)。
按内容自动选择最省的模式:
| 模式 | 判定 | 每字符位宽 |
|---|---|---|
| 数字 Numeric | 纯数字 | 10 / 7 / 4(按 3/2/1 位分组) |
| 字母数字 Alnum | 仅 0-9 A-Z $%*+-./: | 11 / 6 |
| 字节 Bytes | 其余(含中文) | UTF-8,每字节 8 位 |
关键函数:isNumeric / isAlnum / makeSegments / getTotalBits / numCharCountBits。
addEccAndInterleave 对每块数据计算 Reed-Solomon 纠错码,然后按列交织(interleave)成最终码字序列。配套函数:rsGenPoly、rsComputeDivisor、rsComputeRemainder。
依次放置:定位图案(三个角)→ 校正图案 → 时序图案 → 格式信息 → 版本信息 → 数据。生成 8 种掩码各算惩罚分(PENALTY_*),选最低分的那个。
function generateQR(text, ecl) → {
version, // 1–40
size, // 模块边长 = version*4 + 17
mask, // 选中的掩码 0–7
getModule(x, y) // → true(黑) / false(白)
};
(text, ecl) 一定得到同样的矩阵,无副作用、无全局状态。这使它可以被单独提取出来做 Node 端测试。
把 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(); } // 单列 → 全部当内容
}
}
| 函数 | 作用 |
|---|---|
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;
}
必须满足:parseTextRows(setRowsToText(rows)) === rows。setRowsToText 要给名称和内容都做引号转义(早期只转义了内容,导致名称含逗号时结构被破坏)。
.xlsx 本质是个 ZIP。解析流程不依赖 SheetJS:
file.arrayBuffer()
▼ unzip() // 解析 ZIP 中央目录,取所需条目
▼ inflateRaw() // 用原生 DecompressionStream('deflate-raw') 解压
├─ xl/sharedStrings.xml → parseSharedStrings() // 共享字符串表
└─ xl/worksheets/sheet1.xml → parseSheet() // 单元格
▼ readXlsxRows() // → [{name, content}],跳表头
readU16 / readU32 按 PKZIP 结构定位每个条目的本地头与数据段inflateRaw 用浏览器原生 DecompressionStream('deflate-raw');method=0 时直接用原始字节s(共享字符串)、inlineStr(内联)、b(布尔)、其他按数字处理colToIndex("AB12") → {col, row}unescapeXml 处理 < / & / & 等readXlsxRows(file, skipHeader)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 };
}
renderCanvas(row, st, scale) → {canvas, qr}。绘制顺序:
drawModules —— 遍历矩阵画模块,支持三种样式(方形 / 圆点 / 圆角,圆角用 roundRect)drawLogo —— 居中画 LOGO,按 logoRatio × sizePx 定尺寸;logoPad 为真时先垫一块白底drawText —— 上/下绘制展示文字,超长自动缩字(fitText)renderSVG(row, st) 输出字符串。合并相邻模块为整段 <rect> 以减小体积;文字用 <text> + escapeXml() 转义。
| 调用方 | 倍率 | 用途 |
|---|---|---|
updatePreview | min(4, max(2, dpr)) | 屏幕预览 |
exportOne | 3× | 单张下载 |
exportZip | 3× | 批量打包 |
三处共用同一 renderCanvas,保证「预览所见 = 导出的样子」。
手写 PKZIP 格式,零第三方库。结构:本地文件头 + 数据 + 中央目录 + 中央目录结尾。
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;
}
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());
}
| 字段 | 值 | 说明 |
|---|---|---|
| 版本 | 20 | 2.0,足够 |
| 通用标志 | 0x0800 | bit 11 = UTF-8 文件名(支持中文名) |
| 压缩方法 | 8 或 0 | deflate / store |
| 时间戳 | dosDateTime() | DOS 格式两字节时间 + 两字节日期 |
offset 累计每个本地头 + 数据的长度,写进中央目录。重名文件自动追加 (2)、(3) 后缀,不覆盖。
"123456789" → cbf43926);ZIP 经 Python zipfile 校验,中文文件名与内容无损。
二维码是高频黑白图形,用 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 缩到显示尺寸
st.sizePx —— 逻辑尺寸(用户设定的输出边长)scale —— 渲染倍率(只影响预览画布的像素量)旧实现用固定 1× 渲染再 CSS 放大 → 模糊;现在按 min(4, max(2, dpr)) 渲染,像素量提升 4–16 倍后降采样,放大看也锐利。
在 Node + DOM 桩环境下实测(真机通常更快,仅作量级参考):
| 操作 | 规模 | 耗时 | 单条 |
|---|---|---|---|
| generateQR(URL,ECC M) | 1000 条 | ~500 ms | 0.50 ms |
| generateQR(中文,ECC H) | 1000 条 | ~955 ms | 0.96 ms |
| renderSVG | 500 条 | ~255 ms | 0.51 ms |
| renderCanvas(scale=4) | 500 条 | ~280 ms | 0.56 ms |
| parseTextRows | 1000 行 | ~1.5 ms | — |
| parseTextRows | 10000 行 | ~10 ms | — |
| crc32 | 2000 次 | ~1.4 ms | — |
| buildZip | 500 个 SVG | ~300 ms | — |
exportZip 按 CHUNK = 20 分批 await sleep(0) 让出主线程,同时更新进度条renderList 对 > 500 条只渲染前 500 项(LIMIT = 500),导出不受影响debounce 包裹文本域输入,避免每次按键都全量重解析 + 重绘generateQR 放进 Web Worker,彻底消除大数据集的界面卡顿#dotStyle 的 <select> 里加一个 <option value="diamond">菱形</option>drawModules 的样式分支里加 else if(st.dotStyle === "diamond") 分支,用 ctx 画菱形路径renderSVG)同步加对应逻辑,保证两种输出一致<button data-v="webp">WebP</button>state.fmt 会通过 seg 的通用事件自动更新exportOne / exportZip 里把 MIME 分支补上 "image/webp"canvasToBlob 支持该类型(现代浏览器均支持)$("btnXxx").addEventListener("click", () =>
applyToRows(a => a.map(r => ({ ...r, content: 你的变换(r.content) })))
);
applyToRows 内部会调用 setRowsToText 写回并触发 refreshData,你只需提供纯变换函数。
function name(){}$("id"),集中在 PART 5 顶部声明把 <script> 内容抽出后交给 Node:
node --check script.js
页面初始化时自动生成一个测试二维码,控制台输出:
[自检] QR 内核正常,版本 2,尺寸 25
用正则抽取 HTML 的 id 与 JS 的 $("..."),两边做集合比对,应「无缺、无余」。当前为 51 / 51 完全配对。
把 generateQR 的矩阵导出为位图,交给主流扫码 App 同款算法 ZXing 解码比对原文:
import zxingcpp
res = zxingcpp.read_barcode(image)
assert res.text == original
QRCodeDetector 判断生成器对错。 它在版本 11+ 的密集二维码上会大量误判 —— 曾出现「本工具生成 10/50 失败」的假警报,实际换成 ZXing 后 50/50 全通过。这是 OpenCV 解码器弱,不是生成器错。
const rows = [ {name:'a,b', content:'x,y'},
{name:'q"t', content:'line1\nline2'} ];
setRowsToText(rows);
deepEqual(parseTextRows(elDataInput.value), rows); // 必须相等
findIndex(l => l.trim() === "<script>") 自定位,别写死行号new Function(...) 执行;测试代码要注入到 IIFE 内最后的 })(); 之前,否则拿不到内部作用域globalThis.navigator 只读;"use strict" 下隐式全局会抛错arcTo 等方法