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 -
- -
-
-
-
$name
- $subLines -
-
$barcodeSvg
-
-
- -
-
- -
+ '''; @@ -298,7 +226,8 @@ Future printStockInOrderImpl( Future renderLabelPreviewImpl(LabelData label, {LabelTemplate? template}) async => - null; + renderLabelPngBytes( + tpl: template ?? LabelTemplate.builtinDefault(), data: label); Future> listLabelPrintersImpl() async => [];