Compare commits

...

4 Commits

Author SHA1 Message Date
wangjia 146444ccc3 fix(client): Windows 热敏打印 GBK 编码改用 WideCharToMultiByte FFI
Deploy / build-linux-web (push) Successful in 47s
Deploy / build-macos (push) Successful in 1m29s
Deploy / build-windows (push) Successful in 2m32s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:09:57 +08:00
wangjia 2ca65e8077 chore: release v1.0.52
Deploy / build-linux-web (push) Successful in 52s
Deploy / build-windows (push) Successful in 1m52s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 1m56s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m53s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:10:54 +08:00
wangjia bf36f7aa3f fix(client): 标签字体细化:阈值 128,店名细体
Deploy / build-linux-web (push) Successful in 46s
Deploy / build-windows (push) Successful in 1m43s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 59s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m53s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:04:32 +08:00
wangjia 097e466372 fix(client): 标签打印 2× 超采样 + 阈值 160,消除热敏字体模糊
Deploy / build-linux-web (push) Successful in 51s
Deploy / build-windows (push) Successful in 1m46s
Deploy / build-macos (push) Successful in 1m30s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m54s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:20:42 +08:00
7 changed files with 167 additions and 36 deletions
+20
View File
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.53] - 2026-06-15
### 修复
- 修复 Windows 上点击打印无反应的问题(GBK 编码改为直接调用系统 API,不再依赖第三方插件)
## [1.0.52] - 2026-06-15
### 改进
- 热敏标签打印改用打印机内置中文点阵字体(TSS24.BF2/TSS16.BF2),文字清晰度大幅提升,彻底解决 203 DPI 下字体模糊问题
## [1.0.51] - 2026-06-15
### 改进
- 标签打印字体细化:阈值调回 128,店名改细体,商品名保持粗体
## [1.0.50] - 2026-06-15
### 改进
- 标签打印字体更清晰:采用 2× 超采样渲染,消除热敏打印时反锯齿导致的模糊笔划
## [1.0.49] - 2026-06-15 ## [1.0.49] - 2026-06-15
### 改进 ### 改进
+126 -31
View File
@@ -3,6 +3,7 @@ import 'dart:convert' show ascii;
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'dart:ffi'; import 'dart:ffi';
import 'package:charset_converter/charset_converter.dart';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart' import 'package:win32/win32.dart'
show show
@@ -13,7 +14,8 @@ import 'package:win32/win32.dart'
EndPagePrinter, EndPagePrinter,
EndDocPrinter, EndDocPrinter,
ClosePrinter, ClosePrinter,
DOC_INFO_1; DOC_INFO_1,
WideCharToMultiByte;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint; import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../errors/error_reporter.dart'; import '../errors/error_reporter.dart';
@@ -189,7 +191,8 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
return size; return size;
} }
/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi)。 /// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。 /// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
Future<ui.Image> _renderLabelBitmap({ Future<ui.Image> _renderLabelBitmap({
required String shop, required String shop,
@@ -199,10 +202,13 @@ Future<ui.Image> _renderLabelBitmap({
required String date, required String date,
required Uint8List qrBytes, required Uint8List qrBytes,
}) async { }) async {
const w = 320, h = 160; // 40×20mm @203dpi const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
const scale = 2; // 2× 超采样,内部以 640×320 绘制
const sw = w * scale, sh = h * scale;
final recorder = ui.PictureRecorder(); final recorder = ui.PictureRecorder();
final canvas = ui.Canvas( final canvas = ui.Canvas(
recorder, ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble())); recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()), canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = const ui.Color(0xFFFFFFFF)); ui.Paint()..color = const ui.Color(0xFFFFFFFF));
@@ -236,8 +242,8 @@ Future<ui.Image> _renderLabelBitmap({
final codeText = code.isNotEmpty ? '编号:$code' : ''; final codeText = code.isNotEmpty ? '编号:$code' : '';
final rows = <(String, double, bool)>[ final rows = <(String, double, bool)>[
if (shop.isNotEmpty) (shop, kShopSize, true), if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
if (name.isNotEmpty) (name, nameSize, true), if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
if (codeText.isNotEmpty) (codeText, kSmallSize, false), if (codeText.isNotEmpty) (codeText, kSmallSize, false),
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false), if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
if (date.isNotEmpty) (date, kSmallSize, false), if (date.isNotEmpty) (date, kSmallSize, false),
@@ -258,11 +264,55 @@ Future<ui.Image> _renderLabelBitmap({
yy += r.$2 * kLineH + gap; yy += r.$2 * kLineH + gap;
} }
return recorder.endRecording().toImage(w, h); return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320
} }
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。 /// UTF-8 → GBK 字节:
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。 /// Windows 直接调 WideCharToMultiByte(936) FFI,无需插件;
/// macOS 走 charset_converterCoreFoundation 原生支持 GBK)。
Uint8List _encodeGbk(String text) {
if (text.isEmpty) return Uint8List(0);
if (Platform.isWindows) {
final wideStr = text.toNativeUtf16();
try {
final needed = WideCharToMultiByte(936, 0, wideStr, -1, nullptr, 0, nullptr, nullptr);
if (needed <= 1) return Uint8List(0);
final buf = calloc<Uint8>(needed);
try {
WideCharToMultiByte(936, 0, wideStr, -1, buf.cast(), needed, nullptr, nullptr);
return Uint8List.fromList(buf.asTypedList(needed - 1)); // 不含 null 终止符
} finally {
calloc.free(buf);
}
} finally {
calloc.free(wideStr);
}
}
// macOS: charset_converter(同步 CoreFoundation
// encode() 在 macOS 上是同步 channel 调用,此处以 Future.value 包装保持接口一致
throw UnsupportedError('call _encodeGbkAsync on macOS');
}
Future<Uint8List> _encodeGbkAsync(String text) async {
if (text.isEmpty) return Uint8List(0);
if (Platform.isWindows) return _encodeGbk(text);
return CharsetConverter.encode('GBK', text);
}
/// TSPL TEXT 命令辅助:将文本以 GBK 编码写入 BytesBuilder。
/// TSS24.BF2/TSS16.BF2 是 TSC 固件内置简体中文点阵字体,需 GBK 编码。
Future<void> _addTextCmd(
BytesBuilder buf, int x, int y, String font, String text) async {
if (text.isEmpty) return;
final gbk = await _encodeGbkAsync(text);
buf.add(ascii.encode('TEXT $x,$y,"$font",0,1,1,"'));
buf.add(gbk);
buf.add(ascii.encode('"\r\n'));
}
/// 桌面端把标签以 TSPL TEXT(内置中文点阵字体)+ QR BITMAP 混合方式裸发到热敏机。
/// TEXT 走 TSS24.BF2/TSS16.BF2GBK 编码,203 DPI 下比 TrueType 位图清晰数倍)。
/// QR 仍走 BITMAP(内置 QRCODE 命令扫描不可靠)。
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。 /// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。 /// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
Future<bool> _printFlatLabelThermal({ Future<bool> _printFlatLabelThermal({
@@ -279,34 +329,79 @@ Future<bool> _printFlatLabelThermal({
debugPrint('[label] thermal printer = $printer'); debugPrint('[label] thermal printer = $printer');
if (printer == null) return false; if (printer == null) return false;
const w = 320, h = 160; // QR BITMAP: 124×124 dots, 右侧 x=192, y=8
final img = await _renderLabelBitmap( const qrW = 124, qrH = 124;
shop: shop, name: name, code: code, degSpec: degSpec, date: date, qrBytes: qrBytes); const qrX = 192, qrY = 8;
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba); const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
final rgba = bd!.buffer.asUint8List();
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸) // 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
const wbytes = (w + 7) >> 3; final codec = await ui.instantiateImageCodec(qrBytes, targetWidth: qrW, targetHeight: qrH);
final out = BytesBuilder(); final qrImg = (await codec.getNextFrame()).image;
out.add(ascii.encode('SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n' final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n' final qrRgba = qrBd!.buffer.asUint8List();
'BITMAP 0,0,$wbytes,$h,1,'));
for (int yy = 0; yy < h; yy++) { final buf = BytesBuilder();
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1 buf.add(ascii.encode(
for (int xx = 0; xx < w; xx++) { 'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
final i = (yy * w + xx) * 4; 'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
final a = rgba[i + 3]; ));
// QR BITMAP 命令
buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,'));
for (int yy = 0; yy < qrH; yy++) {
final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); // 默认白
for (int xx = 0; xx < qrW; xx++) {
final i = (yy * qrW + xx) * 4;
final a = qrRgba[i + 3];
final lum = a < 128 final lum = a < 128
? 255.0 ? 255.0
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]; : 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2];
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0 if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7));
} }
out.add(row); buf.add(row);
} }
out.add(ascii.encode('\r\nPRINT 1,1\r\n')); buf.add(ascii.encode('\r\n'));
final tspl = out.toBytes();
debugPrint('[label] flat tspl ${tspl.length}B -> $printer'); // "扫码溯源" 居中于 QR 正下方
// TSS16 CJK 每字 16 dots; 4字=64; QR 中心 x=192+62=254; 文字起始 x=254-32=222
await _addTextCmd(buf, 222, qrY + qrH + 2, 'TSS16.BF2', '扫码溯源');
// 左侧文字(x=4, 宽度至 x=186
// TSS24.BF2: 24×24 点阵; TSS16.BF2: 16×16 点阵
const kFontL = 'TSS16.BF2'; // 小号(编号/度数/日期)
const kFontXL = 'TSS24.BF2'; // 大号(商品名,≤7字时使用)
final nameFont = name.runes.length <= 7 ? kFontXL : kFontL;
final nameH = nameFont == kFontXL ? 24 : 16;
final codeText = code.isNotEmpty ? '编号:$code' : '';
// (文本, 字体, 字符高度点数)
final rows = <(String, String, int)>[];
if (shop.isNotEmpty) rows.add((shop, kFontL, 16));
if (name.isNotEmpty) rows.add((name, nameFont, nameH));
if (codeText.isNotEmpty) rows.add((codeText, kFontL, 16));
if (degSpec.isNotEmpty) rows.add((degSpec, kFontL, 16));
if (date.isNotEmpty) rows.add((date, kFontL, 16));
// 在 y=8..150 区间均匀分布各行
const kTextTop = 8;
const kTextBot = 150;
const kTextX = 4;
final totalH = rows.fold(0, (s, r) => s + r.$3);
final n = rows.length;
final gapSize = n > 0
? ((kTextBot - kTextTop - totalH) / (n + 1)).round().clamp(2, 20)
: 8;
int yPos = kTextTop + gapSize;
for (final r in rows) {
await _addTextCmd(buf, kTextX, yPos, r.$2, r.$1);
yPos += r.$3 + gapSize;
}
buf.add(ascii.encode('PRINT 1,1\r\n'));
final tspl = buf.toBytes();
debugPrint('[label] TEXT+BITMAP tspl ${tspl.length}B -> $printer');
await _sendRaw(printer, tspl); await _sendRaw(printer, tspl);
return true; return true;
} }
@@ -5,6 +5,7 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import charset_converter
import file_picker import file_picker
import file_selector_macos import file_selector_macos
import package_info_plus import package_info_plus
@@ -13,6 +14,7 @@ import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
CharsetConverterPlugin.register(with: registry.registrar(forPlugin: "CharsetConverterPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
+12 -4
View File
@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
charset_converter:
dependency: "direct main"
description:
name: charset_converter
sha256: af00b5b73b367101c661b8dfaa52abf3e533de7b3e247e3f65c535ebc4df820a
url: "https://pub.dev"
source: hosted
version: "2.4.0"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -513,10 +521,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.1.6"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
@@ -545,10 +553,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_platform_interface name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.3"
path_provider_windows: path_provider_windows:
dependency: transitive dependency: transitive
description: description:
+3 -1
View File
@@ -1,7 +1,7 @@
name: jiu_client name: jiu_client
description: 酒库管理系统 - 酒店仓库管理 description: 酒库管理系统 - 酒店仓库管理
publish_to: 'none' publish_to: 'none'
version: 1.0.3+1 version: 1.0.52+1
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -29,6 +29,8 @@ dependencies:
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用 # Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
win32: ^5.5.0 win32: ^5.5.0
ffi: ^2.1.3 ffi: ^2.1.3
# GBK 编码(TSPL TEXT 内置中文字体需要),支持 macOS/Windows/Linux
charset_converter: ^2.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -6,11 +6,14 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <charset_converter/charset_converter_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <printing/printing_plugin.h> #include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
CharsetConverterPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CharsetConverterPlugin"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
PrintingPluginRegisterWithRegistrar( PrintingPluginRegisterWithRegistrar(
@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
charset_converter
file_selector_windows file_selector_windows
printing printing
url_launcher_windows url_launcher_windows