فهرست منبع

打印机二维码

hbx 4 روز پیش
والد
کامیت
a0904100ad

BIN
.tmp-classes/com/mes/print/PrinterException.class


BIN
.tmp-classes/com/mes/print/config/LabelConfigLoader$LabelElement.class


BIN
.tmp-classes/com/mes/print/config/LabelConfigLoader$LabelSize.class


BIN
.tmp-classes/com/mes/print/config/LabelConfigLoader.class


BIN
.tmp-classes/com/mes/print/config/PrinterConfig.class


BIN
.tmp-classes/com/mes/print/tsc/QrRenderer$Block.class


BIN
.tmp-classes/com/mes/print/tsc/QrRenderer.class


BIN
.tmp-classes/com/mes/print/tsc/TSCLIB.class


BIN
.tmp-classes/com/mes/print/tsc/TscPrinter.class


BIN
.tmp-classes/com/mes/util/AppLog$1.class


BIN
.tmp-classes/com/mes/util/AppLog$2.class


BIN
.tmp-classes/com/mes/util/AppLog.class


BIN
anli.jpg


BIN
anli2.jpg


+ 6 - 6
config/label-config.json

@@ -12,10 +12,10 @@
       "type": "qrcode",
       "label": "二维码",
       "x": 40,
-      "y": 94,
-      "width": 168,
-      "height": 168,
-      "eccLevel": "L",
+      "y": 40,
+      "width": 200,
+      "height": 200,
+      "eccLevel": "M",
       "cellWidth": "8",
       "text": "+KB60IS3031T811001201",
       "dataSource": "sn"
@@ -48,7 +48,7 @@
       "id": "date-serial",
       "type": "text",
       "label": "日期+流水号",
-      "x": 250,
+      "x": 280,
       "y": 166,
       "fontSize": 28,
       "fontFamily": "SimSun",
@@ -60,7 +60,7 @@
       "id": "batch-number",
       "type": "text",
       "label": "批次号",
-      "x": 250,
+      "x": 280,
       "y": 242,
       "fontSize": 28,
       "fontFamily": "SimSun",

+ 9 - 0
config/print.properties

@@ -7,3 +7,12 @@ label.offset.y=0
 label.offset.x=0
 # 客户码校验正则(反斜杠写两个),默认只认 T09(+KB60)/T68(+KB87) 21 位客户码
 #label.sn.pattern=^\\+KB(60|87)[A-Z0-9]{8}[0-9]{8}$
+
+# 二维码怎么画(默认自绘,纠错等级/模块数/大小/静区都由程序定,不听打印机固件的):
+#   rect     = 程序自己渲染,每个黑块用 BAR 指令画(默认)
+#   bitmap   = 程序生成点阵,用 BITMAP 指令发二进制
+#   firmware = 交打印机固件自己画(固件会把纠错等级改成 L,码更小更脆,只在自绘都失败时用)
+label.qr.mode=rect
+
+# 仅 bitmap 模式用:点阵位序。打出来是花码就改成 lsb(改完下一张生效,不用重新打包)
+#label.qr.bitorder=msb

+ 55 - 7
label-editor.html

@@ -317,6 +317,7 @@
         let selectedElementId = null;
         let zoom = 1.0;
         const DPI = 203 / 25.4; // 203 DPI,1mm = 8 dots
+        const qrModules = {};   // 元素 id → 二维码模块数(renderCanvas 时算出来,属性面板用来提示实际尺寸)
         let isDragging = false;
         let dragStartX = 0;
         let dragStartY = 0;
@@ -389,18 +390,39 @@
                     div.style.height = (element.height * zoom) + 'px';
                     div.style.background = 'white';
                     div.style.padding = '0';
