From eedcd9a385f4d1743eaa90f8a21fc1a119c0b743 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 29 Aug 2026 23:18:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(client):=20=E4=BB=B7=E7=AD=BE=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=99=A8=E6=94=B9=E6=A0=85=E6=A0=BC=E6=B5=81=E5=B8=83?= =?UTF-8?q?=E5=B1=80(computeLayout),=E9=9A=90=E8=97=8F=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E9=82=BB=E5=B1=85=E5=9B=9E=E6=94=B6=E7=A9=BA=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 标签版式从绝对坐标改为「栅格流/自动回流」: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 Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5 --- client/lib/core/utils/label_template.dart | 129 +++++++++++++++++++++ client/lib/core/utils/print_util_stub.dart | 68 +++++------ 2 files changed, 164 insertions(+), 33 deletions(-) diff --git a/client/lib/core/utils/label_template.dart b/client/lib/core/utils/label_template.dart index 78cda83..0f245e1 100644 --- a/client/lib/core/utils/label_template.dart +++ b/client/lib/core/utils/label_template.dart @@ -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 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, + }); +} diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index 6fe1703..0dd05b9 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -261,19 +261,20 @@ Future _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 _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 _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 _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 _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 _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 _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;