diff --git a/CHANGELOG-client.md b/CHANGELOG-client.md index 36f914a..ad0e0ed 100644 --- a/CHANGELOG-client.md +++ b/CHANGELOG-client.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.13] - 2026-08-27 + +### 改进 +- 扫码出库免按回车:扫码枪扫完商品二维码后自动加入明细,无需再手动敲一下回车。 + +### 修复 +- 修复热敏打印机(得力等 TSPL 标签机)打印商品价签时字体重叠、且底部多出一行酒行名称的问题;现在热敏打印效果与屏幕预览完全一致,不再失真。 + ## [1.1.12] - 2026-08-27 ### 改进 diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index 5d37a38..0ce3502 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -4,7 +4,6 @@ import 'dart:typed_data'; import 'dart:ui' as ui; import 'dart:ffi'; import 'package:barcode/barcode.dart' as bc; -import 'package:charset_converter/charset_converter.dart'; import 'package:ffi/ffi.dart'; import 'package:win32/win32.dart' show @@ -15,8 +14,7 @@ import 'package:win32/win32.dart' EndPagePrinter, EndDocPrinter, ClosePrinter, - DOC_INFO_1, - WideCharToMultiByte; + DOC_INFO_1; import 'package:flutter/foundation.dart' show kIsWeb, debugPrint, visibleForTesting; import 'package:flutter/services.dart'; @@ -240,6 +238,8 @@ Future _renderLabelBitmap({ required String code, required List subs, // 底部平铺字段:度数 / 规格 / 日期(已过滤空) required Uint8List qrBytes, + bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码 + bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码 }) async { const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸) const scale = 2; // 2× 超采样,内部以 640×320 绘制 @@ -272,7 +272,7 @@ Future _renderLabelBitmap({ const footerTop = h - footerH; // 145 canvas.drawRect(ui.Rect.fromLTWH(0, footerTop, w.toDouble(), footerH), ui.Paint()..color = cCream); - _drawText(canvas, shop, 10, footerTop + 3.5, 8, 180, color: cFoot); + // 页脚只留右侧尺寸标注:店名已在顶部抬头,页脚不再重复(2026-08-27 去冗余) final szW = _measureWidth('40×20mm', 8, false); _drawText(canvas, '40×20mm', w - 10 - szW, footerTop + 3.5, 8, szW, color: cFoot); @@ -287,16 +287,19 @@ Future _renderLabelBitmap({ const double qrSize = (bodyBot - bodyTop) - 2 * padV; // 109 const double qrTop = bodyTop + padV; // 30 const double qrLeft = w - padH - qrSize; // 203 - try { - final codec = await ui.instantiateImageCodec(qrBytes); - final qrImg = (await codec.getNextFrame()).image; - canvas.drawImageRect( - qrImg, - ui.Rect.fromLTWH(0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()), - const ui.Rect.fromLTWH(qrLeft, qrTop, qrSize, qrSize), - ui.Paint()); - } catch (e) { - debugPrint('[label] QR 解码失败: $e'); + if (drawQr) { + try { + final codec = await ui.instantiateImageCodec(qrBytes); + final qrImg = (await codec.getNextFrame()).image; + canvas.drawImageRect( + qrImg, + ui.Rect.fromLTWH( + 0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()), + const ui.Rect.fromLTWH(qrLeft, qrTop, qrSize, qrSize), + ui.Paint()); + } catch (e) { + debugPrint('[label] QR 解码失败: $e'); + } } // 左列几何 @@ -308,7 +311,7 @@ Future _renderLabelBitmap({ const double barH = 40.0; const double barBottom = bodyBot - padV; // 137 const double barTop = barBottom - barH; // 97 - _drawBarcode(canvas, code, leftX, barTop, leftW, barH); + if (drawBarcode) _drawBarcode(canvas, code, leftX, barTop, leftW, barH); // 左列文字块(品名+编号 一行居中;度数/规格/日期 平铺一行),在条码上方居中 const double infoTop = bodyTop + padV; // 32 @@ -379,64 +382,10 @@ Future _renderLabelBitmap({ return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320) } -/// UTF-8 → GBK 字节: -/// Windows 直接调 WideCharToMultiByte(936) FFI,无需插件; -/// macOS 走 charset_converter(CoreFoundation 原生支持 GBK)。 -Uint8List _encodeGbk(String text) { - if (text.isEmpty) return Uint8List(0); - if (Platform.isWindows) { - final wideStr = text.toNativeUtf16(); - try { - final needed = WideCharToMultiByte( - 936, 0, wideStr, -1, nullptr, 0, nullptr, nullptr); - if (needed <= 1) return Uint8List(0); - final buf = calloc(needed); - try { - WideCharToMultiByte( - 936, 0, wideStr, -1, buf.cast(), needed, nullptr, nullptr); - return Uint8List.fromList(buf.asTypedList(needed - 1)); // 不含 null 终止符 - } finally { - calloc.free(buf); - } - } finally { - calloc.free(wideStr); - } - } - // macOS: charset_converter(同步 CoreFoundation) - // encode() 在 macOS 上是同步 channel 调用,此处以 Future.value 包装保持接口一致 - throw UnsupportedError('call _encodeGbkAsync on macOS'); -} - -Future _encodeGbkAsync(String text) async { - if (text.isEmpty) return Uint8List(0); - if (Platform.isWindows) return _encodeGbk(text); - return CharsetConverter.encode('GBK', text); -} - -/// 估算 TSPL 内置点阵字体下一行文本的像素宽度(dots): -/// 全角(CJK) 字符宽 = 字高;半角(ASCII) 宽 = 字高/2。用于居中 / 平铺定位。 -int _estTsplWidth(String s, int fontH) { - var w = 0; - for (final r in s.runes) { - w += r > 0x7F ? fontH : (fontH ~/ 2); - } - return w; -} - -/// TSPL TEXT 命令辅助:将文本以 GBK 编码写入 BytesBuilder。 -/// TSS24.BF2/TSS16.BF2 是 TSC 固件内置简体中文点阵字体,需 GBK 编码。 -Future _addTextCmd( - BytesBuilder buf, int x, int y, String font, String text) async { - if (text.isEmpty) return; - final gbk = await _encodeGbkAsync(text); - buf.add(ascii.encode('TEXT $x,$y,"$font",0,1,1,"')); - buf.add(gbk); - buf.add(ascii.encode('"\r\n')); -} - -/// 桌面端把标签以 TSPL TEXT(内置中文点阵字体)+ QR BITMAP 混合方式裸发到热敏机。 -/// TEXT 走 TSS24.BF2/TSS16.BF2(GBK 编码,203 DPI 下比 TrueType 位图清晰数倍)。 -/// QR 仍走 BITMAP(内置 QRCODE 命令扫描不可靠)。 +/// 桌面端把整张标签「光栅化」成单色位图裸发到热敏机(TSPL BITMAP),令热敏输出与 +/// 预览画布 100% 一致——彻底消除内置点阵字(TSS16/24)与预览 TrueType 度量不一致 +/// 导致的字体重叠 / 失真。文字/抬头/页脚走光栅;条码(Code128)与二维码仍走原生 +/// BARCODE / BITMAP 命令保证扫码可靠(光栅化后细条/密码易糊)。 /// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。 /// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。 Future _printFlatLabelThermal({ @@ -452,23 +401,23 @@ Future _printFlatLabelThermal({ debugPrint('[label] thermal printer = $printer'); if (printer == null) return false; - // 版面(320×160 dots,DIRECTION 1):深蓝头/米色脚在单色热敏上分别退化为 - // 「黑底反白店名」「白底黑字页脚」;主体两列:左=文字+条码,右=二维码占整高。 - const headerH = 26; // 头部黑带高 - const bodyTop = headerH; // 26 - const bodyBot = 134; // 主体下沿(页脚之上) + const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸) + const wBytes = (w + 7) >> 3; // 40 bytes/row - // 右列二维码:占主体整高(正方形),右对齐(放大,占满主体高度) - const qrW = 104, qrH = 104; - const qrX = 320 - 8 - qrW; // 208 - const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 28 - const qrWBytes = (qrW + 7) >> 3; // 13 bytes/row - - final codec = await ui.instantiateImageCodec(qrBytes, - targetWidth: qrW, targetHeight: qrH); - final qrImg = (await codec.getNextFrame()).image; - final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba); - final qrRgba = qrBd!.buffer.asUint8List(); + // ── 文字/抬头/页脚整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)───────── + // 画布内部 2× 超采样(640×320),此处 2×2 盒式降采样 + 亮度 128 阈值二值化,边缘更干净。 + final img = await _renderLabelBitmap( + shop: shop, + name: name, + code: code, + subs: subs, + qrBytes: qrBytes, + drawBarcode: false, + drawQr: false, + ); + final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba); + final rgba = bd!.buffer.asUint8List(); + final iw = img.width; // 640 final buf = BytesBuilder(); buf.add(ascii.encode( @@ -476,22 +425,43 @@ Future _printFlatLabelThermal({ 'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n', )); - // ── 深蓝店名头(黑底反白):先画黑字店名,再 REVERSE 整条头部 → 白字黑底 ── - await _addTextCmd(buf, 8, 5, 'TSS16.BF2', shop); - buf.add(ascii.encode('REVERSE 0,0,320,$headerH\r\n')); + // 整幅文字位图(mode 0 覆写整版):深蓝抬头→黑底反白、米色页脚→白底黑字, + // 均由亮度阈值自然还原,无需 REVERSE;bit 0=黑点,默认 0xFF=白。 + buf.add(ascii.encode('BITMAP 0,0,$wBytes,$h,0,')); + for (int y = 0; y < h; y++) { + final row = Uint8List(wBytes)..fillRange(0, wBytes, 0xFF); + for (int x = 0; x < w; x++) { + int lum = 0; + for (int dy = 0; dy < 2; dy++) { + for (int dx = 0; dx < 2; dx++) { + final i = ((y * 2 + dy) * iw + (x * 2 + dx)) * 4; + final a = rgba[i + 3]; + lum += a < 128 + ? 255 + : (0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]) + .round(); + } + } + if (lum ~/ 4 < 128) row[x >> 3] &= ~(0x80 >> (x & 7)); + } + buf.add(row); + } + buf.add(ascii.encode('\r\n')); - // ── 米色页脚(白底黑字):店名 + 尺寸 ── - const footerY = 140; - await _addTextCmd(buf, 8, footerY, 'TSS16.BF2', shop); - const sizeStr = '40×20mm'; - final sizeW = _estTsplWidth(sizeStr, 16); - await _addTextCmd( - buf, (320 - 8 - sizeW).clamp(8, 312), footerY, 'TSS16.BF2', sizeStr); - - // ── 右列二维码 BITMAP ── + // ── 右列二维码(原生 BITMAP,避免光栅降采样糊码):占主体整高、右对齐 ── + const qrW = 104, qrH = 104; + const qrX = 320 - 8 - qrW; // 208 + const bodyTop = 26, bodyBot = 134; + const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 28 + const qrWBytes = (qrW + 7) >> 3; // 13 + final qrCodec = await ui.instantiateImageCodec(qrBytes, + targetWidth: qrW, targetHeight: qrH); + final qrImg = (await qrCodec.getNextFrame()).image; + final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba); + final qrRgba = qrBd!.buffer.asUint8List(); buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,')); for (int yy = 0; yy < qrH; yy++) { - final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); // 默认白 + final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); for (int xx = 0; xx < qrW; xx++) { final i = (yy * qrW + xx) * 4; final a = qrRgba[i + 3]; @@ -504,12 +474,8 @@ Future _printFlatLabelThermal({ } buf.add(ascii.encode('\r\n')); - // 左列几何(与二维码列间距收小到 6) - const leftX = 8, - leftRight = qrX - 6, - leftW = leftRight - leftX; // 8..202, 194 - - // ── 左列底部整幅一维条码(Code128,内容=商品编号),narrow 自适应铺满并居中 ── + // ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),narrow 自适应铺满居中 ── + const leftX = 8, leftRight = qrX - 6, leftW = leftRight - leftX; // 8..202, 194 const kBarH = 28; const kBarY = bodyBot - 4 - kBarH; // 102 if (code.isNotEmpty) { @@ -521,50 +487,10 @@ Future _printFlatLabelThermal({ 'BARCODE $barX,$kBarY,"128",$kBarH,0,0,$narrow,$narrow,"$code"\r\n')); } - // ── 左列文字块:品名+编号 一行居中;度数/规格/日期 平铺一行;整体在条码上方居中 ── - const blockTop = bodyTop, blockBot = kBarY - 4; // 26..98 - final nameFont = name.runes.length <= 7 ? 'TSS24.BF2' : 'TSS16.BF2'; - final nameH = nameFont == 'TSS24.BF2' ? 24 : 16; - const subH = 16, kGap = 8; - final blockH = nameH + kGap + subH; - var nameY = blockTop + ((blockBot - blockTop) - blockH) ~/ 2; - if (nameY < blockTop) nameY = blockTop; - final subY = nameY + nameH + kGap; - - // 品名 + 编号:整体水平居中,编号小字随后,基线对齐 - final nameW = _estTsplWidth(name, nameH); - final codeW = code.isNotEmpty ? _estTsplWidth(code, 16) : 0; - final groupW = nameW + (code.isNotEmpty ? kGap + codeW : 0); - var nameX = leftX + (leftW - groupW) ~/ 2; - if (nameX < leftX) nameX = leftX; - await _addTextCmd(buf, nameX, nameY, nameFont, name); - if (code.isNotEmpty) { - await _addTextCmd( - buf, nameX + nameW + kGap, nameY + (nameH - subH), 'TSS16.BF2', code); - } - - // 三字段平铺一行(space-between,两侧留内边距使外白 > 中白) - const innerPad = 10; // 对齐原型 .lbl-sub padding:0 10px - const innerLeft = leftX + innerPad, innerW = leftW - 2 * innerPad; // 18, 174 - if (subs.length == 1) { - final w0 = _estTsplWidth(subs[0], subH); - await _addTextCmd( - buf, innerLeft + (innerW - w0) ~/ 2, subY, 'TSS16.BF2', subs[0]); - } else if (subs.isNotEmpty) { - final widths = subs.map((s) => _estTsplWidth(s, subH)).toList(); - final sumW = widths.fold(0, (s, x) => s + x); - final gap = (innerW - sumW) / (subs.length - 1); - var cx = innerLeft.toDouble(); - for (int i = 0; i < subs.length; i++) { - await _addTextCmd(buf, cx.round(), subY, 'TSS16.BF2', subs[i]); - cx += widths[i] + gap; - } - } - buf.add(ascii.encode('PRINT 1,1\r\n')); final tspl = buf.toBytes(); - debugPrint('[label] TEXT+BITMAP tspl ${tspl.length}B -> $printer'); + debugPrint('[label] raster BITMAP+QR+BARCODE tspl ${tspl.length}B -> $printer'); await _sendRaw(printer, tspl); return true; } @@ -728,22 +654,15 @@ Future printProductLabelImpl({ ), ), - // ── Footer:米色页脚(店名 + 尺寸)──────────────────────────────────── + // ── Footer:米色页脚(仅尺寸;店名已在抬头,不再重复)───────────────── pw.Container( height: footerH, color: _cream, padding: const pw.EdgeInsets.symmetric(horizontal: 5), child: pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + mainAxisAlignment: pw.MainAxisAlignment.end, crossAxisAlignment: pw.CrossAxisAlignment.center, children: [ - pw.Flexible( - child: pw.Text(shopName, - maxLines: 1, - overflow: pw.TextOverflow.clip, - style: pw.TextStyle( - font: font, fontSize: 3.5, color: _footnote)), - ), pw.Text('40×20mm', style: pw.TextStyle( font: font, fontSize: 3.5, color: _footnote)), diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart index 77bc1ca..dbd6195 100644 --- a/client/lib/core/utils/print_util_web.dart +++ b/client/lib/core/utils/print_util_web.dart @@ -117,7 +117,7 @@ body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; } .qr { flex-shrink: 0; height: 100%; aspect-ratio: 1; } .qr img { width: 100%; height: 100%; object-fit: contain; display: block; } -/* ── Footer:米色页脚(店名 + 尺寸)── */ +/* ── Footer:米色页脚(仅尺寸;店名已在抬头,不再重复)── */ .ftr { background: #f4ecd8; height: 2.4mm; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; @@ -146,7 +146,7 @@ body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; } -
$shopName40×20mm
+
40×20mm