+                    const cell = Math.max(1, parseInt(element.cellWidth) || 4);
+                    // 打印机是从元素左上角开始画的,预览也钉左上角;虚线框画在符号外 4 个模块处 = 静区
+                    div.style.display = 'block';
+                    div.style.outline = '1px dashed #bdc3c7';
+                    div.style.outlineOffset = (-4 * cell * zoom) + 'px';
 
                     // 生成真实二维码
                     const qrData = element.text || '+KB60IS3031T811001201'; // 默认示例数据
                     try {
-                        new QRCode(div, {
+                        const qr = new QRCode(div, {
                             text: qrData,
-                            width: element.width * zoom,
-                            height: element.height * zoom,
+                            width: 100,
+                            height: 100,
                             colorDark: '#000000',
                             colorLight: '#ffffff',
-                            correctLevel: QRCode.CorrectLevel.L
+                            correctLevel: eccLevelOf(element.eccLevel)
                         });
+                        // 打印机实际尺寸 = 单元宽度(点) × 模块数,跟 width/height 预留框无关
+                        const modules = (qr._oQRCode && qr._oQRCode.getModuleCount)
+                            ? qr._oQRCode.getModuleCount() : 21;
+                        qrModules[element.id] = modules;
+                        const realPx = cell * modules * zoom;
+                        const img = div.querySelector('canvas, img, table');
+                        if (img) {
+                            img.style.width = realPx + 'px';
+                            img.style.height = realPx + 'px';
+                            img.style.imageRendering = 'pixelated';
+                        }
+                        const overflow = realPx > Math.min(element.width, element.height) * zoom + 0.5;
+                        if (overflow) div.style.outline = '2px dashed #e74c3c';
+                        div.title = `二维码实际 ${cell}点 × ${modules}模块 = ${cell * modules}点(${(cell * modules / 8).toFixed(1)}mm)`
+                            + `,四周另留 4 模块静区(共 ${cell * (modules + 8)} 点),预留框 ${element.width}×${element.height} 点`
+                            + (overflow ? ':超框了,打印时会自动压小单元宽度' : '');
                     } catch (e) {
                         div.textContent = 'QR';
                         div.style.background = '#ecf0f1';
@@ -572,11 +594,11 @@
                     <div class="property-group">
                         <div class="property-row">
                             <div>
-                                <label>宽度 (dots)</label>
+                                <label>预览框宽 (dots)</label>
                                 <input type="number" value="${element.width}" onchange="updateElementProperty('width', parseInt(this.value))">
                             </div>
                             <div>
-                                <label>高度 (dots)</label>
+                                <label>预览框高 (dots)</label>
                                 <input type="number" value="${element.height}" onchange="updateElementProperty('height', parseInt(this.value))">
                             </div>
                         </div>
@@ -591,8 +613,9 @@
                         </select>
                     </div>
                     <div class="property-group">
-                        <label>单元宽度</label>
+                        <label>单元宽度(点,1~10)</label>
                         <input type="number" value="${element.cellWidth}" onchange="updateElementProperty('cellWidth', this.value)">
+                        <div style="font-size:12px;margin-top:6px;line-height:1.5;">${qrSizeHint(element)}</div>
                     </div>
                 `;
             }
@@ -609,9 +632,34 @@
                 element[property] = value;
                 renderCanvas();
                 renderElementList();
+                updateProperties();
+            }
+        }
+
+        // 纠错等级 → qrcode.js 常量(打印时读的是同一个配置值,L 只有 7%,默认按 M)
+        function eccLevelOf(level) {
+            switch ((level || '').toUpperCase()) {
+                case 'L': return QRCode.CorrectLevel.L;
+                case 'Q': return QRCode.CorrectLevel.Q;
+                case 'H': return QRCode.CorrectLevel.H;
+                default:  return QRCode.CorrectLevel.M;
             }
         }
 
+        // 二维码实际打印尺寸提示:实际尺寸 = 单元宽度 × 模块数,超框时打印会按框把单元宽度压小
+        function qrSizeHint(element) {
+            const cell = Math.max(1, parseInt(element.cellWidth) || 4);
+            const modules = qrModules[element.id];
+            if (!modules) return '实际打印尺寸 = 单元宽度 × 模块数';
+            const dots = cell * modules;
+            const box = Math.min(element.width, element.height);
+            const overflow = dots > box;
+            const color = overflow ? '#e74c3c' : '#27ae60';
+            return `<span style="color:${color}">实际打印 = ${cell} × ${modules} = ${dots} 点(${(dots / 8).toFixed(1)}mm)`
+                + (overflow ? `,超出预留框 ${box} 点,打印时会自动压小` : `,在预留框 ${box} 点内`)
+                + `;四周再留 4 模块静区,整块占 ${dots + 8 * cell} 点(${((dots + 8 * cell) / 8).toFixed(1)}mm)</span>`;
+        }
+
         // 缩放控制
         function zoomIn() {
             zoom = Math.min(zoom + 0.1, 2.0);

+ 25 - 3
src/com/mes/label/LabelScanManager.java

@@ -81,7 +81,8 @@ public class LabelScanManager {
         this.callback = callback;
         this.snPattern = compilePattern(cfg("label.sn.pattern", DEFAULT_SN_PATTERN));
         loadHistory();
-        System.out.println("[标签] 初始化完成: 历史 " + history.size() + " 条, 标签机=" + getLabelPrinterName());
+        System.out.println("[标签] 初始化完成: 历史 " + history.size() + " 条, 标签机=" + getLabelPrinterName()
+                + ", TSCLIB.dll=" + dllPath());
     }
 
     // ==== 对外 API ====
@@ -257,15 +258,36 @@ public class LabelScanManager {
 
     private static String describe(Throwable e) {
         if (e instanceof UnsatisfiedLinkError) {
-            return "加载 TSCLIB.dll 失败(需 64 位 Java): " + e.getMessage();
+            // 文件没找到、位数不匹配、依赖缺失都会抛这个异常,别一律说成"需 64 位 Java"
+            return "加载 TSCLIB.dll 失败: " + firstLine(e.getMessage())
+                    + "(当前 Java=" + System.getProperty("os.arch")
+                    + "/" + System.getProperty("sun.arch.data.model") + "位"
+                    + ",TSCLIB.dll=" + dllPath()
+                    + "。把 TSCLIB.dll 放在程序目录,或装上 TSC 打印机驱动(会把 TSCLIB.dll 装进系统目录))";
         }
         if (e instanceof NoClassDefFoundError || e instanceof ExceptionInInitializerError) {
-            return "标签打印组件加载失败(检查 jna jar / TSCLIB.dll): " + e;
+            return "标签打印组件加载失败(TSCLIB.dll 没找到 / jna jar 缺失): " + e;
         }
         String m = e.getMessage();
         return m == null || m.isEmpty() ? e.toString() : m;
     }
 
+    /** TSCLIB.dll 实际用哪个文件(诊断用) */
+    private static String dllPath() {
+        try {
+            File f = TSCLIB.locate();
+            return f == null ? "(程序目录/工作目录都没找到,按库名交给系统搜索)" : f.getAbsolutePath();
+        } catch (Throwable t) {
+            return "(解析失败: " + t + ")";
+        }
+    }
+
+    private static String firstLine(String msg) {
+        if (msg == null) return "";
+        int i = msg.indexOf('\n');
+        return i < 0 ? msg : msg.substring(0, i).trim();
+    }
+
     private static String cfg(String key, String def) {
         try {
             String v = PrinterConfig.get(key, def);

+ 171 - 0
src/com/mes/print/tsc/QrRenderer.java

@@ -0,0 +1,171 @@
+package com.mes.print.tsc;
+
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+import com.google.zxing.qrcode.encoder.ByteMatrix;
+import com.google.zxing.qrcode.encoder.Encoder;
+import com.google.zxing.qrcode.encoder.QRCode;
+
+import java.awt.image.BufferedImage;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * 二维码渲染:用 ZXing 自己算点阵,再交给 TSPL 指令画到标签上。
+ *
+ * 为什么不用打印机的 QRCODE 指令直接画:实测固件在自动模式下会自己改纠错等级
+ * (我们要 M,它打成 L,码只有 21 模块、又小又脆),而且画出来的模块间距不保证均匀。
+ * 自己渲染后,纠错等级、版本、模块大小、静区全部由程序控制,跟 BarTender 打位图是一个思路。
+ *
+ * 两种画法(由 print.properties 的 label.qr.mode 选):
+ *   rect   —— 每个黑块用一条 BAR 指令画(纯 ASCII,像素级精确,默认)
+ *   bitmap —— 打包成 TSPL BITMAP 点阵 + 二进制发送
+ */
+public final class QrRenderer {
+
+    /** 规范要求四周各留 4 个模块的静区 */
+    public static final int QUIET_MODULES = 4;
+
+    /** 一个黑块(模块坐标,含静区偏移) */
+    public static final class Block {
+        public final int x;
+        public final int y;
+        public final int w;
+        public final int h;
+
+        Block(int x, int y, int w, int h) {
+            this.x = x;
+            this.y = y;
+            this.w = w;
+            this.h = h;
+        }
+    }
+
+    /** 符号边长(模块),纠错等级越高、内容越长越大 */
+    public final int modules;
+    /** 每个模块占几个点 */
+    public final int cellWidth;
+    /** 含静区的总点数(宽 = 高) */
+    public final int dotSize;
+
+    private final int gridSize;
+    private final boolean[] grid;
+
+    private QrRenderer(ByteMatrix matrix, int cellWidth) {
+        this.modules = matrix.getWidth();
+        this.cellWidth = Math.max(1, cellWidth);
+        this.gridSize = modules + QUIET_MODULES * 2;
+        this.grid = new boolean[gridSize * gridSize];
+        for (int y = 0; y < modules; y++) {
+            for (int x = 0; x < modules; x++) {
+                if (matrix.get(x, y) == 1) {
+                    grid[(y + QUIET_MODULES) * gridSize + (x + QUIET_MODULES)] = true;
+                }
+            }
+        }
+        this.dotSize = gridSize * this.cellWidth;
+    }
+
+    /**
+     * 按内容和纠错等级生成二维码
+     * @param content 二维码内容
+     * @param eccLevel 纠错等级 L/M/Q/H(非法值按 M)
+     * @param cellWidth 每个模块几个点
+     */
+    public static QrRenderer build(String content, String eccLevel, int cellWidth) {
+        if (content == null || content.isEmpty()) {
+            throw new IllegalArgumentException("二维码内容为空");
+        }
+        QRCode code;
+        try {
+            code = Encoder.encode(content, eccOf(eccLevel));
+        } catch (Throwable t) {
+            throw new RuntimeException("生成二维码点阵失败: " + t, t);
+        }
+        return new QrRenderer(code.getMatrix(), cellWidth);
+    }
+
+    public int gridSize() {
+        return gridSize;
+    }
+
+    public boolean isBlack(int gx, int gy) {
+        return grid[gy * gridSize + gx];
+    }
+
+    /**
+     * 把黑格合并成尽量少的实心矩形:同一行连续的黑格算一段,能往下连就继续连。
+     * 25 模块的码一般 60~100 块,比逐格画少很多条指令。
+     */
+    public List<Block> blocks() {
+        boolean[] left = grid.clone();
+        List<Block> out = new ArrayList<>();
+        for (int y = 0; y < gridSize; y++) {
+            for (int x = 0; x < gridSize; x++) {
+                if (!left[y * gridSize + x]) continue;
+                int x2 = x;
+                while (x2 < gridSize && left[y * gridSize + x2]) x2++;
+                int y2 = y + 1;
+                scan:
+                while (y2 < gridSize) {
+                    for (int i = x; i < x2; i++) {
+                        if (!left[y2 * gridSize + i]) break scan;
+                    }
+                    y2++;
+                }
+                for (int yy = y; yy < y2; yy++) {
+                    for (int xx = x; xx < x2; xx++) left[yy * gridSize + xx] = false;
+                }
+                out.add(new Block(x, y, x2 - x, y2 - y));
+                x = x2 - 1;
+            }
+        }
+        return out;
+    }
+
+    /**
+     * TSPL BITMAP 点阵:每行按字节对齐,1 = 黑点。
+     * @param msbFirst true: 每字节最高位是最左边的点;false: 最低位是最左边的点
+     */
+    public byte[] bitmap(boolean msbFirst) {
+        int bytesPerRow = (dotSize + 7) / 8;
+        byte[] data = new byte[bytesPerRow * dotSize];
+        for (int gy = 0; gy < gridSize; gy++) {
+            for (int gx = 0; gx < gridSize; gx++) {
+                if (!grid[gy * gridSize + gx]) continue;
+                for (int dy = 0; dy < cellWidth; dy++) {
+                    int y = gy * cellWidth + dy;
+                    for (int dx = 0; dx < cellWidth; dx++) {
+                        int x = gx * cellWidth + dx;
+                        int bit = msbFirst ? (7 - (x % 8)) : (x % 8);
+                        data[y * bytesPerRow + x / 8] |= (byte) (1 << bit);
+                    }
+                }
+            }
+        }
+        return data;
+    }
+
+    /** 渲染成图片(自检、预览用) */
+    public BufferedImage image(int scale) {
+        int s = Math.max(1, scale);
+        BufferedImage img = new BufferedImage(dotSize * s, dotSize * s, BufferedImage.TYPE_INT_RGB);
+        for (int y = 0; y < dotSize * s; y++) {
+            for (int x = 0; x < dotSize * s; x++) {
+                boolean black = isBlack(x / s / cellWidth, y / s / cellWidth);
+                img.setRGB(x, y, black ? 0x000000 : 0xFFFFFF);
+            }
+        }
+        return img;
+    }
+
+    private static ErrorCorrectionLevel eccOf(String raw) {
+        if (raw == null) return ErrorCorrectionLevel.M;
+        switch (raw.trim().toUpperCase(Locale.ROOT)) {
+            case "L": return ErrorCorrectionLevel.L;
+            case "Q": return ErrorCorrectionLevel.Q;
+            case "H": return ErrorCorrectionLevel.H;
+            default:  return ErrorCorrectionLevel.M;
+        }
+    }
+}

+ 67 - 3
src/com/mes/print/tsc/TSCLIB.java

@@ -3,15 +3,67 @@ package com.mes.print.tsc;
 import com.sun.jna.Library;
 import com.sun.jna.Native;
 
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
 /**
  * TSC 打印机 DLL 接口
  * 基于 TSCLIB.dll 的 JNA 封装
  */
 public interface TSCLIB extends Library {
 
-    // 加载 DLL,JNA 查找顺序:jna.library.path → 系统搜索(当前目录/PATH 等)
-    // → 都没有就从 classpath 的 win32-x86-64/TSCLIB.dll 解压加载(jar 内置,见 IDEA artifact 配置)
-    TSCLIB INSTANCE = (TSCLIB) Native.load("TSCLIB", TSCLIB.class);
+    String DLL_NAME = "TSCLIB";
+    String DLL_FILE = DLL_NAME + ".dll";
+
+    // 先自己把 TSCLIB.dll 的实际文件找出来,再让 JNA 按绝对路径加载:这样跟"当前工作目录"解耦,
+    // IDEA 里跑、双击 jar 跑、现场装过 TSC 驱动跑都能加载。一个都没找到才退回库名,交给系统搜索
+    // (TSC 驱动会把 TSCLIB.dll 装到系统目录,那时按名字也能找到)。
+    TSCLIB INSTANCE = (TSCLIB) Native.load(resolve(), TSCLIB.class);
+
+    /**
+     * 依次查找 TSCLIB.dll 的实际文件,找不到返回 null:
+     * 1. -Djna.library.path 里列出的目录
+     * 2. 程序自身所在目录(jar 同级,或 bin / classes 目录)
+     * 3. 上面那层的上一层(IDEA 编译到 bin、out/production/<模块> 时,dll 在项目根目录)
+     * 4. 当前工作目录
+     */
+    static File locate() {
+        List<File> dirs = new ArrayList<>();
+        String jnaPath = System.getProperty("jna.library.path");
+        if (jnaPath != null) {
+            for (String p : jnaPath.split(File.pathSeparator)) {
+                if (p != null && !p.trim().isEmpty()) dirs.add(new File(p.trim()));
+            }
+        }
+        File app = appDir();
+        dirs.add(app);
+        if (app != null) dirs.add(app.getParentFile());
+        String userDir = System.getProperty("user.dir");
+        dirs.add(userDir == null ? null : new File(userDir));
+        for (File dir : dirs) {
+            if (dir == null) continue;
+            File f = new File(dir, DLL_FILE);
+            if (f.isFile()) return f.getAbsoluteFile();
+        }
+        return null;
+    }
+
+    /** 找得到就用绝对路径,找不到返回库名让系统按 PATH / 系统目录搜索 */
+    static String resolve() {
+        File f = locate();
+        return f == null ? DLL_NAME : f.getAbsolutePath();
+    }
+
+    /** 程序自身所在目录:jar 所在目录,或 classes/bin 目录;取不到返回 null */
+    static File appDir() {
+        try {
+            File self = new File(TSCLIB.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+            return self.isFile() ? self.getParentFile() : self;
+        } catch (Throwable t) {
+            return null;
+        }
+    }
 
     // ========== 基本控制函数 ==========
 
@@ -113,4 +165,16 @@ public interface TSCLIB extends Library {
      * @param command TSPL 命令字符串
      */
     int sendcommand(String command);
+
+    /**
+     * 发送原始命令,不追加 CRLF(TSPL 的 BITMAP 点阵数据必须紧跟命令头,中间不能有换行)
+     */
+    int sendcommandnocrlf(String command);
+
+    /**
+     * 发送二进制数据,不追加 CRLF:用于 TSPL BITMAP 的点阵
+     * @param data 点阵数据(每行按字节对齐,1 = 黑点)
+     * @param length 字节数
+     */
+    int sendBinaryDataNoCRLF(byte[] data, int length);
 }

+ 178 - 17
src/com/mes/print/tsc/TscPrinter.java

@@ -4,10 +4,12 @@ import com.mes.print.config.LabelConfigLoader;
 import com.mes.print.config.PrinterConfig;
 import com.mes.util.AppLog;
 
+import java.nio.charset.StandardCharsets;
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 
 /**
@@ -23,6 +25,10 @@ import java.util.Map;
  *   serialNo     = 码里的 4 位日流水补零到 7 位:+KB60IS3031T929000401 → 0000004
  *   date         = 打印日期(补打时由调用方传首次打印日期)
  *
+ * 二维码实际尺寸 = cellWidth(点) × 模块数,模块数由内容和纠错等级决定,
+ * 跟配置里的 width/height(只有编辑器在用)无关,所以打印前会按元素框夹一次 cellWidth,
+ * 免得二维码撑出预留区域、吃掉静区或压到旁边文字(扫不出来)。
+ *
  * label-config.json 里非 static 元素的 text 只是编辑器预览用的示例值,
  * 打印时取不到业务数据直接报错,绝不回退到示例值。
  */
@@ -31,6 +37,37 @@ public class TscPrinter {
     /** TTP-244 Pro 为 203 dpi,1mm ≈ 8 点 */
     private static final double DOTS_PER_MM = 8.0;
 
+    /** TSPL QRCODE 单元宽度允许范围(点) */
+    private static final int MIN_CELL_WIDTH = 1;
+    private static final int MAX_CELL_WIDTH = 10;
+    /**
+     * 想扫得稳,单元宽度至少要这么多点(0.625mm)。
+     * 实测(bin 里的 ZXing 渲染解码自检):4 点(0.5mm)时黑模块只要涨 1 个点就糊在一起读不出来,
+     * 5 点以上才有余量,所以配置低于这个值时告警提醒放大元素框。
+     */
+    private static final int ROBUST_CELL_WIDTH = 5;
+    /** 配置里没写单元宽度时的兜底值:0.5mm,模块不会糊在一起 */
+    private static final int DEFAULT_CELL_WIDTH = 6;
+
+    /**
+     * 字节模式容量(字符数):[纠错等级 0=L/1=M/2=Q/3=H][版本-1],版本 1~10。
+     * 只用于估算二维码模块数;按字节模式算比字母数字模式保守(模块数只会多算不会少算),
+     * 保证夹出来的单元宽度一定装得下。
+     */
+    private static final int[][] QR_BYTE_CAPACITY = {
+        //   L    M    Q    H
+        {   17,  14,  11,   7},  // v1  (21×21)
+        {   32,  26,  20,  14},  // v2  (25×25)
+        {   53,  42,  32,  24},  // v3  (29×29)
+        {   78,  62,  46,  34},  // v4  (33×33)
+        {  106,  84,  60,  44},  // v5  (37×37)
+        {  134, 106,  74,  58},  // v6  (41×41)
+        {  154, 122,  86,  64},  // v7  (45×45)
+        {  192, 152, 108,  84},  // v8  (49×49)
+        {  230, 180, 130,  98},  // v9  (53×53)
+        {  271, 213, 151, 119},  // v10 (57×57)
+    };
+
     private final TSCLIB tsc = TSCLIB.INSTANCE;
     private final LabelConfigLoader configLoader;
 
@@ -76,9 +113,10 @@ public class TscPrinter {
         try {
             String setup = setupLabel();
             tsc.clearbuffer();
-            drawLabelFromConfig(dataMap, offX, offY);
+            String qrInfo = drawLabelFromConfig(dataMap, offX, offY);
             tsc.printlabel("1", "1");  // 打印1份,每份1张
             String info = "[TSC] 打印 " + sn + " NO:" + dataMap.get("serialNo") + " " + setup
+                    + (qrInfo.isEmpty() ? "" : " QR=" + qrInfo)
                     + " 偏移=(" + offX + "," + offY + ")点 打印机=" + printerName;
             System.out.println(info);
             AppLog.info(info);
@@ -134,7 +172,7 @@ public class TscPrinter {
             width,
             height,
             "4",  // 速度
-            "10", // 浓度
+            "10", // 浓度:现场验证过 10 的对比度最好;二维码模块别小于 0.625mm,靠模块尺寸抗点增益
             "0",  // 传感器类型:0=间隙
             gap,
             "0"   // 间隙偏移
@@ -146,9 +184,13 @@ public class TscPrinter {
         return (int) Math.round(mm * DOTS_PER_MM);
     }
 
-    /** 按配置逐个绘制元素,offX/offY 为整体偏移(点) */
-    private void drawLabelFromConfig(Map<String, String> dataMap, int offX, int offY) {
+    /**
+     * 按配置逐个绘制元素,offX/offY 为整体偏移(点)
+     * @return 二维码实际尺寸描述(没画二维码返回空串)
+     */
+    private String drawLabelFromConfig(Map<String, String> dataMap, int offX, int offY) {
         List<LabelConfigLoader.LabelElement> elements = configLoader.getElements();
+        String qrDesc = "";
         for (LabelConfigLoader.LabelElement element : elements) {
             int x = Math.max(0, element.x + offX);
             int y = Math.max(0, element.y + offY);
@@ -164,21 +206,140 @@ public class TscPrinter {
                     resolveData(element, dataMap)
                 );
             } else if ("qrcode".equals(element.type)) {
-                // qrcode() 函数不工作,用原始 TSPL:QRCODE x,y,eccLevel,cellWidth,mode,rotation,"data"
-                String qrCommand = String.format(
-                    "QRCODE %d,%d,%s,%s,%s,%s,\"%s\"",
-                    x,
-                    y,
-                    element.eccLevel,
-                    element.cellWidth,
-                    "A",  // mode
-                    "0",  // rotation
-                    resolveData(element, dataMap)
-                );
-                System.out.println("[TSC] " + element.id + " -> " + qrCommand);
-                tsc.sendcommand(qrCommand);
+                qrDesc = drawQr(element, x, y, dataMap);
             }
         }
+        return qrDesc;
+    }
+
+    /**
+     * 画二维码。只使用 ZXing 自绘,不再把内容交给打印机固件解析。
+     * @return 日志描述
+     */
+    private String drawQr(LabelConfigLoader.LabelElement element, int x, int y, Map<String, String> dataMap) {
+        String data = resolveData(element, dataMap);
+        String eccLevel = eccLevelOf(element.eccLevel);
+        String mode = PrinterConfig.get("label.qr.mode", "rect").trim().toLowerCase(Locale.ROOT);
+
+        if ("firmware".equals(mode)) {
+            throw new IllegalArgumentException("不允许使用二维码固件模式,请将 label.qr.mode 设置为 rect");
+        }
+
+        // 先用 ZXing 算出真实模块数,再按元素框夹紧单元宽度。
+        int modules = QrRenderer.build(data, eccLevel, 1).modules;
+        int cellWidth = cellWidthFor(element, modules);
+        QrRenderer qr = QrRenderer.build(data, eccLevel, cellWidth);
+        if ("bitmap".equals(mode)) {
+            throw new IllegalArgumentException("bitmap 模式需要确认 TSCLIB 二进制接口可用,请使用 rect 模式");
+        }
+
+        List<QrRenderer.Block> blocks = qr.blocks();
+        for (QrRenderer.Block b : blocks) {
+            // 分块坐标含静区偏移,让二维码符号左上角落在 (x,y)。
+            tsc.sendcommand(String.format(Locale.ROOT, "BAR %d,%d,%d,%d",
+                    x + (b.x - QrRenderer.QUIET_MODULES) * cellWidth,
+                    y + (b.y - QrRenderer.QUIET_MODULES) * cellWidth,
+                    b.w * cellWidth, b.h * cellWidth));
+        }
+        String how = "自绘" + blocks.size() + "块";
+        log("[TSC] 二维码内容=[" + data + "] 字节=" + data.getBytes(StandardCharsets.UTF_8).length
+                + " " + modules + "模块×" + cellWidth + "点 纠错=" + eccLevel
+                + " 静区=" + QrRenderer.QUIET_MODULES + "模块 " + how + " 位置=(" + x + "," + y + ")");
+        return modules + "模块×" + cellWidth + "点 " + how;
+    }
+
+    /** 点阵位序:print.properties 的 label.qr.bitorder,msb(默认) / lsb */
+    private static boolean isMsbFirst() {
+        return !"lsb".equalsIgnoreCase(PrinterConfig.get("label.qr.bitorder", "msb").trim());
+    }
+
+    private static void log(String message) {
+        System.out.println(message);
+        AppLog.info(message);
+    }
+
+    /**
+     * 二维码单元宽度(点):取配置值,但不允许超过元素框能装下的最大值。
+     * 配置里的 width/height 只是编辑器预览用的框,打印机只认 cellWidth × 模块数,
+     * 所以这里必须再夹一次:超框就压小并告警,防止二维码出框、静区不足导致偶尔扫不出来。
+     */
+    private static int cellWidthFor(LabelConfigLoader.LabelElement element, int modules) {
+        int configured = parseCellWidth(element.cellWidth);
+        int box = Math.min(element.width, element.height);
+        int effective = configured;
+        if (box > 0 && modules > 0) {
+            effective = Math.min(configured, Math.max(MIN_CELL_WIDTH, box / modules));
+            if (effective < configured) {
+                String warn = "[TSC] 二维码 " + modules + " 模块 × " + configured + " 点 = "
+                        + (configured * modules) + " 点,超出元素框 " + box + " 点,已按 " + effective + " 点打印";
+                System.out.println(warn);
+                AppLog.info(warn);
+            }
+        }
+        if (effective < ROBUST_CELL_WIDTH) {
+            String warn = "[TSC] 二维码单元宽度只有 " + effective + " 点("
+                    + String.format(Locale.ROOT, "%.2f", effective / DOTS_PER_MM)
+                    + "mm/模块),再小容易被热敏点增益糊掉、扫不出来;建议把元素框放大到至少 "
+                    + (modules * ROBUST_CELL_WIDTH) + " 点";
+            System.out.println(warn);
+            AppLog.info(warn);
+        }
+        return Math.max(MIN_CELL_WIDTH, Math.min(MAX_CELL_WIDTH, effective));
+    }
+
+    /**
+     * 估算二维码边长(模块数):21 + 4×(版本-1)。
+     * 内容按字节模式(UTF-8 字节数)估,只多不少;纠错等级越高、内容越长,版本越大。
+     */
+    private static int qrModuleCount(String data, String eccLevel) {
+        int len = data == null ? 0 : data.getBytes(StandardCharsets.UTF_8).length;
+        int ecc = eccIndex(eccLevel);
+        int version = 40;
+        for (int v = 1; v <= QR_BYTE_CAPACITY.length; v++) {
+            if (len <= QR_BYTE_CAPACITY[v - 1][ecc]) {
+                version = v;
+                break;
+            }
+        }
+        if (version == 40) {
+            // 超出表范围(正常码长不会走到这里):保守外推,宁可算大不算小
+            int capacity = QR_BYTE_CAPACITY[QR_BYTE_CAPACITY.length - 1][ecc];
+            for (int v = QR_BYTE_CAPACITY.length + 1; v <= 40; v++) {
+                capacity += 34;
+                if (len <= capacity) {
+                    version = v;
+                    break;
+                }
+            }
+        }
+        return 21 + 4 * (version - 1);
+    }
+
+    /** 配置里单元宽度可能是字符串 "8"/"8.0" 或数字,统一转成整数点(非法值回退默认,别把 "8.0" 直接发给打印机) */
+    private static int parseCellWidth(String raw) {
+        if (raw == null || raw.trim().isEmpty()) return DEFAULT_CELL_WIDTH;
+        try {
+            return (int) Math.round(Double.parseDouble(raw.trim()));
+        } catch (NumberFormatException e) {
+            System.err.println("[TSC] 二维码单元宽度非法: " + raw + ",按 " + DEFAULT_CELL_WIDTH + " 点打印");
+            return DEFAULT_CELL_WIDTH;
+        }
+    }
+
+    /** 纠错等级规范化:非法/缺失回退 M(L 只有 7%,打印头一脏就容易扫不出来) */
+    private static String eccLevelOf(String raw) {
+        if (raw == null) return "M";
+        String v = raw.trim().toUpperCase(Locale.ROOT);
+        return ("L".equals(v) || "M".equals(v) || "Q".equals(v) || "H".equals(v)) ? v : "M";
+    }
+
+    private static int eccIndex(String raw) {
+        switch (eccLevelOf(raw)) {
+            case "L": return 0;
+            case "Q": return 2;
+            case "H": return 3;
+            default:  return 1;
+        }
     }
 
     private Map<String, String> prepareDataMap(String sn, Date printDate) {