refactor(client): 价签渲染器改栅格流布局(computeLayout),隐藏字段邻居回收空间

标签版式从绝对坐标改为「栅格流/自动回流」:LabelTemplate.computeLayout()
解析存储坐标为流式绝对几何(LabelLayout),三条回流规则——条码隐藏文字区向下
延伸、二维码隐藏文字区向右延伸、抬头隐藏整体上移;打印安全地板钳制
(kMinQrSize=80/kMinBarcodeH=20)。画布位图与热敏 TSPL 两条渲染路径改为只读
computeLayout(),不再直读 tpl.header/qr/barcode/textStack。

全字段显示时回流均为 no-op,默认模板逐像素等于重构前基准——
label_render_golden_test 零 diff 守护。

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 23:18:55 +08:00
parent d67295234e
commit eedcd9a385
2 changed files with 164 additions and 33 deletions
+129
View File
@@ -459,6 +459,70 @@ class LabelTemplate {
barcode: barcode.copy(),
);
/// 把声明式模型解析成一套**绝对几何**(渲染器只读它,不再各自读原始字段)。
///
/// 1 期布局口径 = 栅格流/自动回流(2026-08 用户拍板,取代绝对坐标):
/// 存的绝对坐标是「全字段显示」的基准版式;某结构区域隐藏时,其让出的空间
/// 由相邻区域回收填充,纸面不留空。三处结构性回流:
/// ① 条码隐藏 → 文字区向下吃掉条码纵向空间(textBottom 延到条码原下沿);
/// ② 二维码隐藏 → 文字区向右吃掉整列(textWidth 延到二维码原右沿);
/// ③ 抬头隐藏 → 正文/二维码/条码整体上移一个抬头高,顶部不留空带。
/// 副字段(编号/系列/规格/日期/批次/备注)的增删本就由文字区竖直均分自适应,无需此处处理。
///
/// **零回归铁律**:全字段显示时,本方法逐值返回存的绝对坐标(三条回流均为 no-op、
/// 安全地板默认值之上不触发),故默认模板渲染与重构前逐像素一致
/// (由 `label_render_golden_test.dart` 守闸)。改这里先确认 golden 仍零 diff。
LabelLayout computeLayout() {
double tTop = textStack.top;
double tBot = textStack.bottom;
final double tX = textStack.x;
double tW = textStack.width;
double qY = qr.y;
double bY = barcode.y;
// ① 条码隐藏:文字区向下延到条码原下沿,吃掉纵向空白
if (!barcode.show) tBot = barcode.y + barcode.height;
// ② 二维码隐藏:文字区向右延到二维码原右沿,吃掉右列空白
if (!qr.show) tW = (qr.x + qr.size) - textStack.x;
// ③ 抬头隐藏:正文/二维码/条码整体上移一个抬头高
if (!header.show) {
final double dy = header.height;
tTop -= dy;
qY -= dy;
bY -= dy;
}
// 打印安全地板(203dpi 热敏可扫下限):默认值均在地板之上 → 不触发、不改默认像素
final double qSize = qr.size < kMinQrSize ? kMinQrSize : qr.size;
final double barH = barcode.height < kMinBarcodeH ? kMinBarcodeH : barcode.height;
return LabelLayout(
headerShow: header.show,
headerHeight: header.height,
headerTextX: header.textX,
headerTextY: header.textY,
headerFontSize: header.fontSize,
headerBold: header.bold,
headerAlign: header.align,
qrShow: qr.show,
qrX: qr.x,
qrY: qY,
qrSize: qSize,
barcodeShow: barcode.show,
barX: barcode.x,
barY: bY,
barW: barcode.width,
barH: barH,
textX: tX,
textWidth: tW,
textTop: tTop,
textBottom: tBot,
textLineHeight: textStack.lineHeight,
name: textStack.name,
subLines: textStack.subLines,
);
}
/// 内置默认模板:逐值冻结重构前 `print_util_stub.dart` 的画布常量。
/// 改任一常量前先确认 `label_render_golden_test.dart` 仍零 diff。
static LabelTemplate builtinDefault() => LabelTemplate(
@@ -505,3 +569,68 @@ class LabelTemplate {
LabelBarcode(show: true, x: 8, y: 114, width: 164, height: 40),
);
}
/// 二维码最小边长(逻辑像素@203dpi ≈ 10mm):低于此扫码不可靠,computeLayout 兜底钳制。
const double kMinQrSize = 80;
/// 一维条码最小高度(逻辑像素@203dpi ≈ 2.5mm):低于此扫码枪难识别,computeLayout 兜底钳制。
const double kMinBarcodeH = 20;
/// `computeLayout()` 的产物:一套解析后的**绝对几何**,渲染器(画布 + 热敏 TSPL)只读它。
///
/// 全字段显示时逐值等于模型存的绝对坐标(栅格流回流均为 no-op),保证默认渲染零回归。
/// 文字栈的品名/副字段沿用原对象(竖直均分 + 品名自适应逻辑在渲染器内,不变)。
class LabelLayout {
final bool headerShow;
final double headerHeight;
final double headerTextX;
final double headerTextY;
final double headerFontSize;
final bool headerBold;
final LabelAlign headerAlign;
final bool qrShow;
final double qrX;
final double qrY;
final double qrSize;
final bool barcodeShow;
final double barX;
final double barY;
final double barW;
final double barH;
final double textX;
final double textWidth;
final double textTop;
final double textBottom;
final double textLineHeight;
final LabelNameField name;
final List<LabelSubLine> subLines;
const LabelLayout({
required this.headerShow,
required this.headerHeight,
required this.headerTextX,
required this.headerTextY,
required this.headerFontSize,
required this.headerBold,
required this.headerAlign,
required this.qrShow,
required this.qrX,
required this.qrY,
required this.qrSize,
required this.barcodeShow,
required this.barX,
required this.barY,
required this.barW,
required this.barH,
required this.textX,
required this.textWidth,
required this.textTop,
required this.textBottom,
required this.textLineHeight,
required this.name,
required this.subLines,
});
}
+35 -33
View File
@@ -261,19 +261,20 @@ Future<ui.Image> _renderLabelBitmap({
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = cWhite);
// 解析栅格流版式(隐藏区域空间由邻居回收;全字段显示时逐值=模型存的绝对坐标 → 零回归)。
final lay = tpl.computeLayout();
// ── 深蓝店名头(binding 固定 shopName)──────────────────────────
final header = tpl.header;
if (header.show) {
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), header.height),
if (lay.headerShow) {
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), lay.headerHeight),
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);
_drawText(canvas, data.shopName, lay.headerTextX, lay.headerTextY,
lay.headerFontSize, w - 2 * lay.headerTextX,
bold: lay.headerBold, textAlign: _uiAlign(lay.headerAlign), color: cCream);
}
// ── 右列二维码(正方形,绝对坐标)─────────────────────────────
final qr = tpl.qr;
if (drawQr && qr.show) {
if (drawQr && lay.qrShow) {
try {
final codec = await ui.instantiateImageCodec(data.qrBytes!);
final qrImg = (await codec.getNextFrame()).image;
@@ -281,7 +282,7 @@ Future<ui.Image> _renderLabelBitmap({
qrImg,
ui.Rect.fromLTWH(
0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()),
ui.Rect.fromLTWH(qr.x, qr.y, qr.size, qr.size),
ui.Rect.fromLTWH(lay.qrX, lay.qrY, lay.qrSize, lay.qrSize),
ui.Paint());
} catch (e) {
debugPrint('[label] QR 解码失败: $e');
@@ -289,29 +290,27 @@ Future<ui.Image> _renderLabelBitmap({
}
// ── 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 ──
final barcode = tpl.barcode;
if (drawBarcode && barcode.show) {
if (drawBarcode && lay.barcodeShow) {
_drawBarcode(
canvas, data.code, barcode.x, barcode.y, barcode.width, barcode.height);
canvas, data.code, lay.barX, lay.barY, lay.barW, lay.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 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 (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));
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 ts.subLines) {
for (final line in lay.subLines) {
final parts = line.bindings
.map((b) => resolveBinding(b, data))
.where((s) => s.isNotEmpty)
@@ -320,7 +319,7 @@ Future<ui.Image> _renderLabelBitmap({
if (s.isNotEmpty) rows.add((s, line.fontSize, line.bold, line.align));
}
final double kLineH = ts.lineHeight; // 行高倍数
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;
@@ -372,6 +371,9 @@ Future<bool> _printFlatLabelThermal({
const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致
const int subTotal = ss * ss; // 16 子像素/墨点
// 解析栅格流版式(与画布同源):QR/条码坐标随隐藏字段回流,全字段显示时=模型绝对坐标。
final lay = tpl.computeLayout();
// TSPL 头部参数由 template 驱动(纸张/间隙/方向/密度/速度)。
final p = tpl.print;
final buf = BytesBuilder();
@@ -406,11 +408,11 @@ Future<bool> _printFlatLabelThermal({
buf.add(ascii.encode('\r\n'));
// ── 右列二维码(原生 BITMAP,避免光栅降采样糊码),坐标由 template 驱动(与画布同源)──
if (tpl.qr.show && data.qrBytes != null) {
final int qrW = tpl.qr.size.round();
if (lay.qrShow && data.qrBytes != null) {
final int qrW = lay.qrSize.round();
final int qrH = qrW;
final int qrX = tpl.qr.x.round();
final int qrY = tpl.qr.y.round();
final int qrX = lay.qrX.round();
final int qrY = lay.qrY.round();
final int qrWBytes = (qrW + 7) >> 3;
final qrCodec = await ui.instantiateImageCodec(data.qrBytes!,
targetWidth: qrW, targetHeight: qrH);
@@ -437,12 +439,12 @@ Future<bool> _printFlatLabelThermal({
// ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),坐标由 template 驱动 ──
// narrow 模块宽自适应铺满区域并水平居中。
if (tpl.barcode.show && code.isNotEmpty) {
final int leftX = tpl.barcode.x.round();
final int leftW = tpl.barcode.width.round();
if (lay.barcodeShow && code.isNotEmpty) {
final int leftX = lay.barX.round();
final int leftW = lay.barW.round();
final int leftRight = leftX + leftW;
final int kBarH = tpl.barcode.height.round();
final int kBarY = tpl.barcode.y.round();
final int kBarH = lay.barH.round();
final int kBarY = lay.barY.round();
final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验)
final narrow = (leftW / estModules).floor().clamp(2, 4);
final barW = estModules * narrow;