diff --git a/client/lib/core/utils/label_bitmap.dart b/client/lib/core/utils/label_bitmap.dart
new file mode 100644
index 0000000..437f127
--- /dev/null
+++ b/client/lib/core/utils/label_bitmap.dart
@@ -0,0 +1,201 @@
+import 'dart:typed_data';
+import 'dart:ui' as ui;
+import 'package:barcode/barcode.dart' as bc;
+import 'package:flutter/foundation.dart' show debugPrint;
+import 'label_data.dart';
+import 'label_template.dart';
+
+/// 标签「画布位图」单源渲染器(平台无关,只依赖 dart:ui + package:barcode)。
+///
+/// 四条打印/预览路径共用同一套几何:
+/// - 桌面预览(`renderLabelPreviewImpl`);
+/// - 桌面热敏 TSPL 光栅(`_printFlatLabelThermal`,barcode/QR 关闭改走原生命令);
+/// - 桌面 PDF 回退(`printProductLabelImpl` 贴整幅位图);
+/// - Web 打印(`print_util_web.dart` 贴整幅位图 `
`)。
+///
+/// 版式坐标/字号/开关全从 [tpl].computeLayout() 读,确保「预览即实际输出」。
+
+/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
+/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
+Future renderLabelImage({
+ required LabelTemplate tpl, // 版式单源:坐标/字号/开关全从此读
+ required LabelData data, // 待打印字段值 + qrBytes
+ bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码
+ bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码
+}) async {
+ final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸)
+ final int h = tpl.paper.logicalH; // 20mm@203 → 160
+ const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐)
+ final int sw = w * scale, sh = h * scale;
+ final recorder = ui.PictureRecorder();
+ final canvas =
+ ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
+ canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
+
+ // 纸面固定配色(对齐设计原型 devices.html 标签块)——预览为彩色设计稿,
+ // 热敏机为单色介质,实际打印时头/脚颜色会退化为黑白(TSPL 路径另行处理)。
+ const cWhite = ui.Color(0xFFFFFFFF); // ds-ignore
+ const cNavy = ui.Color(0xFF1F2A3A); // ds-ignore 深蓝店名头
+ const cCream = ui.Color(0xFFF4ECD8); // ds-ignore 抬头店名字色
+ const cInk = ui.Color(0xFF111111); // ds-ignore 正文
+ const cCode = ui.Color(0xFF333333); // ds-ignore 编号 / 三字段
+
+ canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
+ ui.Paint()..color = cWhite);
+
+ // 解析栅格流版式(隐藏区域空间由邻居回收;全字段显示时逐值=模型存的绝对坐标 → 零回归)。
+ final lay = tpl.computeLayout();
+
+ // ── 深蓝店名头(binding 固定 shopName)──────────────────────────
+ if (lay.headerShow) {
+ canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), lay.headerHeight),
+ ui.Paint()..color = cNavy);
+ _drawText(canvas, data.shopName, lay.headerTextX, lay.headerTextY,
+ lay.headerFontSize, w - 2 * lay.headerTextX,
+ bold: lay.headerBold,
+ textAlign: _uiAlign(lay.headerAlign),
+ color: cCream);
+ }
+
+ // ── 右列二维码(正方形,绝对坐标)─────────────────────────────
+ if (drawQr && lay.qrShow) {
+ try {
+ final codec = await ui.instantiateImageCodec(data.qrBytes!);
+ final qrImg = (await codec.getNextFrame()).image;
+ canvas.drawImageRect(
+ qrImg,
+ ui.Rect.fromLTWH(
+ 0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()),
+ ui.Rect.fromLTWH(lay.qrX, lay.qrY, lay.qrSize, lay.qrSize),
+ ui.Paint());
+ } catch (e) {
+ debugPrint('[label] QR 解码失败: $e');
+ }
+ }
+
+ // ── 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 ──
+ if (drawBarcode && lay.barcodeShow) {
+ _drawBarcode(canvas, data.code, lay.barX, lay.barY, lay.barW, lay.barH);
+ }
+
+ // ── 左列文字块:品名 + 若干副字段行,整体在 [top, bottom] 区间竖直均分 ──
+ final double leftX = lay.textX;
+ final double leftW = lay.textWidth;
+ final double infoTop = lay.textTop;
+ final double infoBot = lay.textBottom;
+ final double infoH = infoBot - infoTop;
+
+ // 逐行装配(text, size, bold, align):品名自适应/固定;副字段行按各自样式。
+ final rows = <(String, double, bool, LabelAlign)>[];
+ if (lay.name.show && data.name.isNotEmpty) {
+ final double nameSize = lay.name.fontSize ??
+ _fitFont(data.name, leftW, 1000, lay.name.bold, lay.name.fontMax)
+ .clamp(lay.name.fontMin, lay.name.fontMax);
+ rows.add((data.name, nameSize, lay.name.bold, lay.name.align));
+ }
+ for (final line in lay.subLines) {
+ final parts = line.bindings
+ .map((b) => resolveBinding(b, data))
+ .where((s) => s.isNotEmpty)
+ .toList();
+ final s = parts.join(' ');
+ if (s.isNotEmpty) rows.add((s, line.fontSize, line.bold, line.align));
+ }
+
+ final double kLineH = lay.textLineHeight; // 行高倍数
+ final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH);
+ final int n = rows.length;
+ final double rawGap = n > 0 ? (infoH - totalTextH) / (n + 1) : 2.0;
+ final double gap = rawGap < 1.0 ? 1.0 : rawGap;
+ double yy = infoTop + gap;
+ for (final r in rows) {
+ _drawText(canvas, r.$1, leftX, yy, r.$2, leftW,
+ bold: r.$3, color: r.$3 ? cInk : cCode, textAlign: _uiAlign(r.$4));
+ yy += r.$2 * kLineH + gap;
+ }
+
+ return recorder.endRecording().toImage(sw, sh); // 返回 4× 图(1280×640)
+}
+
+/// 渲染标签为 PNG 字节(预览弹窗 / Web 打印共用)。QR 未就绪等失败返回 null。
+Future renderLabelPngBytes({
+ required LabelTemplate tpl,
+ required LabelData data,
+}) async {
+ final img = await renderLabelImage(tpl: tpl, data: data);
+ final bd = await img.toByteData(format: ui.ImageByteFormat.png);
+ return bd?.buffer.asUint8List();
+}
+
+/// 在 canvas 上画一行文字(默认黑色,单行不换行,CJK 走系统字体回退)
+void _drawText(
+ ui.Canvas canvas, String s, double x, double y, double size, double maxW,
+ {bool bold = false,
+ ui.TextAlign textAlign = ui.TextAlign.left,
+ ui.Color color = const ui.Color(0xFF000000)}) // ds-ignore: 纸面固定色(默认黑)
+{
+ if (s.isEmpty) return;
+ final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
+ fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊)
+ fontSize: size,
+ fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
+ textAlign: textAlign,
+ maxLines: 1,
+ ellipsis: '',
+ ))
+ ..pushStyle(ui.TextStyle(color: color)) // ds-ignore: 打印画布纸面固定色
+ ..addText(s);
+ final p = pb.build()..layout(ui.ParagraphConstraints(width: maxW));
+ canvas.drawParagraph(p, ui.Offset(x, y));
+}
+
+/// 版式对齐 → 画布 TextAlign。
+ui.TextAlign _uiAlign(LabelAlign a) {
+ switch (a) {
+ case LabelAlign.left:
+ return ui.TextAlign.left;
+ case LabelAlign.center:
+ return ui.TextAlign.center;
+ case LabelAlign.right:
+ return ui.TextAlign.right;
+ }
+}
+
+/// 在 canvas 上把 [data] 画成 Code128 一维条码(黑条,铺满 x,y,w,h 区域,不带人读字符)。
+/// 扫码枪扫 1D 条码;条码内容 = 商品编号,与出库扫码一致。
+void _drawBarcode(
+ ui.Canvas canvas, String data, double x, double y, double w, double h) {
+ if (data.isEmpty) return;
+ final paint = ui.Paint()
+ ..color = const ui.Color(0xFF000000) // ds-ignore: 打印画布纸面固定色
+ ..style = ui.PaintingStyle.fill;
+ for (final e in bc.Barcode.code128().make(data, width: w, height: h)) {
+ if (e is bc.BarcodeBar && e.black) {
+ canvas.drawRect(
+ ui.Rect.fromLTWH(x + e.left, y + e.top, e.width, e.height), paint);
+ }
+ }
+}
+
+/// 测量单行文本在指定字号下的宽度
+double _measureWidth(String s, double size, bool bold) {
+ final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
+ fontFamily: 'NotoSansSC',
+ fontSize: size,
+ fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
+ maxLines: 1,
+ ))
+ ..addText(s);
+ final p = pb.build()..layout(const ui.ParagraphConstraints(width: 100000));
+ return p.maxIntrinsicWidth;
+}
+
+/// 自适应字号:在 maxW × maxH 范围内尽量大(受宽度、行高、上限三者约束)
+double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
+ if (s.isEmpty) return 0;
+ final w100 = _measureWidth(s, 100, bold);
+ final byW = w100 > 0 ? maxW / w100 * 100 : cap;
+ var size = byW < maxH ? byW : maxH;
+ if (size > cap) size = cap;
+ return size;
+}
diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart
index 0dd05b9..07e1495 100644
--- a/client/lib/core/utils/print_util_stub.dart
+++ b/client/lib/core/utils/print_util_stub.dart
@@ -3,7 +3,6 @@ import 'dart:convert' show ascii;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:ffi';
-import 'package:barcode/barcode.dart' as bc;
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart'
show
@@ -24,6 +23,7 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
+import 'label_bitmap.dart';
import 'label_data.dart';
import 'label_template.dart';
import 'order_print_meta.dart';
@@ -46,12 +46,6 @@ String _fileTs() {
return '${n.year}${p(n.month)}${p(n.day)}_${p(n.hour)}${p(n.minute)}${p(n.second)}';
}
-// ── 设计色彩 token ──────────────────────────────────────────────────────────
-const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip
-const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 抬头反白店名
-const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 编号 / 三字段小字
-const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text
-
// ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)────────
// 得力 DL-888B 等热敏机走系统打印(CUPS 通用驱动)会多走纸/空白,必须发原生 TSPL。
// 直接画位图而非走 PDF 光栅化:避免二维码渲染丢失、SIZE 用整数避免多走纸。
@@ -155,185 +149,10 @@ bool _winRawPrint(String printerName, Uint8List data) {
}
}
-/// 在 canvas 上画一行文字(默认黑色,单行不换行,CJK 走系统字体回退)
-void _drawText(
- ui.Canvas canvas, String s, double x, double y, double size, double maxW,
- {bool bold = false,
- ui.TextAlign textAlign = ui.TextAlign.left,
- ui.Color color = const ui.Color(0xFF000000)}) // ds-ignore: 纸面固定色(默认黑)
-{
- if (s.isEmpty) return;
- final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
- fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊)
- fontSize: size,
- fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
- textAlign: textAlign,
- maxLines: 1,
- ellipsis: '',
- ))
- ..pushStyle(ui.TextStyle(color: color)) // ds-ignore: 打印画布纸面固定色
- ..addText(s);
- final p = pb.build()..layout(ui.ParagraphConstraints(width: maxW));
- canvas.drawParagraph(p, ui.Offset(x, y));
-}
-
/// 毫米数格式化为 TSPL 数值(整数不带小数点:40.0 → "40")。
String _tsplMm(double v) =>
v == v.roundToDouble() ? v.round().toString() : v.toString();
-/// 版式对齐 → 画布 TextAlign。
-ui.TextAlign _uiAlign(LabelAlign a) {
- switch (a) {
- case LabelAlign.left:
- return ui.TextAlign.left;
- case LabelAlign.center:
- return ui.TextAlign.center;
- case LabelAlign.right:
- return ui.TextAlign.right;
- }
-}
-
-/// 在 canvas 上把 [data] 画成 Code128 一维条码(黑条,铺满 x,y,w,h 区域,不带人读字符)。
-/// 扫码枪扫 1D 条码;条码内容 = 商品编号,与出库扫码一致。
-void _drawBarcode(
- ui.Canvas canvas, String data, double x, double y, double w, double h) {
- if (data.isEmpty) return;
- final paint = ui.Paint()
- ..color = const ui.Color(0xFF000000) // ds-ignore: 打印画布纸面固定色
- ..style = ui.PaintingStyle.fill;
- for (final e in bc.Barcode.code128().make(data, width: w, height: h)) {
- if (e is bc.BarcodeBar && e.black) {
- canvas.drawRect(
- ui.Rect.fromLTWH(x + e.left, y + e.top, e.width, e.height), paint);
- }
- }
-}
-
-/// 测量单行文本在指定字号下的宽度
-double _measureWidth(String s, double size, bool bold) {
- final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
- fontFamily: 'NotoSansSC',
- fontSize: size,
- fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
- maxLines: 1,
- ))
- ..addText(s);
- final p = pb.build()..layout(const ui.ParagraphConstraints(width: 100000));
- return p.maxIntrinsicWidth;
-}
-
-/// 自适应字号:在 maxW × maxH 范围内尽量大(受宽度、行高、上限三者约束)
-double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
- if (s.isEmpty) return 0;
- final w100 = _measureWidth(s, 100, bold);
- final byW = w100 > 0 ? maxW / w100 * 100 : cap;
- var size = byW < maxH ? byW : maxH;
- if (size > cap) size = cap;
- return size;
-}
-
-/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
-/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
-/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
-Future _renderLabelBitmap({
- required LabelTemplate tpl, // 版式单源:坐标/字号/开关全从此读
- required LabelData data, // 待打印字段值 + qrBytes
- bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码
- bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码
-}) async {
- final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸)
- final int h = tpl.paper.logicalH; // 20mm@203 → 160
- const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐)
- final int sw = w * scale, sh = h * scale;
- final recorder = ui.PictureRecorder();
- final canvas =
- ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
- canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
-
- // 纸面固定配色(对齐设计原型 devices.html 标签块)——预览为彩色设计稿,
- // 热敏机为单色介质,实际打印时头/脚颜色会退化为黑白(TSPL 路径另行处理)。
- const cWhite = ui.Color(0xFFFFFFFF); // ds-ignore
- const cNavy = ui.Color(0xFF1F2A3A); // ds-ignore 深蓝店名头
- const cCream = ui.Color(0xFFF4ECD8); // ds-ignore 抬头店名字色
- const cInk = ui.Color(0xFF111111); // ds-ignore 正文
- const cCode = ui.Color(0xFF333333); // ds-ignore 编号 / 三字段
-
- canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
- ui.Paint()..color = cWhite);
-
- // 解析栅格流版式(隐藏区域空间由邻居回收;全字段显示时逐值=模型存的绝对坐标 → 零回归)。
- final lay = tpl.computeLayout();
-
- // ── 深蓝店名头(binding 固定 shopName)──────────────────────────
- if (lay.headerShow) {
- canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), lay.headerHeight),
- ui.Paint()..color = cNavy);
- _drawText(canvas, data.shopName, lay.headerTextX, lay.headerTextY,
- lay.headerFontSize, w - 2 * lay.headerTextX,
- bold: lay.headerBold, textAlign: _uiAlign(lay.headerAlign), color: cCream);
- }
-
- // ── 右列二维码(正方形,绝对坐标)─────────────────────────────
- if (drawQr && lay.qrShow) {
- try {
- final codec = await ui.instantiateImageCodec(data.qrBytes!);
- final qrImg = (await codec.getNextFrame()).image;
- canvas.drawImageRect(
- qrImg,
- ui.Rect.fromLTWH(
- 0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()),
- ui.Rect.fromLTWH(lay.qrX, lay.qrY, lay.qrSize, lay.qrSize),
- ui.Paint());
- } catch (e) {
- debugPrint('[label] QR 解码失败: $e');
- }
- }
-
- // ── 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 ──
- if (drawBarcode && lay.barcodeShow) {
- _drawBarcode(
- canvas, data.code, lay.barX, lay.barY, lay.barW, lay.barH);
- }
-
- // ── 左列文字块:品名 + 若干副字段行,整体在 [top, bottom] 区间竖直均分 ──
- final double leftX = lay.textX;
- final double leftW = lay.textWidth;
- final double infoTop = lay.textTop;
- final double infoBot = lay.textBottom;
- final double infoH = infoBot - infoTop;
-
- // 逐行装配(text, size, bold, align):品名自适应/固定;副字段行按各自样式。
- final rows = <(String, double, bool, LabelAlign)>[];
- if (lay.name.show && data.name.isNotEmpty) {
- final double nameSize = lay.name.fontSize ??
- _fitFont(data.name, leftW, 1000, lay.name.bold, lay.name.fontMax)
- .clamp(lay.name.fontMin, lay.name.fontMax);
- rows.add((data.name, nameSize, lay.name.bold, lay.name.align));
- }
- for (final line in lay.subLines) {
- final parts = line.bindings
- .map((b) => resolveBinding(b, data))
- .where((s) => s.isNotEmpty)
- .toList();
- final s = parts.join(' ');
- if (s.isNotEmpty) rows.add((s, line.fontSize, line.bold, line.align));
- }
-
- final double kLineH = lay.textLineHeight; // 行高倍数
- final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH);
- final int n = rows.length;
- final double rawGap = n > 0 ? (infoH - totalTextH) / (n + 1) : 2.0;
- final double gap = rawGap < 1.0 ? 1.0 : rawGap;
- double yy = infoTop + gap;
- for (final r in rows) {
- _drawText(canvas, r.$1, leftX, yy, r.$2, leftW,
- bold: r.$3, color: r.$3 ? cInk : cCode, textAlign: _uiAlign(r.$4));
- yy += r.$2 * kLineH + gap;
- }
-
- return recorder.endRecording().toImage(sw, sh); // 返回 4× 图(1280×640)
-}
-
/// 桌面端把整张标签「光栅化」成单色位图裸发到热敏机(TSPL BITMAP),令热敏输出与
/// 预览画布 100% 一致——彻底消除内置点阵字(TSS16/24)与预览 TrueType 度量不一致
/// 导致的字体重叠 / 失真。文字/抬头走光栅;条码(Code128)与二维码仍走原生
@@ -359,7 +178,7 @@ Future _printFlatLabelThermal({
// 画布内部 4× 超采样(1280×640),此处按覆盖率投票降采样二值化:
// 每个墨点看其 4×4=16 个子像素中"是墨"的占比,≥40% 即打黑。
// 替代旧的"盒式平均+128 阈值"——后者先把边缘抹成灰再一刀切,正是糊字的根因。
- final img = await _renderLabelBitmap(
+ final img = await renderLabelImage(
tpl: tpl,
data: data,
drawBarcode: false,
@@ -368,7 +187,7 @@ Future _printFlatLabelThermal({
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List();
final iw = img.width; // 1280(4× 超采样)
- const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致
+ const int ss = 4; // 超采样倍率,须与 renderLabelImage 的 scale 一致
const int subTotal = ss * ss; // 16 子像素/墨点
// 解析栅格流版式(与画布同源):QR/条码坐标随隐藏字段回流,全字段显示时=模型绝对坐标。
@@ -491,17 +310,11 @@ Future printProductLabelImpl({
shopAddress: shopAddress,
shopPhone: shopPhone,
);
- final font = await _loadFont();
final doc = pw.Document();
- final qrImage = pw.MemoryImage(qrBytes);
// Label: 40 × 20 mm(匹配实际标签纸尺寸)
const labelW = 40.0 * PdfPageFormat.mm;
const labelH = 20.0 * PdfPageFormat.mm;
- const headerH = labelH * 0.20;
-
- // 副字段行由 template 装配(口径与预览、TSPL 一致)
- final subs = tpl.textStack.composeSubs(data);
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
if (await _printFlatLabelThermal(
@@ -509,106 +322,20 @@ Future printProductLabelImpl({
return;
}
- // 商品名字号:随字数自适应(与预览一致,短名更大)
- final double nameFs =
- name.runes.length > 10 ? 6.5 : (name.runes.length > 7 ? 7.5 : 9.0);
+ // ── P3:非热敏(PDF / 办公打印机)整幅贴画布位图 ───────────────────────────
+ // 收口为「贴画布位图」:用与预览、热敏光栅同一套 _renderLabelBitmap(版式坐标 /
+ // 字号 / 开关单源),把整张标签渲染成一张位图铺满 40×20mm 页——令 PDF 观感与
+ // 预览、热敏 100% 一致;条码 / 二维码随位图光栅化(≥300dpi 办公机可扫,热敏
+ // 203dpi 路径不受影响,仍走上面原生 TSPL BARCODE / BITMAP)。
+ final labelImg = await renderLabelImage(tpl: tpl, data: data);
+ final labelPngBd =
+ await labelImg.toByteData(format: ui.ImageByteFormat.png);
+ final labelImage = pw.MemoryImage(labelPngBd!.buffer.asUint8List());
doc.addPage(pw.Page(
pageFormat: const PdfPageFormat(labelW, labelH),
margin: pw.EdgeInsets.zero,
- build: (_) => pw.Column(
- crossAxisAlignment: pw.CrossAxisAlignment.stretch,
- children: [
- // ── Header:深蓝店名头 ────────────────────────────────────────────────
- pw.Container(
- height: headerH,
- color: _navy,
- padding: const pw.EdgeInsets.symmetric(horizontal: 5),
- alignment: pw.Alignment.centerLeft,
- child: pw.Text(shopName,
- maxLines: 1,
- overflow: pw.TextOverflow.clip,
- style: pw.TextStyle(
- font: font,
- fontSize: 6,
- fontWeight: pw.FontWeight.bold,
- color: _cream,
- letterSpacing: 0.5,
- )),
- ),
-
- // ── Body:左列(文字+条码) / 右列(二维码占整高) ─────────────────────────
- pw.Expanded(
- child: pw.Container(
- color: PdfColors.white,
- padding: const pw.EdgeInsets.fromLTRB(4, 3, 4, 3),
- child: pw.Row(
- crossAxisAlignment: pw.CrossAxisAlignment.stretch,
- children: [
- // 左列
- pw.Expanded(
- child: pw.Column(
- crossAxisAlignment: pw.CrossAxisAlignment.stretch,
- children: [
- // 文字块:品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体居中。
- // 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。
- pw.Expanded(
- child: pw.Column(
- mainAxisAlignment: pw.MainAxisAlignment.center,
- crossAxisAlignment: pw.CrossAxisAlignment.center,
- children: [
- pw.Text(name,
- textAlign: pw.TextAlign.center,
- maxLines: 2,
- overflow: pw.TextOverflow.clip,
- style: pw.TextStyle(
- font: font,
- fontSize: nameFs,
- fontWeight: pw.FontWeight.bold,
- color: _ink,
- letterSpacing: 0.3)),
- for (final s in subs) ...[
- pw.SizedBox(height: 2),
- pw.Text(s,
- textAlign: pw.TextAlign.center,
- maxLines: 1,
- overflow: pw.TextOverflow.clip,
- style: pw.TextStyle(
- font: font,
- fontSize: 5,
- color: _footnote)),
- ],
- ],
- ),
- ),
- // 左列底部整幅一维条码(Code128,内容=商品编号),供扫码枪
- if (code.isNotEmpty) ...[
- pw.SizedBox(height: 3),
- pw.SizedBox(
- height: 13,
- child: pw.BarcodeWidget(
- barcode: pw.Barcode.code128(),
- data: code,
- drawText: false,
- color: _ink,
- ),
- ),
- ],
- ],
- ),
- ),
- pw.SizedBox(width: 4),
- // 右列:二维码占主体整高(正方形)
- pw.AspectRatio(
- aspectRatio: 1,
- child: pw.Image(qrImage, fit: pw.BoxFit.contain),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
+ build: (_) => pw.Image(labelImage, fit: pw.BoxFit.fill),
));
// 预生成 PDF 字节,避免 macOS NSPrintPanel 打开后再调 doc.save() 导致主线程卡住。
@@ -631,7 +358,7 @@ Future printProductLabelImpl({
/// [template] 为空时用内置默认版式(行为同重构前)。Web 平台或 qrBytes 为空时抛异常。
Future renderLabelPreviewImpl(LabelData label,
{LabelTemplate? template}) async {
- final img = await _renderLabelBitmap(
+ final img = await renderLabelImage(
tpl: template ?? LabelTemplate.builtinDefault(),
data: label,
);
diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart
index 2518e12..a0b04e4 100644
--- a/client/lib/core/utils/print_util_web.dart
+++ b/client/lib/core/utils/print_util_web.dart
@@ -1,11 +1,11 @@
import 'dart:convert';
import 'dart:js_interop';
import 'dart:typed_data';
-import 'package:barcode/barcode.dart' as bc;
import 'package:web/web.dart' as web;
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
+import 'label_bitmap.dart';
import 'label_data.dart';
import 'label_template.dart';
import 'order_print_meta.dart';
@@ -34,9 +34,7 @@ Future printProductLabelImpl({
String? printerName,
LabelTemplate? template,
}) async {
- final base64Img = base64Encode(qrBytes);
-
- // 副字段行由 template 装配(口径与预览、PDF、TSPL 一致;null → 内置默认版式)。
+ // 版式单源(null → 内置默认版式,口径同桌面预览/PDF/TSPL)。
final tpl = template ?? LabelTemplate.builtinDefault();
final data = LabelData(
qrBytes: qrBytes,
@@ -51,103 +49,33 @@ Future printProductLabelImpl({
shopAddress: shopAddress,
shopPhone: shopPhone,
);
- final subs = tpl.textStack.composeSubs(data);
- final subLines = subs.map((s) => '$s
').join();
- // 商品名过长时缩小字号
- final nameLen = name.runes.length;
- final nameFontPt = nameLen > 10 ? 7.0 : (nameLen > 7 ? 8.0 : 9.0);
- // 一维条码(Code128,内容=商品编号):矢量 SVG 内联,扫码枪扫码出库。
- final barcodeSvg = code.isNotEmpty
- ? bc.Barcode.code128()
- .toSvg(code, width: 200, height: 46, drawText: false)
- : '';
+ // P3:Web 打印收口为「贴画布位图」——与桌面预览/热敏/PDF 共用同一套 renderLabelImage
+ // 把整张标签渲染成 PNG(版式坐标/字号/开关单源),铺满 40×20mm 页;令四端观感
+ // 100% 一致(条码/二维码随位图光栅化,浏览器→办公打印机 ≥300dpi 可扫)。
+ final png = await renderLabelPngBytes(tpl: tpl, data: data);
+ if (png == null) {
+ throw Exception('标签渲染失败:二维码未就绪,请稍后重试');
+ }
+ final base64Img = base64Encode(png);
final html = '''
-
-
-
-
-
- $shopName
-
-
-
-
-
-

-
-
-
-
+
''';
@@ -298,7 +226,8 @@ Future printStockInOrderImpl(
Future renderLabelPreviewImpl(LabelData label,
{LabelTemplate? template}) async =>
- null;
+ renderLabelPngBytes(
+ tpl: template ?? LabelTemplate.builtinDefault(), data: label);
Future> listLabelPrintersImpl() async => [];