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; }