fix(client): 热敏打印价签中文字体清晰化(NotoSansSC+4x超采样+覆盖率投票二值化,字号上调)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
This commit is contained in:
wangjia
2026-08-28 17:31:00 +08:00
parent 801b8528ec
commit 78e6a4c192
+25 -18
View File
@@ -163,6 +163,7 @@ void _drawText(
{
if (s.isEmpty) return;
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊)
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
textAlign: center ? ui.TextAlign.center : ui.TextAlign.left,
@@ -194,6 +195,7 @@ void _drawBarcode(
/// 测量单行文本在指定字号下的宽度
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,
@@ -239,7 +241,7 @@ List<String> _labelSubFields(
}
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
Future<ui.Image> _renderLabelBitmap({
required String shop,
@@ -251,7 +253,7 @@ Future<ui.Image> _renderLabelBitmap({
bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码
}) async {
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
const scale = 2; // 2× 超采样,内部以 640×320 绘制
const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐)
const sw = w * scale, sh = h * scale;
final recorder = ui.PictureRecorder();
final canvas =
@@ -273,7 +275,7 @@ Future<ui.Image> _renderLabelBitmap({
const headerH = 24.0;
canvas.drawRect(
ui.Rect.fromLTWH(0, 0, w.toDouble(), headerH), ui.Paint()..color = cNavy);
_drawText(canvas, shop, 10, 5, 12, w - 20, bold: true, color: cCream);
_drawText(canvas, shop, 10, 4, 14, w - 20, bold: true, color: cCream);
// ── 主体两列布局(无页脚:主体直抵纸底,2026-08-27 删掉底部米色尺寸条)────────
const bodyTop = headerH; // 24
@@ -318,9 +320,9 @@ Future<ui.Image> _renderLabelBitmap({
const double infoBot = barTop - 4; // 110
const double infoH = infoBot - infoTop; // 80
const double kSmallSize = 12.0; // 编号+度数 / 规格+日期
const double kSmallSize = 14.0; // 编号+度数 / 规格+日期2026-08-28 调大一号 12→14
final double nameSize =
_fitFont(name, leftW, 1000, true, 16.0).clamp(11.0, 16.0);
_fitFont(name, leftW, 1000, true, 18.0).clamp(12.0, 18.0);
final rows = <(String, double, bool)>[
if (name.isNotEmpty) (name, nameSize, true), // 品名粗体
for (final s in subs) (s, kSmallSize, false), // 编号+度数 / 规格+日期
@@ -337,7 +339,7 @@ Future<ui.Image> _renderLabelBitmap({
yy += r.$2 * kLineH + gap;
}
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320
return recorder.endRecording().toImage(sw, sh); // 返回 4× 图(1280×640
}
/// 桌面端把整张标签「光栅化」成单色位图裸发到热敏机(TSPL BITMAP),令热敏输出与
@@ -363,7 +365,9 @@ Future<bool> _printFlatLabelThermal({
const wBytes = (w + 7) >> 3; // 40 bytes/row
// ── 文字/抬头整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)─────────
// 画布内部 2× 超采样(640×320),此处 2×2 盒式降采样 + 亮度 128 阈值二值化,边缘更干净。
// 画布内部 4× 超采样(1280×640),此处按覆盖率投票降采样二值化:
// 每个墨点看其 4×4=16 个子像素中"是墨"的占比,≥40% 即打黑。
// 替代旧的"盒式平均+128 阈值"——后者先把边缘抹成灰再一刀切,正是糊字的根因。
final img = await _renderLabelBitmap(
shop: shop,
name: name,
@@ -375,7 +379,9 @@ Future<bool> _printFlatLabelThermal({
);
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List();
final iw = img.width; // 640
final iw = img.width; // 12804× 超采样)
const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致
const int subTotal = ss * ss; // 16 子像素/墨点
final buf = BytesBuilder();
buf.add(ascii.encode(
@@ -389,18 +395,19 @@ Future<bool> _printFlatLabelThermal({
for (int y = 0; y < h; y++) {
final row = Uint8List(wBytes)..fillRange(0, wBytes, 0xFF);
for (int x = 0; x < w; x++) {
int lum = 0;
for (int dy = 0; dy < 2; dy++) {
for (int dx = 0; dx < 2; dx++) {
final i = ((y * 2 + dy) * iw + (x * 2 + dx)) * 4;
final a = rgba[i + 3];
lum += a < 128
? 255
: (0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2])
.round();
int ink = 0; // 该墨点内"是墨"的子像素数
for (int dy = 0; dy < ss; dy++) {
for (int dx = 0; dx < ss; dx++) {
final i = ((y * ss + dy) * iw + (x * ss + dx)) * 4;
if (rgba[i + 3] < 128) continue; // 透明子像素当白
final lum =
0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
if (lum < 128) ink++; // 暗且不透明 = 墨
}
}
if (lum ~/ 4 < 128) row[x >> 3] &= ~(0x80 >> (x & 7));
// 覆盖率投票:≥40% 子像素是墨即打黑点。阈值偏低让细笔画不断(热敏易掉墨),
// 且不引入盒式平均的灰边模糊。
if (ink * 5 >= subTotal * 2) row[x >> 3] &= ~(0x80 >> (x & 7));
}
buf.add(row);
}