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:
@@ -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; // 1280(4× 超采样)
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user