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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user