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` 贴整幅位图 `
`)。
///
/// 版式坐标/字号/开关全从 [tpl].computeLayout() 读,确保「预览即实际输出」。
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
Future 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 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;
}