Files
jiu/client/lib/core/utils/label_bitmap.dart
T
wangjia 7dceefc55e feat(client): 价签默认模板品名默认展示 + 二维码放大留小边框
- 默认模板改为代码只读基准:readLabelTemplates 加载时强制用
  builtinDefault() 覆盖 builtin-default 项,忽略存储里的旧脏几何
  (旧默认品名被关/旧 QR 坐标等一律自愈回代码基准)
- 二维码放大 x198/y44/尺寸96 → x192/y32/尺寸120,四周留 ~1mm 小边框
- 原型 label-template-editor.html 高级 QR 输入同步;golden 重生
- 新增存储测试钉死「内置默认永远回代码基准」

原型与代码同提交(design-first 双边一致)
2026-08-31 12:07:22 +08:00

208 lines
9.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
centerInH: lay.headerHeight); // 店名在深蓝带内上下居中(textY 作微调)
}
// ── 右列二维码(正方形,绝对坐标)─────────────────────────────
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 走系统字体回退)。
/// [centerInH] > 0 时:在 [y, y+centerInH] 区间内竖直居中(用紧排行高 1.0 量高后
/// 上下留白均分),供抬头店名在深蓝带内上下居中;[y] 作为居中基础上的手动微调量。
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: 纸面固定色(默认黑)
double centerInH = 0})
{
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: '',
height: centerInH > 0 ? 1.0 : null, // 居中时用紧排行高,量出的高≈字号,居中更准
))
..pushStyle(ui.TextStyle(color: color)) // ds-ignore: 打印画布纸面固定色
..addText(s);
final p = pb.build()..layout(ui.ParagraphConstraints(width: maxW));
final double dy = centerInH > 0 ? y + (centerInH - p.height) / 2 : y;
canvas.drawParagraph(p, ui.Offset(x, dy));
}
/// 版式对齐 → 画布 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;
}