refactor(client): P3 PDF/Web 价签收口为「贴画布位图」+ 抽出单源渲染器

将标签「画布位图」渲染逻辑(renderLabelImage / renderLabelPngBytes + 文字/条码/
字号自适应辅助)抽到平台无关的 label_bitmap.dart,四条路径共用同一套几何:
桌面预览、桌面热敏 TSPL 光栅、桌面 PDF 回退、Web 打印。

- print_util_stub.dart:PDF 回退(printProductLabelImpl)非热敏时不再手搭 pw.* 组件
  树(忽略模板坐标),改贴整幅 renderLabelImage 位图铺满 40×20mm 页;删除仅供旧
  PDF 树使用的 _navy/_cream/_footnote/_ink 常量与 barcode 包导入。
- print_util_web.dart:HTML flex 布局换成单张 <img> 画布 PNG;renderLabelPreviewImpl
  由返回 null 改为走共享渲染器(Web 预览与桌面同源)。
- 效果:同一模板在热敏/PDF/Web 观感 100% 一致。条码/二维码随位图光栅化,扫码风险
  只在 ≥300dpi 办公机路径(热敏 203dpi 仍走原生 TSPL BARCODE/BITMAP,不受影响)。

零回归:label_render_golden 逐像素通过(抽出保持字节等价);flutter analyze 0
error/warning、362 测试全绿、check_ds_code --strict 0 违规。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
This commit is contained in:
wangjia
2026-08-30 14:43:34 +08:00
parent 195b4d22e7
commit ced71bd8a1
3 changed files with 232 additions and 375 deletions
+201
View File
@@ -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` 贴整幅位图 `<img>`)。
///
/// 版式坐标/字号/开关全从 [tpl].computeLayout() 读,确保「预览即实际输出」。
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
Future<ui.Image> 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<Uint8List?> 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;
}
+14 -287
View File
@@ -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<ui.Image> _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<bool> _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<bool> _printFlatLabelThermal({
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List();
final iw = img.width; // 12804× 超采样)
const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致
const int ss = 4; // 超采样倍率,须与 renderLabelImage 的 scale 一致
const int subTotal = ss * ss; // 16 子像素/墨点
// 解析栅格流版式(与画布同源):QR/条码坐标随隐藏字段回流,全字段显示时=模型绝对坐标。
@@ -491,17 +310,11 @@ Future<void> 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<void> 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<void> printProductLabelImpl({
/// [template] 为空时用内置默认版式(行为同重构前)。Web 平台或 qrBytes 为空时抛异常。
Future<Uint8List> renderLabelPreviewImpl(LabelData label,
{LabelTemplate? template}) async {
final img = await _renderLabelBitmap(
final img = await renderLabelImage(
tpl: template ?? LabelTemplate.builtinDefault(),
data: label,
);
+17 -88
View File
@@ -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<void> 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<void> printProductLabelImpl({
shopAddress: shopAddress,
shopPhone: shopPhone,
);
final subs = tpl.textStack.composeSubs(data);
final subLines = subs.map((s) => '<div class="sub">$s</div>').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 = '''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
<style>
/* 标签尺寸:40mm × 20mm,横向(对齐设计原型 devices.html 标签块*/
/* 标签尺寸:40mm × 20mm,横向(整幅贴位图,对齐 renderLabelImage 输出*/
@page { size: 40mm 20mm; margin: 0; }
* { box-sizing: border-box; margin: 0; padding: 0;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
.label {
width: 40mm; height: 20mm;
display: flex; flex-direction: column;
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #111;
}
/* ── Header:深蓝店名头 3.2mm ── */
.hdr {
background: #1f2a3a; color: #f4ecd8;
height: 3.2mm; flex-shrink: 0;
display: flex; align-items: center;
padding: 0 1.2mm; overflow: hidden;
}
.hdr-name {
font-size: 6pt; font-weight: 700; letter-spacing: 0.05em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── 主体:左列(文字+条码) / 右列(二维码占整高) ── */
.middle {
flex: 1; min-height: 0; display: flex; gap: 0.8mm;
padding: 0.7mm 0.9mm; overflow: hidden;
}
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.info {
flex: 1; min-height: 0; display: flex; flex-direction: column;
justify-content: center; align-items: center; gap: 0.6mm; overflow: hidden;
}
.pname {
font-weight: 700; text-align: center; line-height: 1.1; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sub {
font-size: 5pt; color: #333; line-height: 1.15; text-align: center;
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bc { flex-shrink: 0; height: 4.6mm; }
.bc svg { width: 100%; height: 100%; display: block; }
/* 右:二维码占主体整高(正方形)*/
.qr { flex-shrink: 0; height: 100%; aspect-ratio: 1; }
.qr img { width: 100%; height: 100%; object-fit: contain; display: block; }
html, body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
img { width: 40mm; height: 20mm; display: block; object-fit: fill; }
</style>
</head>
<body>
<div class="label">
<div class="hdr">
<span class="hdr-name">$shopName</span>
</div>
<div class="middle">
<div class="left">
<div class="info">
<div class="pname" style="font-size:${nameFontPt}pt">$name</div>
$subLines
</div>
<div class="bc">$barcodeSvg</div>
</div>
<div class="qr">
<img src="data:image/png;base64,$base64Img" />
</div>
</div>
</div>
<img src="data:image/png;base64,$base64Img" />
<script>
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(function() { window.print(); });
} else {
window.onload = function() { window.print(); };
}
window.onload = function() { window.print(); };
</script>
</body>
</html>''';
@@ -298,7 +226,8 @@ Future<void> printStockInOrderImpl(
Future<Uint8List?> renderLabelPreviewImpl(LabelData label,
{LabelTemplate? template}) async =>
null;
renderLabelPngBytes(
tpl: template ?? LabelTemplate.builtinDefault(), data: label);
Future<List<String>> listLabelPrintersImpl() async => [];