Compare commits

...

2 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
7 changed files with 149 additions and 40 deletions
+10
View File
@@ -5,6 +5,16 @@ 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/),
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
### 改进
+118 -35
View File
@@ -3,6 +3,7 @@ import 'dart:convert' show ascii;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:ffi';
import 'package:charset_converter/charset_converter.dart';
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart'
show
@@ -13,7 +14,8 @@ import 'package:win32/win32.dart'
EndPagePrinter,
EndDocPrinter,
ClosePrinter,
DOC_INFO_1;
DOC_INFO_1,
WideCharToMultiByte;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:flutter/services.dart';
import '../errors/error_reporter.dart';
@@ -265,8 +267,52 @@ Future<ui.Image> _renderLabelBitmap({
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,失败抛异常。
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
Future<bool> _printFlatLabelThermal({
@@ -283,42 +329,79 @@ Future<bool> _printFlatLabelThermal({
debugPrint('[label] thermal printer = $printer');
if (printer == null) return false;
// img 是 2× 超采样图(640×320);TSPL 输出仍是 320×160
const w = 320, h = 160;
final img = await _renderLabelBitmap(
shop: shop, name: name, code: code, degSpec: degSpec, date: date, qrBytes: qrBytes);
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List();
final sw = img.width; // 6402×宽)
// QR BITMAP: 124×124 dots, 右侧 x=192, y=8
const qrW = 124, qrH = 124;
const qrX = 192, qrY = 8;
const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
const wbytes = (w + 7) >> 3;
final out = BytesBuilder();
out.add(ascii.encode('SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n'
'BITMAP 0,0,$wbytes,$h,1,'));
for (int yy = 0; yy < h; yy++) {
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1
for (int xx = 0; xx < w; xx++) {
// 2×2 四像素平均后阈值,消除反锯齿模糊(阈值 160 = 偏灰的边缘算黑)
double lum = 0;
for (int dy = 0; dy < 2; dy++) {
for (int dx = 0; dx < 2; dx++) {
final i = ((yy * 2 + dy) * sw + (xx * 2 + dx)) * 4;
final a = rgba[i + 3];
lum += a < 128
? 255.0
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
}
}
if (lum / 4 < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0
// 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
final codec = await ui.instantiateImageCodec(qrBytes, targetWidth: qrW, targetHeight: qrH);
final qrImg = (await codec.getNextFrame()).image;
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
final qrRgba = qrBd!.buffer.asUint8List();
final buf = BytesBuilder();
buf.add(ascii.encode(
'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
));
// 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
? 255.0
: 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2];
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'));
final tspl = out.toBytes();
buf.add(ascii.encode('\r\n'));
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);
return true;
}
@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import charset_converter
import file_picker
import file_selector_macos
import package_info_plus
@@ -13,6 +14,7 @@ import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
CharsetConverterPlugin.register(with: registry.registrar(forPlugin: "CharsetConverterPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
+12 -4
View File
@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -513,10 +521,10 @@ packages:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
@@ -545,10 +553,10 @@ packages:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
+3 -1
View File
@@ -1,7 +1,7 @@
name: jiu_client
description: 酒库管理系统 - 酒店仓库管理
publish_to: 'none'
version: 1.0.3+1
version: 1.0.52+1
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -29,6 +29,8 @@ dependencies:
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
win32: ^5.5.0
ffi: ^2.1.3
# GBK 编码(TSPL TEXT 内置中文字体需要),支持 macOS/Windows/Linux
charset_converter: ^2.1.0
dev_dependencies:
flutter_test:
@@ -6,11 +6,14 @@
#include "generated_plugin_registrant.h"
#include <charset_converter/charset_converter_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
CharsetConverterPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CharsetConverterPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
PrintingPluginRegisterWithRegistrar(
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
charset_converter
file_selector_windows
printing
url_launcher_windows