上周有位朋友微信找我,说单位三百多台仪器,每年盘点都要两个人蹲在地上逐个抄标签,标签还是买设备时经销商送的,掉了三年没人补。他问我:网上说的“用itextpdf 配合 google.zxing 批量生成资产标签PDF”,到底能不能直接用?
能,而且我认为这是资产管理里最该先落地的小工具。我先后在两个项目里用这套组合批量输出过带二维码的资产标签PDF:台账一份Excel导出来,程序跑一遍,直接生成可打印的A4标签页,打印在不干胶标签纸上,一贴就能用。这篇文章不是泛泛讲概念,而是完整的选型思路、最小可运行方案、批量实现代码,以及我在字体、二维码扫描率、打印对齐上踩过的坑。适合有Java基础、想快速给自家资产做标签打印的人,也适合刚开始接触PDF生成、不知道从哪下手的同事。
先说一句结论:itextpdf负责排版和输出PDF,ZXing负责把资产编号变成二维码,两个库配合,正好覆盖“看得见、扫得出、打得准”三个核心诉求。
1. 为什么非要程序生成资产标签:从“账实不符”说起
1.1 手工贴标和Excel台账的尴尬
大部分中小型单位做资产管理,起步都是同一个套路:用Excel登记台账,资产编号一列、名称一列、购买日期一列、责任人一列,然后没了。
问题出在物理资产和电子台账脱节。你Excel里写着“编号SB-2024-0001”对应某台服务器,但服务器机柜上没贴任何标识,或者贴的是厂商出厂贴纸,编号压根对不上。盘点时全靠人肉记忆“这台大概是财务部的”“那台好像是研发的”,最后盘点单上全是不确定项,审计抽几台立刻暴露。
我还见过更极端的操作:为了应付检查,临时从网上买现成的“资产编号贴纸”,拿马克笔手写编号贴上去。结果一年后贴纸掉色,重新盘点又变成了“猜编号”游戏。
手工贴标不是不能用,是它在资产数量过百之后就很难维持准确率。标签容易掉、容易写错、格式不统一,一旦跟台账编号对不上,后面每一次盘点都在为前面的随意买单。
1.2 二维码的价值在于“一物一码”
如果把资产标签从普通的“文字编号贴纸”升级成“二维码+文字编号”,等于给每台设备发了一张身份证。平时找货、盘点、维修登记,拿手机或扫码枪一扫,直接得到这台设备的唯一编号,再关联到后台台账,整个过程不需要抄写、不需要手工录入。
这里有个关键设计:二维码内容不建议放资产名称、部门、购买日期这些可变信息,只放一个全局唯一的资产编号。为什么?因为编号是稳定的,名称和责任人可能变更。贴标签贴的是“身份”,不是“快照”。后续任何信息变化,改后台台账就行,不需要重新贴标签。
这也是程序批量生成和二维码在线生成器的本质区别:在线生成器适合临时生成一二十个,批量场景下效率和规则一致性才是第一位。
1.3 什么时候值得上“程序+标签纸”
不是所有场景都需要代码生成。我给自己定的判断标准很简单:
- 资产数量超过50个,后续还要定期补打、重打标签,值得上程序。
- 资产分散在多个房间、楼层,盘点依赖扫码,必须上程序。
- 标签需要统一品牌风格、包含公司名称或部门信息,值得用模板固定排版。
- 如果只有三五台设备拿不出手,直接找个在线工具生成二维码再截图,反而更快。
换句话说,程序生成的核心不是“生成一个二维码”,而是“批量、稳定、可重复地生成一整批格式统一的PDF”。资产越多,这套做法的收益越明显。
2. 选型思路:为什么是iTextPDF配ZXing,而不是PDFBox、JasperReports或前端打印
2.1 我曾对比过的“另一批方案”
做标签PDF之前,我先把市面上常见的几条路都过了一遍,结论比较明确。这里直接给一张对比表,省得你再踩一遍:
| 方案 | 优点 | 缺点 | 我的使用建议 |
|---|---|---|---|
| iTextPDF 5.x + ZXing | 轻量、API直观、中文字体方案成熟、批量控制力强 | AGPL许可需注意,赶不上PDFBox的底层自由度 | 首选,大部分Java标签场景都够用 |
| Apache PDFBox + ZXing | 纯Apache项目,底层渲染自由,模块划分清晰 | 文字定位、排版要自己算坐标,中文字体嵌入要手动折腾 | 适合要精细控制PDF内部结构的场景,标签这种重复排版用起来偏累 |
| JasperReports + 二维码 | 报表设计器可视化,套打能力强 | 依赖重、学习成本高,做卡片式标签有点大材小用 | 适合规章制度、复杂报表和标签混合的项目 |
| 前端HTML打印 | 浏览器渲染排版容易 | 不同浏览器打印缩放不同,PDF边距和坐标不可控 | 适合内部处理,不适合直接交给不干胶打印机 |
坦白说,这些方案没有绝对优劣,只有适不适合。资产标签的特点是:页面结构高度重复、标签卡片尺寸固定、需要精确到毫米级的坐标控制。这类需求恰好是iText这种“把PDF当画布来操作”的库最擅长的事情。
2.2 iTextPDF和ZXing组合的真正理由
我选iTextPDF主要有三个原因。
第一,控制粒度细。标签卡片的位置、边框、文字,全部可以用绝对坐标写死。PDF页面坐标以左下角为原点、以pt为单位,我可以根据A4标签纸的物理尺寸反推每个卡片的位置,打印出来基本不会偏。
第二,中文字体处理成熟。Java生成PDF绕不开中文,iText 5.x配合itext-asian包,用STSong-Light标准字体,不需要额外嵌入字体文件,PDF文件体积小,跨机器运行结果一致。这一点PDFBox用起来没这么省心。
第三,批量逻辑简单。PDF的本质是一页一页的内容流,iText的Document对象天然支持循环newPage(),我可以把“每页10个标签、数据自动换页”写成一个很直白的for循环,代码量不大,出问题也好排查。
ZXing这边没什么悬念。二维码生成库有很多,但ZXing社区成熟、API稳定,QRCodeWriter几行代码就能出一个BitMatrix,再配合javase包里的MatrixToImageWriter转成图片,二十行内搞定。它不需要联网、不需要付费接口,本地批量生成几千个标签也没有额外成本。
2.3 版本选择与许可证提醒
我用的是iText 5.5.13.3,不是iText 7。原因很实际:5.x的API资料多,网上随手能搜到合适的写法;7.x架构更新、性能更好,但很多老帖子里的写法不通用,项目里如果只是做标签打印,没必要为了新版而升级。
需要提一句:iText 5.x采用AGPL许可。内部使用、不对外分发软件,通常没问题;如果是闭源商业软件要打包分发,需要评估商业授权。ZXing是Apache 2.0许可,宽松很多。这个细节很多人忽略,等产品上线再做合规评估就晚了。
3. 先把一张标签跑通:环境配置与最小可运行示例
3.1 POM依赖里三个包的各自任务
我建议新建一个干净的Maven项目,Java 8及以上都可以。pom.xml里加下面四个依赖:
<dependency> <groupId>com.itextpdf</groupId> <artifactId>itextpdf</artifactId> <version>5.5.13.3</version> </dependency> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itext-asian</artifactId> <version>5.2.0</version> </dependency> <dependency> <groupId>com.google.zxing</groupId> <artifactId>core</artifactId> <version>3.5.2</version> </dependency> <dependency> <groupId>com.google.zxing</groupId> <artifactId>javase</artifactId> <version>3.5.2</version> </dependency>这四个依赖各自的角色:itextpdf负责PDF排版;itext-asian提供中文字体名称到字体文件的映射,没有它,“STSong-Light”这个字体名认不出来;zxing core负责把字符串编码成二维码矩阵;zxing javase里的MatrixToImageWriter负责把矩阵转成BufferedImage图片对象。
不要图省事只加core不加javase,因为矩阵转图片的工具类在javase包里,漏了会在运行时报ClassNotFoundException。
3.2 最小程序:一张小卡片,一个可扫描二维码
先别急着做批量,目标只有一个:生成一张带二维码的小PDF。代码尽量精简:
public class SingleLabelDemo { public static void main(String[] args) throws Exception { // 1. 用ZXing生成二维码BufferedImage String content = "ASSET:SB-2024-0001"; QRCodeWriter qrWriter = new QRCodeWriter(); Map<EncodeHintType, Object> hints = new HashMap<>(); hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); hints.put(EncodeHintType.MARGIN, 2); BitMatrix matrix = qrWriter.encode(content, BarcodeFormat.QR_CODE, 220, 220, hints); BufferedImage qrImage = MatrixToImageWriter.toBufferedImage(matrix); // 2. 用iText生成PDF Document document = new Document(PageSize.A4, 36, 36, 36, 36); PdfWriter writer = PdfWriter.getInstance(document, new FileOutputStream("single-label.pdf")); document.open(); // 中文字体 BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); Font titleFont = new Font(bf, 14, Font.BOLD); Font infoFont = new Font(bf, 10, Font.NORMAL); PdfContentByte canvas = writer.getDirectContent(); // 画一个圆角卡片 canvas.setLineWidth(1f); canvas.roundRectangle(60, 680, 220, 90, 6); canvas.stroke(); // 卡片标题 ColumnText.showTextAligned(canvas, Element.ALIGN_CENTER, new Phrase("资产编号", titleFont), 170, 750, 0); // 二维码放在左下角 Image qr = Image.getInstance(qrImage, null); qr.scaleToFit(70, 70); qr.setAbsolutePosition(75, 682); document.add(qr); // 编号文字放在二维码右侧 ColumnText.showTextAligned(canvas, Element.ALIGN_LEFT, new Phrase("SB-2024-0001", infoFont), 160, 710, 0); document.close(); System.out.println("生成完成,请打开 single-label.pdf 检查"); } }代码不复杂,但要提醒几个点:
- 二维码内容我写的是“ASSET:SB-2024-0001”,这个前缀是给盘点系统识别的,不是随便写的,后面第4章会细说。
hints.put(EncodeHintType.MARGIN, 2)控制了二维码四周的白边宽度,这是个关键参数,直接关系到扫描率,后面第5章展开讲。ColumnText.showTextAligned的第一参数是canvas,第二个参数是对齐方式,后面是坐标和旋转角。iText坐标原点在页面左下角,不是左上角,初学容易把文字画到页面外面。
3.3 运行后你应该看到什么,以及常见启动报错
正常跑完会生成一个几十KB大小的single-label.pdf,打开以后能看到:页面上方一张带圆角边框的小卡片,卡片顶部写着“资产编号”,左下角一个二维码,右边跟着编号文字。
如果直接编译运行,最容易遇到两个问题:
一是找不到“STSong-Light”字体,报错内容类似Font STSong-Light with UniGB-UCS2-H is not recognized。这个基本就是itext-asian依赖没加,或者加错版本。加上com.itextpdf:itext-asian:5.2.0就能解决。
二是MatrixToImageWriter找不到类,报NoClassDefFoundError。因为zxing javase包没加到classpath里。zxing core和javase是两个独立的jar包,别只依赖core。
4. 批量资产标签的核心实现:数据模型、坐标计算与完整代码
4.1 资产数据模型该有哪些字段
第一张标签跑通之后,批量就只是“循环”的问题。先定义一个最小的资产数据类,字段根据实际台账定:
public class AssetRecord { private String assetNo; // 唯一资产编号,二维码内容的核心 private String assetName; // 资产名称,用于标签展示 private String category; // 类别,比如“办公设备/仪器仪表” private String owner; // 责任人 private String purchaseDate; // 购置日期 public AssetRecord() {} public AssetRecord(String assetNo, String assetName, String category, String owner, String purchaseDate) { this.assetNo = assetNo; this.assetName = assetName; this.category = category; this.owner = owner; this.purchaseDate = purchaseDate; } // getter / setter 省略,IDE自动生成即可 }字段不是越多越好。标签卡片只有这么大,放太多信息反而扫描时一坨文字看不清。我最后只保留了“编号、名称、类别、责任人、购置日期”五个字段,必要信息都在,又不臃肿。
数据来源可以是Excel、CSV、数据库,甚至可以直接在Java代码里new一个List测试。我建议先用List 跑通流程,再替换成实际数据源。
4.2 二维码内容编码规则:先定规则再写代码
这是整个项目最容易被低估的一步。很多人上来就问“二维码里放啥”,我的答案永远是:先想清楚你的扫码端是谁。
如果资产盘点用的是自研小程序,建议二维码内容用统一前缀加编号,比如:
ASSET:SB-2024-0001如果将来要对互联网用户公开查询,可以放一个链接:
https://asset.example.com/view/SB-2024-0001如果盘点系统用的是第三方扫码工具,对方通常只识别纯数字编号,那就直接用:
SB-2024-0001我自己更推荐“前缀+编号”的格式,尤其是当现场可能出现多个二维码混贴的情况。盘点程序拿到内容后,先判断前缀是不是ASSET,再取编号,这样不会把别人的二维码误认成自己的资产。
编码规则确定后要固化到文档里,不要今天一种格式明天一种格式。二维码内容一旦变了,已经贴出去的标签全部作废,重打成本很高。
4.3 理解iText的坐标系统:左下角是原点
开始写批量代码前,先花两分钟搞懂iText坐标系,否则调试标签位置会让你怀疑人生。
iText中,A4页面的宽度是595pt,高度是842pt,坐标原点在页面左下角,x向右增长,y向上增长。而我们在视觉上习惯从上往下数格子,两者之间需要做一次转换。
假设我用的是常见的A4不干胶标签纸:每页2列5行,也就是10个标签卡片,纸张四周留10mm边距,卡片之间留3mm间隙。换算成pt(1mm约等于2.8346pt):
private static final float MM = 2.8346f; private static final float MARGIN = 10 * MM; // 页面边距 private static final float GAP = 3 * MM; // 卡片间隙 private static final int COLS = 2; private static final int ROWS = 5;每个卡片宽高根据纸张大小反推:
float labelW = (PageSize.A4.getWidth() - 2 * MARGIN - (COLS - 1) * GAP) / COLS; float labelH = (PageSize.A4.getHeight() - 2 * MARGIN - (ROWS - 1) * GAP) / ROWS;计算出来大概是90mm×47mm,正好落进市面上常见的“二五格”标签纸尺寸。
关键转换在两处。列号从左往右编号,x坐标直接算:
float x = MARGIN + col * (labelW + GAP);行号从视觉上是从上往下,而坐标原点在左下角,所以要先反转行号:
int rowFromTop = indexInPage / COLS; int rowFromBottom = ROWS - 1 - rowFromTop; float y = MARGIN + rowFromBottom * (labelH + GAP);这个反转就是iText初学最绕的地方。我自己第一次做的时候忘了反转,结果第一张标签跑到了页面底部,第二张跑到倒数第二行,跟预期正好上下颠倒。
4.4 页面分格与翻页循环
批量逻辑本身不复杂:拿到资产列表,按每页10个切片,最后一页不足10个就只画有的部分。注意一个细节——最后一页不足10个时,要不要补画空边框?
我建议不补。标签纸打印出来,没画的区域就是空白,补一堆空框既浪费碳带,贴的时候还容易混淆。空框只在实际少数据时能帮人对齐位置,但实际贴标的人根本不会在意空框,反而会觉得浪费。
翻页用document.newPage(),在循环里每处理完一页就开新页。最后一页不翻,所以判断条件要写在每页处理之前:
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { if (pageIndex > 0) { document.newPage(); } // 当前页的数据切片 }4.5 完整批量代码与逐段说明
下面是一个可以直接使用的批量生成器,我把它整理成一个类,方便复制修改:
import com.google.zxing.BarcodeFormat; import com.google.zxing.EncodeHintType; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.itextpdf.text.*; import com.itextpdf.text.pdf.*; import java.awt.image.BufferedImage; import java.io.FileOutputStream; import java.util.HashMap; import java.util.List; import java.util.Map; public class AssetPdfBatchGenerator { private static final float MM = 2.8346f; private static final float MARGIN = 10 * MM; private static final float GAP = 3 * MM; private static final int COLS = 2; private static final int ROWS = 5; private static final float LABEL_W = (PageSize.A4.getWidth() - 2 * MARGIN - (COLS - 1) * GAP) / COLS; private static final float LABEL_H = (PageSize.A4.getHeight() - 2 * MARGIN - (ROWS - 1) * GAP) / ROWS; public void generate(List<AssetRecord> assets, String outputPath) throws Exception { Document document = new Document(PageSize.A4, MARGIN, MARGIN, MARGIN, MARGIN); PdfWriter writer = PdfWriter.getInstance(document, new FileOutputStream(outputPath)); document.open(); BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); Font titleFont = new Font(bf, 9, Font.BOLD); Font labelFont = new Font(bf, 8, Font.NORMAL); Font tipFont = new Font(bf, 7, Font.NORMAL); int perPage = COLS * ROWS; int totalPages = (int) Math.ceil(assets.size() / (double) perPage); for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { if (pageIndex > 0) { document.newPage(); } PdfContentByte canvas = writer.getDirectContent(); int startIndex = pageIndex * perPage; int endIndex = Math.min(startIndex + perPage, assets.size()); for (int i = startIndex; i < endIndex; i++) { int posInPage = i - startIndex; int rowFromTop = posInPage / COLS; int col = posInPage % COLS; int rowFromBottom = ROWS - 1 - rowFromTop; float x = MARGIN + col * (LABEL_W + GAP); float y = MARGIN + rowFromBottom * (LABEL_H + GAP); drawSingleTag(canvas, document, x, y, LABEL_W, LABEL_H, assets.get(i), titleFont, labelFont, tipFont); } // 页码,放在页面底部右下角 ColumnText.showTextAligned(canvas, Element.ALIGN_RIGHT, new Phrase("第 " + (pageIndex + 1) + " / " + totalPages + " 页", tipFont), PageSize.A4.getWidth() - 15, 12, 0); } document.close(); } private void drawSingleTag(PdfContentByte canvas, Document document, float x, float y, float w, float h, AssetRecord asset, Font titleFont, Font labelFont, Font tipFont) throws Exception { // 外边框 canvas.setLineWidth(0.8f); canvas.setColorStroke(BaseColor.GRAY); canvas.roundRectangle(x, y, w, h, 4); canvas.stroke(); // 左上角:类别 ColumnText.showTextAligned(canvas, Element.ALIGN_LEFT, new Phrase(asset.getCategory(), titleFont), x + 6, y + h - 12, 0); // 顶部第二行:资产名称,超出长度截断 String name = asset.getAssetName(); if (name.length() > 12) { name = name.substring(0, 12) + "..."; } ColumnText.showTextAligned(canvas, Element.ALIGN_LEFT, new Phrase("名称:" + name, titleFont), x + 6, y + h - 24, 0); // 第三行:资产编号 ColumnText.showTextAligned(canvas, Element.ALIGN_LEFT, new Phrase("编号:" + asset.getAssetNo(), labelFont), x + 6, y + h - 36, 0); // 底部:责任人和购置日期 String owner = "责任人:" + asset.getOwner(); if (owner.length() > 12) { owner = owner.substring(0, 11) + "..."; } ColumnText.showTextAligned(canvas, Element.ALIGN_LEFT, new Phrase(owner, labelFont), x + 6, y + 10, 0); ColumnText.showTextAligned(canvas, Element.ALIGN_RIGHT, new Phrase(asset.getPurchaseDate(), labelFont), x + w - 6, y + 10, 0); // 二维码:放在标签右侧中间 String qrContent = "ASSET:" + asset.getAssetNo(); BufferedImage qrImage = createQrImage(qrContent, 220, 220); Image qr = Image.getInstance(qrImage, null); float qrSize = Math.min(70, w - 100); qr.scaleToFit(qrSize, qrSize); float qrX = x + w - qrSize - 6; float qrY = y + (h - qrSize) / 2; qr.setAbsolutePosition(qrX, qrY); document.add(qr); // 二维码正下方放编号文字,扫码失败时还能人工录入 ColumnText.showTextAligned(canvas, Element.ALIGN_CENTER, new Phrase(asset.getAssetNo(), tipFont), qrX + qrSize / 2, qrY - 10, 0); } private BufferedImage createQrImage(String content, int width, int height) throws Exception { QRCodeWriter writer = new QRCodeWriter(); Map<EncodeHintType, Object> hints = new HashMap<>(); hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); hints.put(EncodeHintType.MARGIN, 2); hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); BitMatrix matrix = writer.encode(content, BarcodeFormat.QR_CODE, width, height, hints); return MatrixToImageWriter.toBufferedImage(matrix); } }代码有几个可以按需改动的地方:
第一,二维码尺寸。标签宽90mm左右,二维码留出70pt(约25mm)大小是足够扫码的。如果资产编号长到十几个字符,二维码模块会变密,可以把qrSize适当放大一点,但不要超过纸质标签的2/3,否则会挤压文字区域。
第二,二维码生成尺寸和显示尺寸的关系。我在createQrImage里生成220×220像素的矩阵图,在PDF里显示成70pt。两个尺寸可以不一致,ZXing会按显示尺寸缩放。生成像素高一些,是为了缩放后边缘不糊。
第三,文字字号。9pt、8pt、7pt是专门为90×47mm小卡片调的。字体太大会溢出卡片边界,字体太小在普通喷墨打印机上又看不清。这个组合我在A4不干胶纸上实测过,属于比较稳妥的参数。
跑完这段代码,几百条资产的标签PDF就生成了。打印页数和资产数量自动匹配,最后一页不足10个也不会报错。
5. 实测中绕不开的四个坑:字体方块、二维码扫不出、打印错位、内存爆表
以下四个坑,每一个我都真实遇到过。贴出来是为了让后面的朋友少走弯路。
5.1 中文全部变成“口口口”
Java生成PDF,最大的新手杀手就是中文变方块。症状很典型:程序没报错,PDF也生成了,但所有中文文字在阅读器里显示成一排小方框。
本质原因是iText默认字体只支持拉丁字符,中文字符没有对应字形。解决方式就两招:
第一招,用itext-asian包里的内置中文字体。就是我在pom里加的依赖,然后在代码里声明:
BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);注意第二个参数必须是UniGB-UCS2-H,这是中文的编码规则,填错照样出方块或乱码。
第二招,如果公司有指定的品牌字体(比如黑体、宋体),可以读取系统字体文件:
BaseFont bf = BaseFont.createFont("C:/Windows/Fonts/simhei.ttf", BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);这种方式的缺点是字体文件路径依赖机器,换个电脑路径不存在就报错。如果只是内部打印,STSong-Light足够,PDF体积还小。
5.2 二维码调试:白边、纠错级别与打印尺寸
二维码扫不出的原因,我排查下来通常集中在三个点。
第一,白边不够。二维码矩阵周围必须有一圈静区,ZXing默认白边参数是4,我代码里手动改成了2。有人为了省空间改成0或者1,这在屏幕上看没问题,打印出来就各种识别失败。二是打印的墨迹扩散,code区域边缘会糊,静区太窄直接压到边框。如果你用的是喷墨打印机,建议保持2-3;用激光或热转印可以适当压缩。
第二,纠错级别太低。ZXing默认的纠错级别是L,只能容忍约7%的损伤。标签贴到设备上,难免有污渍、折角、磨损,建议至少用M级别(15%),我给的是M。如果标签会暴露在户外或者环境比较脏,直接上Q(25%)更稳,但二维码模块会稍微密一点。
第三,打印尺寸太小。二维码打印出来,单个模块至少要0.5mm才容易被手机识别。简单换算一下:如果二维码是37×37个模块,整体尺寸20mm,每个模块约0.54mm,比较保险;如果压缩到15mm,每个模块0.4mm,普通手机就可能识别慢了。所以二维码在标签上的边长不要小于20mm,我代码里70pt约25mm,是留了余量的。
调试时最有效的办法:生成一页PDF,用普通纸先打印出来,拿手机和扫码枪分别扫一遍,再贴到实际设备上扫一遍。纸上能扫不算数,设备上能扫才算数。
5.3 打印机“自适应缩放”导致标签错位
这个问题经常被忽略,因为它出现在打印环节,不是代码环节。
生成好的PDF看起来无比标准,A4上每个卡片位置都对。但发给打印机一打,所有卡片往右上角偏了5mm,或者整体被缩小了。原因基本是打印驱动开启了“适应页面”或“缩放以适合”选项。
同一份PDF,在“实际大小”和“适合页面”两种模式下,内容占比完全不同。不干胶标签纸是预先切好卡片的,任何缩放都会导致二维码和卡片边界错开。
解决办法有两个:
一是在打印设置里选“实际大小”或“100%大小”,不要选“适应页面”。这个操作要看打印机驱动,普通的喷墨一体机在打印预览界面能找到。
二是给PDF页面设置成和标签纸完全一致的物理尺寸,比如标签纸本身不是标准A4,而是某种特殊规格,就用Rectangle指定页面尺寸:
Rectangle tagPage = new Rectangle(PageSize.A4.getWidth(), PageSize.A4.getHeight()); Document document = new Document(tagPage, MARGIN, MARGIN, MARGIN, MARGIN);特殊尺寸纸张用Rectangle自定义,这是iText支持标准A4与自定义尺寸的关键。
另外提一个容易被忽略的点:有些打印机不支持边到边打印,会在四边各切掉几毫米。如果你的标签纸本身就把内容做到边缘,打印后标签会缺角。所以标签页四周至少要留5mm安全边距,我代码里留了10mm,比较稳。
5.4 批量生成时内存和PDF体积失控
几百张标签完全没压力,但到几千张时,如果不注意写法,内存和文件体积会一起失控。
最常见的错误是把全部二维码图片一次性生成好放到内存里,再开始写PDF。几千张BufferedImage放在堆里,很容易把内存占满。正确做法是循环里生成一个、写入一个,用完的Image对象自然脱离作用域,让GC回收。我上面的完整代码就是这么写的。
输出端也建议直接写文件流:
PdfWriter.getInstance(document, new FileOutputStream(outputPath));不要先写到一个ByteArrayOutputStream再一次性刷盘。几千页PDF全部堆在内存里,几十MB文件加上对象开销,很容易触发OutOfMemoryError。
如果资产量非常大,比如几万条,建议每5000张生成一个独立PDF文件,文件名带上批次号。这样即使某批生成一半失败,重跑那一批就行,不会前功尽弃。
二维码内容长度也在影响文件体积。内容越长,二维码版本越高,模块越多,图片越大。代码里二维码内容只放“ASSET:编号”,就是刻意控制长度。有人图省事把资产名称全部塞进二维码,一个标签的二维码文件多出好几KB,成百上千个累积起来文件瞬间变大。
6. 从“能出图”到“能上岗”:撕下来就能贴的几点实操经验
6.1 标签排版字段分区
排版看似小事,实际直接影响使用效率。我最后确定的标签版式是“左右分区”:
左侧从上到下放类别、名称、编号、责任人,右侧放二维码,二维码正下方再放一遍编号文字。
之所以二维码下方还要放一遍编号,是因为现场总有扫码设备没电、网络断了、或者二维码被污染的情况。这时人工看文字编号也能完成登记。很多标签设计只放二维码不放文字编号,扫码工具一坏全盘抓瞎。
类别放在左上角起到快速识别作用,盘点时一眼就能判断这层楼哪几台是仪器,哪几台是电脑,不用逐台扫。
6.2 用CSV驱动批量任务,非技术人员也能独立操作
程序写好后,不要让业务人员碰Java代码。提供一个CSV文件作为入口,他们改Excel另存为CSV,跑一下脚本,PDF就出来了。
CSV加载我建议自己写个简单方法,不额外引POI,减少部署成本:
public List<AssetRecord> loadFromCsv(String path) throws IOException { List<AssetRecord> list = new ArrayList<>(); try (BufferedReader reader = Files.newBufferedReader(Paths.get(path))) { String line = reader.readLine(); // 表头,跳过 while ((line = reader.readLine()) != null) { if (line.trim().isEmpty()) { continue; } line = line.replace("\uFEFF", ""); // 去掉CSV的BOM头,防止第一个字段多字符 String[] cols = line.split(",", -1); if (cols.length < 4) { continue; } AssetRecord asset = new AssetRecord(); asset.setCategory(cols[0].trim()); asset.setAssetNo(cols[1].trim()); asset.setAssetName(cols[2].trim()); asset.setOwner(cols[3].trim()); asset.setPurchaseDate(cols.length > 4 ? cols[4].trim() : ""); list.add(asset); } } return list; }两个细节:
第一,Excel另存的CSV默认带UTF-8 BOM,Java按UTF-8读的时候,第一行第一列会多出几个隐藏字符BOM,导致“类别”字段开头看起来正常但实际不对。replace("\uFEFF", "")就是干这个的。
第二,split(",", -1)最后的参数-1很关键。它保证即使某行末尾有空字段,数组长度也保持稳定,不会丢掉最后一个字段。很多资产管理Excel里“购置日期”偶尔是空的,没有-1参数,数组长度会不对,取数据就错位。
6.3 先测一页再测一卷,打印机永远等得起这一分钟
完整流程我建议分三步验证:
第一步,把资产列表里前5条数据单独导出一页PDF,在普通A4纸上打印。确认卡片位置、字号、二维码大小、中文字体全部正确。
第二步,从资产列表里挑一批编码相对复杂的(比如编号特别长的、名称特别长的)导出几页,再到不干胶纸上打一卷试打印。因为普通纸和不干胶纸的打印精度有差异,二维码在普通纸上能扫,不代表在不干胶上也能扫。
第三步,把试打印的标签贴到真实的仪器上,放半个月再扫一次。这样能验出油墨、环境对二维码的损耗程度。
这三步看着慢,其实也就是多花半小时,但能避免“一口气打了两千张发现二维码扫不出”的灾难。
6.4 标签的补打与重打策略
资产标签迟早要面对补打和重打的问题。标签掉了、贴错位置、设备退库重新分配,都需要重新生成单张标签。
我一般会在批量工具里额外留一个“单号补打”入口:命令行传入一个或多个资产编号,程序只生成一两页。不需要为了补一张标签重新跑全量数据。实现也简单,把批量方法里的List切片逻辑去掉,直接处理传入的资产列表即可。
补打时有个细节:不要在旧标签上直接叠新标。不干胶标签揭下来常有残胶,二维码区域有残留胶层,扫描容易花。现场条件允许的话,用除胶剂清理后再贴新标;如果贴双层标签,一定要确保旧标签完全平整,否则新标签悬空,扫码时会轻微反光,影响识别。
如果让我给这个项目排优先级,第一是编码规则先定清楚,第二才是PDF排版。因为编码规则决定了二维码里放什么,而扫码系统对接不上时,再漂亮的标签都是麻烦。这也是我用这套组合做了第三个标签项目之后才真正想明白的事情。希望这篇能让后来的你少走一段弯路,直接打印出能扫、能贴、能盘点的资产标签。