7dceefc55e
- 默认模板改为代码只读基准:readLabelTemplates 加载时强制用 builtinDefault() 覆盖 builtin-default 项,忽略存储里的旧脏几何 (旧默认品名被关/旧 QR 坐标等一律自愈回代码基准) - 二维码放大 x198/y44/尺寸96 → x192/y32/尺寸120,四周留 ~1mm 小边框 - 原型 label-template-editor.html 高级 QR 输入同步;golden 重生 - 新增存储测试钉死「内置默认永远回代码基准」 原型与代码同提交(design-first 双边一致)
208 lines
9.1 KiB
Dart
208 lines
9.1 KiB
Dart
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;
|
||
}
|