屏幕上的条码看着没问题。打印出来也很干净。到了仓库,它被扫成了另一件商品,而这批货已经发出去了才有人发现。
原因是校验位的加权方向算反了。符号本身结构完全合法,所以每个渲染器都接受它,每次预览都正常。扫描器不会校验你的意图——它只解码条纹说了什么,然后把数字返回给你。
条码的失败方式在硬件介入之前是看不见的。这篇文章讲真正决定一个符号能否被扫出来的因素:数据该配哪种符号集、校验位的算法怎么回事,以及那几个会产出「合法但读不出」标签的印刷与排版错误。
选择符号集
零售商品没得选。编号方案决定符号集,而号码本身就告诉你手上拿的是哪一种。
| 符号集 | 数据 | 长度 | 适用场景 |
|---|---|---|---|
| EAN-13 | 数字 | 12 位 + 校验位 | 全球零售;ISBN-13、ISSN、JAN 使用同一套编码 |
| EAN-8 | 数字 | 7 位 + 校验位 | 包装太小放不下完整 EAN-13 |
| UPC-A | 数字 | 11 位 + 校验位 | 北美零售 |
| Code 128 | ASCII 0-127 | 可变 | 物流、仓储、内部资产标签 |
| Code 39 | 0-9 A-Z 空格 - . $ / + % | 可变 | 工业、汽车、国防遗留系统 |
| ITF-14 | 数字 | 13 位 + 校验位 | 运输外箱与托盘级贸易单元 |
| Codabar | 0-9 - $ : / . + | 可变 | 图书馆、血库、快递面单 |
GTIN-13 必须是 EAN-13,GTIN-12 必须是 UPC-A。要给一件会经过零售收银台的商品贴标签,你并不是在选符号集——你是在把别人分配给你的号码编成条码,符号集是跟着号码一起来的。中国大陆的商品条码由中国物品编码中心分配,厂商识别代码以 690–699 开头,落到标签上就是一个以 69 打头的 EAN-13。
内部用途才是真正的选择题,答案通常是 Code 128。它覆盖完整 ASCII 范围,能在三个子集之间自动切换以保持紧凑,也是现代物流系统默认预期的格式。Code 39 还活在老系统里,因为它的打印和解码都极其简单,但同样宽度下它装的字符更少,字符集也只到 43 个符号。只在接收方系统点名要求时才选 Code 39;它排在下拉框第一位不构成理由。
校验位,以及方向为什么重要
EAN-13、EAN-8、UPC-A、ITF-14 共用一条公式。从最右边那位数据位起算向左走,权重在 3 和 1 之间交替。把加权后的数字求和,再取到下一个 10 的倍数的距离:
check = (10 - (weighted sum mod 10)) mod 10
以 590123412345 为例:
digit: 5 9 0 1 2 3 4 1 2 3 4 5
weight: 1 3 1 3 1 3 1 3 1 3 1 3
product: 5 27 0 3 2 9 4 3 2 9 4 15 → sum 83
check: (10 - 83 mod 10) mod 10 = 7
完整的 EAN-13 是 5901234123457。
陷阱出在锚点上。绝大多数 EAN-13 的资料把这条规则表述成按位置计算——「从左边数起,奇数位权重 1,偶数位权重 3」——对 EAN-13 而言这是对的,因为它的 12 位数据让两种表述等价。EAN-13 恰好是这四种里唯一载荷长度为偶数的。UPC-A 是 11 位,EAN-8 是 7 位,ITF-14 是 13 位。把按位置的规则套到它们任何一个身上,权重都会落在错误的数字上。
这不是偶发的边界情况。在这些长度的随机载荷上,按位置的规则与正确答案的分歧率大约是 80%——而剩下那 20% 才是危险所在,因为随手挑一个测试向量,它有相当的概率恰好通过:
| 数据 | 符号集 | 右起锚定 | 按位置规则 |
|---|---|---|---|
9638507 | EAN-8 | 4 | 4 |
1234567 | EAN-8 | 0 | 8 |
03600029145 | UPC-A | 2 | 8 |
1540141253226 | ITF-14 | 4 | 2 |
9638507 正是大多数 EAN-8 文档里出现的那个向量,两种实现在它身上结果一致。围绕教科书例子搭起来的测试套件,会带着这个 bug 一路发布出去。
// 锚定在最右边那位数据位 —— 对 EAN-13、EAN-8、UPC-A
// 和 ITF-14 一律适用。
function checkDigit(data) {
let sum = 0;
for (let i = data.length - 1, weight = 3; i >= 0; i--, weight = 4 - weight) {
sum += Number(data[i]) * weight;
}
return (10 - (sum % 10)) % 10;
}
checkDigit('590123412345'); // 7 EAN-13
checkDigit('1234567'); // 0 EAN-8
checkDigit('03600029145'); // 2 UPC-A
至少测一个奇数长度的载荷。1234567 和 03600029145 都能把右起锚定的实现和按位置的实现区分开;常见的教科书向量做不到。
UPC-A 就是换了个短名字的 EAN-13
UPC-A 符号是前面补了一个零的 EAN-13。036000291452 与 0036000291452 编码结果完全相同,补上这个零也不会改变校验位。
有差别的是扫描器怎么上报它,而且差别按平台划分而不是按设备划分。在 macOS 和 iOS 上,系统解码器把 UPC-A 作为 13 位的 ean_13 结果返回——Apple 的技术说明 TN2325 写明,解码出的 UPC-A 会以带前导零的 EAN-13 对象输出。Chromium 的 BarcodeDetector 在该平台继承了这一行为;在 macOS 上调用 getSupportedFormats() 返回的列表里有 ean_13 和 upc_e,完全没有 upc_a。在 Android 上同一个 API 底层是 ML Kit,UPC-A 是独立格式,返回 12 位。
于是同一份代码读同一张标签,在一个平台上拿到 13 位,在另一个平台上拿到 12 位。如果你的商品按 12 位 UPC 存储,macOS 上每次查询都会落空;存成补零的 13 位,落空的就换成 Android 那条路径。在边界上做归一化:选定一种内部表示,把每一次扫描结果都转换成它再去匹配,而不是信任你恰好收到的那个长度。
同样的关系也解释了 UPC-E——偶尔会有人向你要这个。UPC-E 不是另一套编号方案,它是 UPC-A 的零压缩打印形式,用于放不下完整符号的小包装。大多数扫描器可以配置成把它展开回 12 位的 UPC-A——在 Zebra 硬件上是 Convert UPC-E to UPC-A 这个参数——但那是一项设置而非保证,需要确认而不是假定。如果某个系统向你索要商品代码,生成 UPC-A。
Code 128 靠什么保持紧凑
Code 128 有三个字符子集。A 编码控制字符与大写字母,B 编码包含小写的可打印 ASCII 范围,C 编码数字对——一个符号装两位数字,而不是一位。
正是这个成对编码让 Code 128 能很好地处理长数字载荷,也让它的长度不与输入成正比。20 位的序列号在子集 C 下占 10 个符号,同样这 20 位在子集 B 下占 20 个。一个从不切换到 C 的编码器,产出的符号大约是必要宽度的两倍。
切换本身要花掉一个符号,所以只有过了某个阈值才划算。ISO/IEC 15417 附录 E 的最小化规则大致归结为:数据开头或结尾处 4 位及以上数字(起始符或载荷末尾吸收了这份开销),数据中间 6 位及以上(进入子集 C 要花一个切换符,离开还要再花一个)。低于这些数量,留在当前子集更便宜。各实现推进这套优化的程度不一——JsBarcode 用固定阈值,而 zint 直接搜索最小成本编码、根本不用阈值——所以两个都符合规范的编码器,对同一输入可以产出不同的符号数量。它们解码出来是同一个字符串。
起始符之后的每一个符号都参与一个模 103 的校验符计算:
checksum = (start value + Σ(symbol value × position)) mod 103
位置从第一个数据符号的 1 开始;起始符按自身值计入一次。子集切换符是普通符号,与其他符号同样计数。校验符嵌在条纹里,从不出现在人眼可读文本中——所以一张 Code 128 标签显示的正是你编进去的内容,同时携带着一个你看不见的额外符号。
什么时候需要 GS1-128
运输标签常常要携带不止一个字段:GTIN、批次号、有效期、序列号。GS1-128 就是把它们打包进单个 Code 128 符号的约定。它不是独立的符号集,而是 Code 128 加上一层数据结构。
每个字段由一个应用标识符引导,这是一个二到四位的数字前缀,声明后面跟着什么、有多长。(01) 是 GTIN-14,(17) 是 YYMMDD 格式的有效期,(10) 是批次码:
(01)09501101020917(17)260801(10)ABC123
圆括号是给人看的书写约定,从不参与编码。在条纹里,结构由 FNC1 标记:第一次出现在首位,声明该数据为 GS1 格式;之后每当需要终结一个非末位的可变长度字段时再次出现。(01) (17) 这类定长应用标识符不需要终结符,长度由标识符本身隐含;(10) 这类变长的则需要。
通用 Code 128 编码器和 GS1-128 编码器在这里分道扬镳。把上面那串字符喂给通用编码器,圆括号会被当作字面字符编码进去,且不会插入任何 FNC1——符号能扫出来,返回一个带括号的字符串,任何懂 GS1 的系统都会拒收它。ZeroTool 的生成器是通用 Code 128 编码器,按字面处理你的输入,这是一个不解析应用标识符语法的工具应有的诚实行为。真要生成 GS1-128,请用理解应用标识符的编码器:zint --barcode=16 --data="[01]09501101020917[17]260801[10]ABC123" 会处理好 FNC1 的位置,GS1 官方的 Digital Link 与语法资源记录了完整的标识符集合。
如果载荷确实是结构化的,且读取硬件支持二维符号,那就改用 QR 码或 GS1 DataMatrix。一个为了装下四个字段而不断变长的一维码,通常说明数据已经撑破了这个格式。
能逃过代码评审的失效模式
编码正确是简单的那一半。下面这些能通过所有测试,然后在仓库现场翻车。
静区被裁掉了。 每种一维码符号集都需要两侧留白,扫描器靠它判断符号从哪里开始。所需宽度按符号集以模块数规定,而且左右并不对称:EAN-13 左侧要 11 个模块、右侧要 7 个,EAN-8 两侧各 7 个,UPC-A 是 9 个,Code 128 至少 10 个。设计师看到空白就把版面收紧,或者某个脚本把图片裁到了内容边界。条纹完好无损,符号读不出来。导出时把静区一并带上,永远不要裁到内容边缘。
有人让它跟品牌色统一了。 解码依赖条与底之间的对比度。米色标签上的中灰条、黑底上的深蓝、或者深底白条的反色符号,都会在硬件上失败——尽管黑白打样它读得完美无缺。红色是个特例:许多激光扫描器用红色光源,所以红条在白底上可能完全消失。保持浅色底上的深色条。
最窄的条比打印机能印的还细。 在矢量空间里合法的模块宽度,到了 203 dpi 的热敏打印机上会变成一条糊掉的灰线。直热式和喷墨还会让墨略微扩散,条变粗、空变窄。用生产尺寸在生产介质上打一张测试标签,用实际会用到的硬件去扫——不要用手机摄像头,它比固定式激光扫描器宽容太多了。
载荷长过了扫描器的视野。 可变长度符号集没有硬性上限,所以没有任何东西阻止你编进一个 300 字符的字符串。结果是结构合法、物理上读不出来,因为手持扫描枪只解码它窗口里装得下的部分。Code 128 控制在 48 个字符以内,Code 39 和 Codabar 控制在 43 个以内——它们密度更低,更早用完可用宽度。长载荷属于二维符号集。
ITF-14 丢了保护条。 交叉二五码容易被部分扫描击穿:只扫过符号的一部分,就可能解码出一个更短的、错误的号码。ITF-14 上下那两条粗横线正是为了防住这一点,它们属于规范的一部分而非装饰。为了省标签空间把它们去掉,外箱被读成另一个 GTIN,就是这么来的。
两端对 Code 39 校验字符的理解不一致。 Code 39 的模 43 校验字符是可选的。扫描器如果不预期它,就会把它当成你数据的最后一个字符报上来;要求它的系统则会拒收没有它的符号。启用之前先确认接收方系统要哪种行为,并且注意这个字符会出现在人眼可读文本中。
打印之前先验证
最强的检查是一次往返:编码载荷,栅格化,再用一个独立实现去解码。如果回来的东西和进去的不是逐字符相同,那这个符号就是错的,不管它看起来多正常。
现代 Chromium 浏览器原生暴露了一个解码器:
// 用浏览器自带的扫描器解码渲染好的条码。
const detector = new BarcodeDetector();
const results = await detector.detect(canvas);
console.log(results.map(r => `${r.format}:${r.rawValue}`));
// → ["ean_13:5901234123457"]
有两个细节决定这是真测试还是走过场。栅格化时用显示尺寸的数倍,让细条能扛过采样;比较解码字符串与载荷时要带上校验位。只比数据部分,恰好会掩盖掉最要命的那个 bug。
批量作业就别在浏览器里验了。zint 负责渲染,zbarimg 负责解码:
# 批量生成 EAN-13 标签,然后逐张验证它能解码回
# 当初用来生成它的那个号码。
while read -r gtin; do
zint -b EAN13 --data="$gtin" --output="labels/$gtin.png" --height=60
decoded=$(zbarimg --quiet --raw "labels/$gtin.png")
[ "$decoded" = "$gtin" ] || echo "MISMATCH: $gtin -> $decoded"
done < gtins.txt
工具链上有两点要注意。用 zint 的符号集名称而不是数字 ID:-b EAN13 是稳定的,而旧的数字 13 指向 legacy 的 EANX 类型,新版本保留它只是为了向后兼容。另外 zbarimg --raw 只打印解码值本身,这正是上面那个字符串比较能成立的原因——会打印人眼可读区块的解码器,得先解析才能拿去比较。
在 Python 里,守住你生成器的那套条码校验位逻辑,同样应该守住你的数据导入:
def check_digit(data: str) -> int:
"""GS1 模 10 校验,锚定在最右边那位数据位。"""
total = 0
for i, char in enumerate(reversed(data)):
total += int(char) * (3 if i % 2 == 0 else 1)
return (10 - total % 10) % 10
def is_valid_gtin(code: str) -> bool:
# 校验整个字符串,而不是 code[:-1] —— 否则末尾的非数字字符
# 会溜过检查,int() 抛异常而不是返回 False。
return code.isdigit() and int(code[-1]) == check_digit(code[:-1])
assert is_valid_gtin('5901234123457')
assert not is_valid_gtin('5901234123456')
assert not is_valid_gtin('590123412345X')
在导入时校验 GTIN,能在誊写错误还很便宜的时候把它抓住。表格里错一位数字,修起来一分钟;同一位数字印到一万个外箱上,代价就大得多。
ZeroTool 的生成器有什么不同
大多数在线条形码生成工具会把你的输入上传到服务器去渲染图片。对一个公开的商品 GTIN 来说这没什么危害。但换成未发布的 SKU、内部资产标签或序列号,这就是数据毫无技术必要地离开了你的机器——编码不过是一张查找表加一点算术,它属于浏览器。
ZeroTool 条码生成器完全在客户端编码。你提供数据位时它补上校验位;你粘贴完整号码时它转为校验该位,不匹配就告诉你正确值。条的颜色刻意不做成可配置:低对比度配色是标签在硬件上失效的最常见原因,去掉这个选项就去掉了这类故障。当可变长度载荷长到手持扫描枪一次扫不完时,状态栏会明说,而不是默默产出一个不可用的符号。
导出格式是 SVG 和 PNG。打印以及任何会被缩放的场景优先用 SVG——条的边缘在任何比例下都保持精确,而这恰恰是扫描器在意的。PNG 按屏幕尺寸的四倍栅格化,让细模块能存活下来。
如果是流水线而非一次性标签,用库:命令行用 zint,Python 用 python-barcode,Node 用 JsBarcode。生成器面向的是那些引入一个依赖比标签本身还贵的场景。
延伸阅读
ZeroTool 上的相关工具:
规范与参考资料:
- GS1 通用规范 —— GTIN 结构、校验位与印刷质量的权威来源
- ISO/IEC 15417 —— Code 128 符号集规范
- ISO/IEC 15420 —— EAN/UPC 符号集规范
- MDN: BarcodeDetector —— 上文用到的浏览器解码 API