refactor(client): 抽声明式 LabelTemplate 单源,价签渲染改读模型(P0)

价签版式原 100% 硬编码在四条渲染路径。引入 label_template.dart
声明式模型(纸张/打印/抬头/文字栈/QR/条码),builtinDefault() 逐值冻结
重构前常量;画布渲染器 _renderLabelBitmap 与热敏 _printFlatLabelThermal
改为纯读模型,坐标统一到画布权威。facade/web stub 加 template 可选参
(null→默认,现有调用点行为不变)。

新增 label_render_golden 零回归基准:默认模板渲染与重构前逐像素零 diff。
附评审通过的设计文档 label-template-editor.html(5 项决策)。

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-29 00:00:13 +08:00
parent df242dd703
commit d67295234e
8 changed files with 965 additions and 159 deletions
+507
View File
@@ -0,0 +1,507 @@
import 'label_data.dart';
/// 价签版式声明式模型(单一真源)。
///
/// 设计铁律(2026-08-28 已评审通过):
/// - **画布 = 唯一几何权威**。渲染器(`_renderLabelBitmap`)是「纯绝对坐标画笔」,
/// header/textStack/qr/barcode 的坐标、字号、开关全从本模型读;
/// 预览与热敏文字光栅共用同一套画布,所见即所得。
/// - 热敏保留原生 BARCODE + 独立位图 QR,但**坐标同样由本模型驱动**(决策②),
/// 与画布同源(不再各自硬编码)。
/// - 编辑器负责「算坐标」(移动 QR 时联动重算左列宽度等),渲染器只读绝对值。
/// - `builtinDefault()` 把重构前 `print_util_stub.dart` 的硬编码常量逐值冻结为绝对值,
/// 保证默认模板渲染与重构前**逐像素一致**(由 `label_render_golden_test.dart` 守闸)。
///
/// MVP 边界:字体只做字号 + 粗体(不换字族、不增包,决策④);颜色不暴露(热敏单色,决策⑤)。
/// 存储:整棵树 toJson 存 `shop.custom_fields.label_templates`List+ `label_template_active`id)。
/// 文本水平对齐(MVP:左/中/右;渲染器映射到 `ui.TextAlign`)。
enum LabelAlign { left, center, right }
/// 可绑定到标签字段的商品/门店数据源。
enum LabelBinding {
shopName,
name,
code,
series,
spec,
productionDate,
batchNo,
remark,
shopAddress,
shopPhone,
}
LabelAlign _alignFrom(String? s) =>
LabelAlign.values.firstWhere((e) => e.name == s,
orElse: () => LabelAlign.center);
LabelBinding? _bindingFrom(String? s) {
for (final e in LabelBinding.values) {
if (e.name == s) return e;
}
return null;
}
/// 把绑定解析为其在 [d] 上的字符串值(空字段返回空串;日期截断到 10 字符)。
String resolveBinding(LabelBinding b, LabelData d) {
switch (b) {
case LabelBinding.shopName:
return d.shopName;
case LabelBinding.name:
return d.name;
case LabelBinding.code:
return d.code;
case LabelBinding.series:
return d.series ?? '';
case LabelBinding.spec:
return d.spec ?? '';
case LabelBinding.productionDate:
final p = d.productionDate ?? '';
return p.length > 10 ? p.substring(0, 10) : p;
case LabelBinding.batchNo:
return d.batchNo ?? '';
case LabelBinding.remark:
return d.remark ?? '';
case LabelBinding.shopAddress:
return d.shopAddress;
case LabelBinding.shopPhone:
return d.shopPhone;
}
}
/// 纸张与打印分辨率。逻辑像素 = mm / 25.4 * dpi(四舍五入)。
class LabelPaper {
double widthMm;
double heightMm;
int dpi;
LabelPaper({this.widthMm = 40, this.heightMm = 20, this.dpi = 203});
int get logicalW => (widthMm / 25.4 * dpi).round(); // 40mm@203 → 320
int get logicalH => (heightMm / 25.4 * dpi).round(); // 20mm@203 → 160
Map<String, dynamic> toJson() =>
{'widthMm': widthMm, 'heightMm': heightMm, 'dpi': dpi};
factory LabelPaper.fromJson(Map<String, dynamic> j) => LabelPaper(
widthMm: (j['widthMm'] as num?)?.toDouble() ?? 40,
heightMm: (j['heightMm'] as num?)?.toDouble() ?? 20,
dpi: (j['dpi'] as num?)?.toInt() ?? 203,
);
LabelPaper copy() => LabelPaper(widthMm: widthMm, heightMm: heightMm, dpi: dpi);
}
/// 热敏 TSPL 打印参数(高级配置区暴露)。
class LabelPrint {
int density;
int speed;
int direction;
int copies;
double gapMm;
LabelPrint({
this.density = 10,
this.speed = 2,
this.direction = 1,
this.copies = 1,
this.gapMm = 2,
});
Map<String, dynamic> toJson() => {
'density': density,
'speed': speed,
'direction': direction,
'copies': copies,
'gapMm': gapMm,
};
factory LabelPrint.fromJson(Map<String, dynamic> j) => LabelPrint(
density: (j['density'] as num?)?.toInt() ?? 10,
speed: (j['speed'] as num?)?.toInt() ?? 2,
direction: (j['direction'] as num?)?.toInt() ?? 1,
copies: (j['copies'] as num?)?.toInt() ?? 1,
gapMm: (j['gapMm'] as num?)?.toDouble() ?? 2,
);
LabelPrint copy() => LabelPrint(
density: density,
speed: speed,
direction: direction,
copies: copies,
gapMm: gapMm);
}
/// 抬头栏:深蓝底反白店名(binding 固定为 shopName)。
class LabelHeader {
bool show;
double height; // 逻辑像素
double textX;
double textY;
double fontSize;
bool bold;
LabelAlign align;
LabelHeader({
this.show = true,
this.height = 24,
this.textX = 10,
this.textY = 4,
this.fontSize = 14,
this.bold = true,
this.align = LabelAlign.left,
});
Map<String, dynamic> toJson() => {
'show': show,
'height': height,
'textX': textX,
'textY': textY,
'fontSize': fontSize,
'bold': bold,
'align': align.name,
};
factory LabelHeader.fromJson(Map<String, dynamic> j) => LabelHeader(
show: j['show'] as bool? ?? true,
height: (j['height'] as num?)?.toDouble() ?? 24,
textX: (j['textX'] as num?)?.toDouble() ?? 10,
textY: (j['textY'] as num?)?.toDouble() ?? 4,
fontSize: (j['fontSize'] as num?)?.toDouble() ?? 14,
bold: j['bold'] as bool? ?? true,
align: _alignFrom(j['align'] as String?),
);
LabelHeader copy() => LabelHeader(
show: show,
height: height,
textX: textX,
textY: textY,
fontSize: fontSize,
bold: bold,
align: align);
}
/// 大号自适应品名字段。fontSize == null → 在 [fontMin, fontMax] 内自适应放大。
class LabelNameField {
bool show;
double? fontSize; // null = 自适应
double fontMin;
double fontMax;
bool bold;
LabelAlign align;
LabelNameField({
this.show = true,
this.fontSize,
this.fontMin = 12,
this.fontMax = 18,
this.bold = true,
this.align = LabelAlign.center,
});
Map<String, dynamic> toJson() => {
'show': show,
'fontSize': fontSize,
'fontMin': fontMin,
'fontMax': fontMax,
'bold': bold,
'align': align.name,
};
factory LabelNameField.fromJson(Map<String, dynamic> j) => LabelNameField(
show: j['show'] as bool? ?? true,
fontSize: (j['fontSize'] as num?)?.toDouble(),
fontMin: (j['fontMin'] as num?)?.toDouble() ?? 12,
fontMax: (j['fontMax'] as num?)?.toDouble() ?? 18,
bold: j['bold'] as bool? ?? true,
align: _alignFrom(j['align'] as String?),
);
LabelNameField copy() => LabelNameField(
show: show,
fontSize: fontSize,
fontMin: fontMin,
fontMax: fontMax,
bold: bold,
align: align);
}
/// 副字段行:一行内若干绑定按 4 空格拼接(任一为空则跳过;整行为空则该行消失)。
/// 每行是「一个字段」的最小单元时即可实现「逐字段独立字号」。
class LabelSubLine {
List<LabelBinding> bindings;
double fontSize;
bool bold;
LabelAlign align;
LabelSubLine({
required this.bindings,
this.fontSize = 14,
this.bold = false,
this.align = LabelAlign.center,
});
Map<String, dynamic> toJson() => {
'bindings': bindings.map((b) => b.name).toList(),
'fontSize': fontSize,
'bold': bold,
'align': align.name,
};
factory LabelSubLine.fromJson(Map<String, dynamic> j) => LabelSubLine(
bindings: ((j['bindings'] as List?) ?? const [])
.map((s) => _bindingFrom(s as String?))
.whereType<LabelBinding>()
.toList(),
fontSize: (j['fontSize'] as num?)?.toDouble() ?? 14,
bold: j['bold'] as bool? ?? false,
align: _alignFrom(j['align'] as String?),
);
LabelSubLine copy() => LabelSubLine(
bindings: List.of(bindings),
fontSize: fontSize,
bold: bold,
align: align);
}
/// 左列文字区域:品名 + 若干副字段行,整体在 [top, bottom] 竖直区间内均分。
class LabelTextStack {
double x;
double width;
double top;
double bottom;
double lineHeight;
LabelNameField name;
List<LabelSubLine> subLines;
LabelTextStack({
this.x = 8,
this.width = 164,
this.top = 30,
this.bottom = 110,
this.lineHeight = 1.3,
required this.name,
required this.subLines,
});
Map<String, dynamic> toJson() => {
'x': x,
'width': width,
'top': top,
'bottom': bottom,
'lineHeight': lineHeight,
'name': name.toJson(),
'subLines': subLines.map((l) => l.toJson()).toList(),
};
factory LabelTextStack.fromJson(Map<String, dynamic> j) => LabelTextStack(
x: (j['x'] as num?)?.toDouble() ?? 8,
width: (j['width'] as num?)?.toDouble() ?? 164,
top: (j['top'] as num?)?.toDouble() ?? 30,
bottom: (j['bottom'] as num?)?.toDouble() ?? 110,
lineHeight: (j['lineHeight'] as num?)?.toDouble() ?? 1.3,
name: j['name'] is Map
? LabelNameField.fromJson(
(j['name'] as Map).cast<String, dynamic>())
: LabelNameField(),
subLines: ((j['subLines'] as List?) ?? const [])
.map((m) =>
LabelSubLine.fromJson((m as Map).cast<String, dynamic>()))
.toList(),
);
LabelTextStack copy() => LabelTextStack(
x: x,
width: width,
top: top,
bottom: bottom,
lineHeight: lineHeight,
name: name.copy(),
subLines: subLines.map((l) => l.copy()).toList(),
);
/// 组装非空副字段行(复刻旧 `_labelSubFields` 语义)。
List<String> composeSubs(LabelData d) {
final out = <String>[];
for (final line in subLines) {
final parts = line.bindings
.map((b) => resolveBinding(b, d))
.where((s) => s.isNotEmpty)
.toList();
final s = parts.join(' ');
if (s.isNotEmpty) out.add(s);
}
return out;
}
}
/// 二维码区域(正方形,绝对坐标)。
class LabelQr {
bool show;
double x;
double y;
double size;
LabelQr({this.show = true, this.x = 178, this.y = 25, this.size = 134});
Map<String, dynamic> toJson() =>
{'show': show, 'x': x, 'y': y, 'size': size};
factory LabelQr.fromJson(Map<String, dynamic> j) => LabelQr(
show: j['show'] as bool? ?? true,
x: (j['x'] as num?)?.toDouble() ?? 178,
y: (j['y'] as num?)?.toDouble() ?? 25,
size: (j['size'] as num?)?.toDouble() ?? 134,
);
LabelQr copy() => LabelQr(show: show, x: x, y: y, size: size);
}
/// 一维条码区域(Code128,内容 = 商品编号,绝对坐标)。
class LabelBarcode {
bool show;
double x;
double y;
double width;
double height;
LabelBarcode(
{this.show = true,
this.x = 8,
this.y = 114,
this.width = 164,
this.height = 40});
Map<String, dynamic> toJson() =>
{'show': show, 'x': x, 'y': y, 'width': width, 'height': height};
factory LabelBarcode.fromJson(Map<String, dynamic> j) => LabelBarcode(
show: j['show'] as bool? ?? true,
x: (j['x'] as num?)?.toDouble() ?? 8,
y: (j['y'] as num?)?.toDouble() ?? 114,
width: (j['width'] as num?)?.toDouble() ?? 164,
height: (j['height'] as num?)?.toDouble() ?? 40,
);
LabelBarcode copy() =>
LabelBarcode(show: show, x: x, y: y, width: width, height: height);
}
/// 一套完整的价签版式。
class LabelTemplate {
final String id;
String name;
LabelPaper paper;
LabelPrint print;
LabelHeader header;
LabelTextStack textStack;
LabelQr qr;
LabelBarcode barcode;
LabelTemplate({
required this.id,
required this.name,
required this.paper,
required this.print,
required this.header,
required this.textStack,
required this.qr,
required this.barcode,
});
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'paper': paper.toJson(),
'print': print.toJson(),
'header': header.toJson(),
'textStack': textStack.toJson(),
'qr': qr.toJson(),
'barcode': barcode.toJson(),
};
factory LabelTemplate.fromJson(Map<String, dynamic> j) {
final def = builtinDefault();
Map<String, dynamic>? sub(String k) =>
j[k] is Map ? (j[k] as Map).cast<String, dynamic>() : null;
return LabelTemplate(
id: (j['id'] as String?)?.isNotEmpty == true
? j['id'] as String
: def.id,
name: (j['name'] as String?)?.isNotEmpty == true
? j['name'] as String
: def.name,
paper: sub('paper') != null ? LabelPaper.fromJson(sub('paper')!) : def.paper,
print: sub('print') != null ? LabelPrint.fromJson(sub('print')!) : def.print,
header:
sub('header') != null ? LabelHeader.fromJson(sub('header')!) : def.header,
textStack: sub('textStack') != null
? LabelTextStack.fromJson(sub('textStack')!)
: def.textStack,
qr: sub('qr') != null ? LabelQr.fromJson(sub('qr')!) : def.qr,
barcode: sub('barcode') != null
? LabelBarcode.fromJson(sub('barcode')!)
: def.barcode,
);
}
LabelTemplate copyWith({String? id, String? name}) => LabelTemplate(
id: id ?? this.id,
name: name ?? this.name,
paper: paper.copy(),
print: print.copy(),
header: header.copy(),
textStack: textStack.copy(),
qr: qr.copy(),
barcode: barcode.copy(),
);
/// 内置默认模板:逐值冻结重构前 `print_util_stub.dart` 的画布常量。
/// 改任一常量前先确认 `label_render_golden_test.dart` 仍零 diff。
static LabelTemplate builtinDefault() => LabelTemplate(
id: 'builtin-default',
name: '默认版式',
paper: LabelPaper(widthMm: 40, heightMm: 20, dpi: 203),
print: LabelPrint(density: 10, speed: 2, direction: 1, copies: 1, gapMm: 2),
header: LabelHeader(
show: true,
height: 24,
textX: 10,
textY: 4,
fontSize: 14,
bold: true,
align: LabelAlign.left),
textStack: LabelTextStack(
x: 8,
width: 164,
top: 30,
bottom: 110,
lineHeight: 1.3,
name: LabelNameField(
show: true,
fontSize: null,
fontMin: 12,
fontMax: 18,
bold: true,
align: LabelAlign.center),
subLines: [
LabelSubLine(
bindings: [LabelBinding.code, LabelBinding.series],
fontSize: 14,
bold: false,
align: LabelAlign.center),
LabelSubLine(
bindings: [LabelBinding.spec, LabelBinding.productionDate],
fontSize: 14,
bold: false,
align: LabelAlign.center),
],
),
qr: LabelQr(show: true, x: 178, y: 25, size: 134),
barcode:
LabelBarcode(show: true, x: 8, y: 114, width: 164, height: 40),
);
}
+7 -2
View File
@@ -5,6 +5,8 @@ import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
import 'label_data.dart';
export 'label_data.dart';
import 'label_template.dart';
export 'label_template.dart';
import 'order_print_meta.dart';
import '../theme/context_tokens.dart';
import '../../widgets/ds/ds_toast.dart';
@@ -25,6 +27,7 @@ Future<void> printProductLabel({
String shopAddress = '',
String shopPhone = '',
String? printerName,
LabelTemplate? template,
}) =>
printProductLabelImpl(
qrBytes: qrBytes,
@@ -39,11 +42,13 @@ Future<void> printProductLabel({
shopAddress: shopAddress,
shopPhone: shopPhone,
printerName: printerName,
template: template,
);
/// 渲染标签预览图(PNG 字节);Web 平台返回 null。
Future<Uint8List?> renderLabelPreview(LabelData label) =>
renderLabelPreviewImpl(label);
Future<Uint8List?> renderLabelPreview(LabelData label,
{LabelTemplate? template}) =>
renderLabelPreviewImpl(label, template: template);
/// 枚举当前系统所有可用打印机名;Web 平台返回空列表。
Future<List<String>> listLabelPrinters() => listLabelPrintersImpl();
+141 -135
View File
@@ -25,6 +25,7 @@ import 'package:printing/printing.dart';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import 'label_data.dart';
import 'label_template.dart';
import 'order_print_meta.dart';
Future<pw.Font> _loadFont() async {
@@ -158,7 +159,7 @@ bool _winRawPrint(String printerName, Uint8List data) {
void _drawText(
ui.Canvas canvas, String s, double x, double y, double size, double maxW,
{bool bold = false,
bool center = false,
ui.TextAlign textAlign = ui.TextAlign.left,
ui.Color color = const ui.Color(0xFF000000)}) // ds-ignore: 纸面固定色(默认黑)
{
if (s.isEmpty) return;
@@ -166,7 +167,7 @@ void _drawText(
fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊)
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
textAlign: center ? ui.TextAlign.center : ui.TextAlign.left,
textAlign: textAlign,
maxLines: 1,
ellipsis: '',
))
@@ -176,6 +177,22 @@ void _drawText(
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(
@@ -215,46 +232,19 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
return size;
}
/// 组装标签左列名下两行副字段(居中排布):
/// 行1 = 编号 + 度数(型号) 行2 = 规格(版本) + 生产日期
/// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。
/// 各字段为空则跳过(该行可只剩一个字段或整行消失);日期截断到 10 字符。四路渲染共用。
List<String> _labelSubFields(
String code, String? series, String? spec, String? productionDate) {
final date = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10
? productionDate.substring(0, 10)
: productionDate)
: '';
final line1 = [
if (code.isNotEmpty) code,
if ((series ?? '').isNotEmpty) series!,
].join(' ');
final line2 = [
if ((spec ?? '').isNotEmpty) spec!,
if (date.isNotEmpty) date,
].join(' ');
return [
if (line1.isNotEmpty) line1,
if (line2.isNotEmpty) line2,
];
}
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
Future<ui.Image> _renderLabelBitmap({
required String shop,
required String name,
required String code,
required List<String> subs, // 左列逐行副字段:型号规格 / 日期(已过滤空)
required Uint8List qrBytes,
required LabelTemplate tpl, // 版式单源:坐标/字号/开关全从此读
required LabelData data, // 待打印字段值 + qrBytes
bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码
bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码
}) async {
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸)
final int h = tpl.paper.logicalH; // 20mm@203 → 160
const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐)
const sw = w * scale, sh = h * scale;
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()));
@@ -271,63 +261,66 @@ Future<ui.Image> _renderLabelBitmap({
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = cWhite);
// ── 深蓝店名头 ──────────────────────────────────────────────
const headerH = 24.0;
canvas.drawRect(
ui.Rect.fromLTWH(0, 0, w.toDouble(), headerH), ui.Paint()..color = cNavy);
_drawText(canvas, shop, 10, 4, 14, w - 20, bold: true, color: cCream);
// ── 深蓝店名头binding 固定 shopName──────────────────────────
final header = tpl.header;
if (header.show) {
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), header.height),
ui.Paint()..color = cNavy);
_drawText(canvas, data.shopName, header.textX, header.textY,
header.fontSize, w - 2 * header.textX,
bold: header.bold, textAlign: _uiAlign(header.align), color: cCream);
}
// ── 主体两列布局(无页脚:主体直抵纸底,2026-08-27 删掉底部米色尺寸条)────────
const bodyTop = headerH; // 24
const double bodyBot = 160.0; // 直抵纸底 h(原到页脚顶 145),二维码/条码顺势放大
const padV = 6.0, padH = 8.0; // 收紧留白,给二维码腾空间
const double colGap = 6.0; // 左列与二维码列间距(原 10,收小)
// 右列:二维码略放大,占主体近满高(正方形),2026-08-28 用户口径「二维码调大一点」
const double qrPad = 1.0;
const double qrSize = (bodyBot - bodyTop) - 2 * qrPad; // 134
const double qrTop = bodyTop + qrPad; // 25
const double qrLeft = w - padH - qrSize; // 178
if (drawQr) {
// ── 右列二维码(正方形,绝对坐标)─────────────────────────────
final qr = tpl.qr;
if (drawQr && qr.show) {
try {
final codec = await ui.instantiateImageCodec(qrBytes);
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()),
const ui.Rect.fromLTWH(qrLeft, qrTop, qrSize, qrSize),
ui.Rect.fromLTWH(qr.x, qr.y, qr.size, qr.size),
ui.Paint());
} catch (e) {
debugPrint('[label] QR 解码失败: $e');
}
}
// 左列几何
const double leftX = padH; // 8
const double leftRight = qrLeft - colGap; // 172
const double leftW = leftRight - leftX; // 164
// ── 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 ──
final barcode = tpl.barcode;
if (drawBarcode && barcode.show) {
_drawBarcode(
canvas, data.code, barcode.x, barcode.y, barcode.width, barcode.height);
}
// 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库
const double barH = 40.0;
const double barBottom = bodyBot - padV; // 154
const double barTop = barBottom - barH; // 114
if (drawBarcode) _drawBarcode(canvas, code, leftX, barTop, leftW, barH);
// ── 左列文字块:品名 + 若干副字段行,整体在 [top, bottom] 区间竖直均分 ──
final ts = tpl.textStack;
final double leftX = ts.x;
final double leftW = ts.width;
final double infoTop = ts.top;
final double infoBot = ts.bottom;
final double infoH = infoBot - infoTop;
// 左列文字块:品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体水平居中、
// 竖直均分。2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。
const double infoTop = bodyTop + padV; // 30
const double infoBot = barTop - 4; // 110
const double infoH = infoBot - infoTop; // 80
// 逐行装配(text, size, bold, align):品名自适应/固定;副字段行按各自样式。
final rows = <(String, double, bool, LabelAlign)>[];
if (ts.name.show && data.name.isNotEmpty) {
final double nameSize = ts.name.fontSize ??
_fitFont(data.name, leftW, 1000, ts.name.bold, ts.name.fontMax)
.clamp(ts.name.fontMin, ts.name.fontMax);
rows.add((data.name, nameSize, ts.name.bold, ts.name.align));
}
for (final line in ts.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));
}
const double kSmallSize = 14.0; // 编号+度数 / 规格+日期(2026-08-28 调大一号 12→14
final double nameSize =
_fitFont(name, leftW, 1000, true, 18.0).clamp(12.0, 18.0);
final rows = <(String, double, bool)>[
if (name.isNotEmpty) (name, nameSize, true), // 品名粗体
for (final s in subs) (s, kSmallSize, false), // 编号+度数 / 规格+日期
];
const double kLineH = 1.3; // 行高倍数
final double kLineH = ts.lineHeight; // 行高倍数
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;
@@ -335,7 +328,7 @@ Future<ui.Image> _renderLabelBitmap({
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, center: true);
bold: r.$3, color: r.$3 ? cInk : cCode, textAlign: _uiAlign(r.$4));
yy += r.$2 * kLineH + gap;
}
@@ -349,11 +342,8 @@ Future<ui.Image> _renderLabelBitmap({
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
Future<bool> _printFlatLabelThermal({
required String shop,
required String name,
required String code,
required List<String> subs, // 度数 / 规格 / 日期(已过滤空)
required Uint8List qrBytes,
required LabelTemplate tpl, // 版式单源:QR/条码坐标 + TSPL 参数全从此读
required LabelData data,
String? printerName,
}) async {
if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false;
@@ -361,19 +351,18 @@ Future<bool> _printFlatLabelThermal({
debugPrint('[label] thermal printer = $printer');
if (printer == null) return false;
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
const wBytes = (w + 7) >> 3; // 40 bytes/row
final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸)
final int h = tpl.paper.logicalH; // 20mm@203 → 160
final int wBytes = (w + 7) >> 3; // 40 bytes/row
final code = data.code;
// ── 文字/抬头整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)─────────
// 画布内部 4× 超采样(1280×640),此处按覆盖率投票降采样二值化:
// 每个墨点看其 4×4=16 个子像素中"是墨"的占比,≥40% 即打黑。
// 替代旧的"盒式平均+128 阈值"——后者先把边缘抹成灰再一刀切,正是糊字的根因。
final img = await _renderLabelBitmap(
shop: shop,
name: name,
code: code,
subs: subs,
qrBytes: qrBytes,
tpl: tpl,
data: data,
drawBarcode: false,
drawQr: false,
);
@@ -383,10 +372,13 @@ Future<bool> _printFlatLabelThermal({
const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致
const int subTotal = ss * ss; // 16 子像素/墨点
// TSPL 头部参数由 template 驱动(纸张/间隙/方向/密度/速度)。
final p = tpl.print;
final buf = BytesBuilder();
buf.add(ascii.encode(
'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
'SIZE ${_tsplMm(tpl.paper.widthMm)} mm,${_tsplMm(tpl.paper.heightMm)} mm\r\n'
'GAP ${_tsplMm(p.gapMm)} mm,0 mm\r\nDIRECTION ${p.direction}\r\n'
'REFERENCE 0,0\r\nDENSITY ${p.density}\r\nSPEED ${p.speed}\r\nCLS\r\n',
));
// 整幅文字位图(mode 0 覆写整版):深蓝抬头→黑底反白,其余白底黑字,
@@ -413,37 +405,44 @@ Future<bool> _printFlatLabelThermal({
}
buf.add(ascii.encode('\r\n'));
// ── 右列二维码(原生 BITMAP,避免光栅降采样糊码):占主体整高、右对齐 ──
const qrW = 130, qrH = 130; // 2026-08-28 放大,对齐画布 QR134
const qrX = 320 - 8 - qrW; // 182
const bodyTop = 24, bodyBot = 160; // 无页脚:主体直抵纸底
const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 27
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);
for (int xx = 0; xx < qrW; xx++) {
final i = (yy * qrW + xx) * 4;
final a = qrRgba[i + 3];
final lum = a < 128
? 255.0
: 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2];
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7));
// ── 右列二维码(原生 BITMAP,避免光栅降采样糊码),坐标由 template 驱动(与画布同源)──
if (tpl.qr.show && data.qrBytes != null) {
final int qrW = tpl.qr.size.round();
final int qrH = qrW;
final int qrX = tpl.qr.x.round();
final int qrY = tpl.qr.y.round();
final int qrWBytes = (qrW + 7) >> 3;
final qrCodec = await ui.instantiateImageCodec(data.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);
for (int xx = 0; xx < qrW; xx++) {
final i = (yy * qrW + xx) * 4;
final a = qrRgba[i + 3];
final lum = a < 128
? 255.0
: 0.299 * qrRgba[i] +
0.587 * qrRgba[i + 1] +
0.114 * qrRgba[i + 2];
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7));
}
buf.add(row);
}
buf.add(row);
buf.add(ascii.encode('\r\n'));
}
buf.add(ascii.encode('\r\n'));
// ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),narrow 自适应铺满居中 ──
const leftX = 8, leftRight = qrX - 6, leftW = leftRight - leftX; // 8..188, 180
const kBarH = 34;
const kBarY = bodyBot - 8 - kBarH; // 118
if (code.isNotEmpty) {
// ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),坐标由 template 驱动 ──
// narrow 模块宽自适应铺满区域并水平居中。
if (tpl.barcode.show && code.isNotEmpty) {
final int leftX = tpl.barcode.x.round();
final int leftW = tpl.barcode.width.round();
final int leftRight = leftX + leftW;
final int kBarH = tpl.barcode.height.round();
final int kBarY = tpl.barcode.y.round();
final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验)
final narrow = (leftW / estModules).floor().clamp(2, 4);
final barW = estModules * narrow;
@@ -452,7 +451,7 @@ Future<bool> _printFlatLabelThermal({
'BARCODE $barX,$kBarY,"128",$kBarH,0,0,$narrow,$narrow,"$code"\r\n'));
}
buf.add(ascii.encode('PRINT 1,1\r\n'));
buf.add(ascii.encode('PRINT 1,${p.copies}\r\n'));
final tspl = buf.toBytes();
debugPrint('[label] raster BITMAP+QR+BARCODE tspl ${tspl.length}B -> $printer');
@@ -473,8 +472,23 @@ Future<void> printProductLabelImpl({
String shopAddress = '',
String shopPhone = '',
String? printerName,
LabelTemplate? template, // 版式单源;null → 内置默认(行为同重构前)
}) async {
try {
final tpl = template ?? LabelTemplate.builtinDefault();
final data = LabelData(
qrBytes: qrBytes,
name: name,
code: code,
spec: spec,
series: series,
batchNo: batchNo,
productionDate: productionDate,
remark: remark,
shopName: shopName,
shopAddress: shopAddress,
shopPhone: shopPhone,
);
final font = await _loadFont();
final doc = pw.Document();
final qrImage = pw.MemoryImage(qrBytes);
@@ -484,17 +498,12 @@ Future<void> printProductLabelImpl({
const labelH = 20.0 * PdfPageFormat.mm;
const headerH = labelH * 0.20;
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、TSPL 一致)
final subs = _labelSubFields(code, series, spec, productionDate);
// 副字段行由 template 装配(口径与预览、TSPL 一致)
final subs = tpl.textStack.composeSubs(data);
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
if (await _printFlatLabelThermal(
shop: shopName,
name: name,
code: code,
subs: subs,
qrBytes: qrBytes,
printerName: printerName)) {
tpl: tpl, data: data, printerName: printerName)) {
return;
}
@@ -617,15 +626,12 @@ Future<void> printProductLabelImpl({
}
/// 渲染标签预览图(PNG 字节),与实际热敏打印使用相同画布逻辑(所见即所得)。
/// Web 平台或 qrBytes 为空时抛异常。
Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
/// [template] 为空时用内置默认版式(行为同重构前)。Web 平台或 qrBytes 为空时抛异常。
Future<Uint8List> renderLabelPreviewImpl(LabelData label,
{LabelTemplate? template}) async {
final img = await _renderLabelBitmap(
shop: label.shopName,
name: label.name,
code: label.code,
subs: _labelSubFields(
label.code, label.series, label.spec, label.productionDate),
qrBytes: label.qrBytes!,
tpl: template ?? LabelTemplate.builtinDefault(),
data: label,
);
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
return bd!.buffer.asUint8List();
+21 -22
View File
@@ -7,6 +7,7 @@ import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
import 'label_data.dart';
import 'label_template.dart';
import 'order_print_meta.dart';
void _openPrintWindow(String html) {
@@ -31,30 +32,26 @@ Future<void> printProductLabelImpl({
String shopAddress = '',
String shopPhone = '',
String? printerName,
LabelTemplate? template,
}) async {
final base64Img = base64Encode(qrBytes);
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10
? productionDate.substring(0, 10)
: productionDate)
: '';
// 左列名下两行副字段(居中):行1=编号+度数、行2=规格+日期(口径与预览、PDF、TSPL 一致)
// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。
final line1 = [
if (code.isNotEmpty) code,
if (seriesVal.isNotEmpty) seriesVal,
].join(' ');
final line2 = [
if (specVal.isNotEmpty) specVal,
if (dateVal.isNotEmpty) dateVal,
].join(' ');
final subs = <String>[
if (line1.isNotEmpty) line1,
if (line2.isNotEmpty) line2,
];
// 副字段行由 template 装配(口径与预览、PDF、TSPL 一致;null → 内置默认版式)。
final tpl = template ?? LabelTemplate.builtinDefault();
final data = LabelData(
qrBytes: qrBytes,
name: name,
code: code,
spec: spec,
series: series,
batchNo: batchNo,
productionDate: productionDate,
remark: remark,
shopName: shopName,
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;
@@ -299,7 +296,9 @@ Future<void> printStockInOrderImpl(
// ── 预览 / 打印机枚举(Web 不支持热敏裸发,返回空值)─────────────────────────
Future<Uint8List?> renderLabelPreviewImpl(LabelData label) async => null;
Future<Uint8List?> renderLabelPreviewImpl(LabelData label,
{LabelTemplate? template}) async =>
null;
Future<List<String>> listLabelPrintersImpl() async => [];