feat: Windows PavoApi ffi 直打后端(Phase 3 代码完成,待实机验收)

- lib/print/pavo/:PavoApiW_x64.dll 的 dart:ffi 绑定(结构体布局对齐 C#,W 后缀导出名回退兼容)
- pavo_printer.dart:枚举(UTF-8/UTF-16 自动解码)/状态/色带信息/单卡直打/忙碌轮询(3 分钟超时)/LayerHolder 非托管内存管理
- pavo_backend.dart:全部 ffi 调用与像素转换跑在 Isolate.run,主 isolate HTTP 服务零阻塞
- lib/image/card_image.dart:PNG data URL → 纵横比选卡面 → 手动 alpha 铺白底 → BGR24/Gray8 紧凑像素(image 包 compositeImage 对全透明像素非 src-over 语义,改为逐像素混合对齐 C#)
- windows/ 目录:从 Flutter SDK windows.tmpl 手动实例化(mac 上 flutter create 拒绝生成的绕过),窗口标题"饭卡打印助手"
- windows/vendor/:收录 SDK 的 PavoApiW_x64.dll/PavoCardSDK_x64.dll/HeatTB3.dll/heatthcd.bin,CMake POST_BUILD 自动拷到 exe 旁
- backend.dart 工厂接通 Windows 分支;analyze 零问题、单测 12 个全过
This commit is contained in:
dzc committed 2026-09-21 12:19:07 +08:00
1 parent 689a07ca44
commit 0a78ff1b58
28 files changed
+1874 -5

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:image/image.dart';
/// PNG data URL 解析与像素层转换(Windows PavoApi 直打用)。
///
/// 卡面为 CR-80 @300dpi:横向 1014×642 / 纵向 642×1014,按源图纵横比自动选择;
/// 透明区域铺白,避免黑底(对齐 C# 版 Hiti/CardImage.cs)。
class CardImage {
CardImage._(this._canvas, this.width, this.height);
static const int landscapeWidth = 1014;
static const int landscapeHeight = 642;
final Image _canvas;
final int width;
final int height;
/// 宽 ≥ 高 = 横向卡面
bool get isLandscape => width >= height;
/// 解析 data:image/png;base64,…(或其他 data URL 形式),缩放到卡面画布并铺白底。
static CardImage fromDataUrl(String dataUrl) {
if (dataUrl.trim().isEmpty) {
throw ArgumentError('卡面图为空');
}
final comma = dataUrl.indexOf(',');
final base64Part = comma >= 0 ? dataUrl.substring(comma + 1) : dataUrl;
final bytes = base64.decode(base64Part.replaceAll(RegExp(r'\s'), ''));
final raw = decodeImage(bytes);
if (raw == null) {
throw ArgumentError('卡面图解码失败(不支持的图片格式)');
}
final landscape = raw.width >= raw.height;
final width = landscape ? landscapeWidth : landscapeHeight;
final height = landscape ? landscapeHeight : landscapeWidth;
final resized = copyResize(raw, width: width, height: height);
final canvas = Image(width: width, height: height);
// 手动 alpha 混合铺白底(与 C# 版逐像素语义一致):
// image 包的 compositeImage 对全透明 src 像素不是 src-over 语义,透明处会漏黑
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
final p = resized.getPixel(x, y);
final a = p.a.toInt() / 255.0;
canvas.setPixelRgba(
x,
y,
(255 * (1 - a) + p.r * a).round().clamp(0, 255),
(255 * (1 - a) + p.g * a).round().clamp(0, 255),
(255 * (1 - a) + p.b * a).round().clamp(0, 255),
255,
);
}
}
return CardImage._(canvas, width, height);
}
/// 转 BGR 24bit 紧凑像素(无行对齐填充,PavoApi BITMAP 用)。
Uint8List toBgr24() {
final packed = Uint8List(width * 3 * height);
for (var y = 0; y < height; y++) {
var offset = y * width * 3;
for (var x = 0; x < width; x++) {
final p = _canvas.getPixel(x, y);
packed[offset] = p.b.toInt() & 0xff;
packed[offset + 1] = p.g.toInt() & 0xff;
packed[offset + 2] = p.r.toInt() & 0xff;
offset += 3;
}
}
return packed;
}
/// 转 8bit 灰度(无行对齐)。树脂 K 层像素语义:0x00 = 转印黑色,
/// 因此前端 K 层 PNG 为白底黑字,灰度值可直接使用。
Uint8List toGray8() {
final gray = Uint8List(width * height);
for (var y = 0; y < height; y++) {
var offset = y * width;
for (var x = 0; x < width; x++) {
final p = _canvas.getPixel(x, y);
gray[offset + x] = (p.r.toInt() * 299 + p.g.toInt() * 587 + p.b.toInt() * 114) ~/ 1000 & 0xff;
}
}
return gray;
}
}
+3 -2
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import '../server/protocol.dart';
import 'cups_backend.dart';
import 'pavo/pavo_backend.dart';
/// 打印后端抽象:Windows 走 HiTi PavoApi 直打(分层 BITMAP);macOS 走系统 CUPS 队列。
abstract class PrintBackend {
@@ -17,10 +18,10 @@ abstract class PrintBackend {
String osBackendName() => Platform.isWindows ? 'pavo' : 'cups';
/// 创建当前操作系统的真实打印后端(非 mock)。
/// PavoPrintBackend 构造不加载 DLL(延迟到实际调用的子 isolate 内),mac 上亦可安全实例化/分析。
PrintBackend createOsBackend({void Function(String line)? onLog}) {
if (Platform.isWindows) {
// Phase 3:PavoApi ffi 直打(待 Windows 实机联调)
throw UnsupportedError('Windows PavoApi 后端尚未实现(Phase 3)');
return PavoPrintBackend(onLog: onLog);
}
return CupsPrintBackend(onLog: onLog);
}
+121
View File
@@ -0,0 +1,121 @@
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import '../../image/card_image.dart';
import '../../server/protocol.dart';
import '../backend.dart';
import '../cups_backend.dart' show noopLog;
import 'pavo_ffi.dart';
import 'pavo_printer.dart';
/// Windows 打印后端:HiTi PavoApi 直打(BGR/K 分层 BITMAP)。
///
/// 所有 ffi 调用(含像素转换的 CPU 密集计算)都放进 `Isolate.run`:
/// PavoApi 的同步调用最长可阻塞数秒(发送 2MB 像素)、打印轮询最长 3 分钟,
/// 放独立 isolate 保证主 isolate 的 HTTP 服务(任务状态轮询)不被卡住。
/// DLL 与非托管指针都不跨 isolate(闭包内自建自释放)。
class PavoPrintBackend implements PrintBackend {
PavoPrintBackend({void Function(String line)? onLog}) : _log = onLog ?? noopLog;
final void Function(String line) _log;
@override
String get name => 'pavo';
@override
Future<PrinterStatus> getStatus() => Isolate.run(_statusSync);
PrinterStatus _statusSync() {
try {
final api = PavoApi();
final printers = PavoPrinter.enumUsbPrinters(api);
if (printers.isEmpty) {
return PrinterStatus.offline('打印机未连接');
}
final printer = printers.first.toNativeUtf16();
try {
final status = PavoPrinter.getStatus(api, printer);
final (ribbonType, remainCount) = PavoPrinter.getRibbonInfo(api, printer);
final dto = PrinterStatus(
online: true,
model: PavoPrinter.getDeviceInfoText(api, printer, PavoApi.devinfoModelName),
firmware: PavoPrinter.getDeviceInfoText(api, printer, PavoApi.devinfoFirmwareVersion),
connection: 'USB',
ribbon: RibbonInfo(
type: PavoPrinter.ribbonTypeName(ribbonType),
// 设备返回剩余张数,按常见 100 张装 YMCKO 色带折算百分比
remain: ribbonType == PavoApi.ribbonMissing ? -1 : (remainCount > 100 ? 100 : remainCount),
remainCount: remainCount,
),
status: status,
statusText: PavoPrinter.statusTextOf(status),
);
if (status == PavoApi.dsCardLow || status == PavoApi.dsCardOut) {
dto.cardLow = true;
}
return dto;
} finally {
calloc.free(printer);
}
} catch (e) {
return PrinterStatus.offline('状态读取失败:$e');
}
}
@override
Future<void> printOneCard(PrintRequest request) {
_log('Pavo 直打任务开始(份数 ${request.copies},后端在子 isolate 执行)');
final front = request.front!;
final back = request.back;
// 只把 data URL 字符串与份数传进 isolate;图层转换与 ffi 调用全部在子 isolate 内完成
final frontColor = front.color;
final frontK = front.k;
final backColor = back?.color;
final backK = back?.k;
final copies = request.copies;
return Isolate.run(() async {
final api = PavoApi();
final printers = PavoPrinter.enumUsbPrinters(api);
if (printers.isEmpty) {
throw StateError('未找到 USB 连接的 HiTi 证卡打印机');
}
final printer = printers.first.toNativeUtf16();
try {
final frontImg = CardImage.fromDataUrl(frontColor);
final frontBgr = frontImg.toBgr24();
final Uint8List? frontKGray;
if (frontK != null && frontK.isNotEmpty) {
frontKGray = CardImage.fromDataUrl(frontK).toGray8();
} else {
frontKGray = null;
}
final Uint8List? backBgr;
final Uint8List? backKGray;
if (backColor != null && backColor.isNotEmpty) {
final backImg = CardImage.fromDataUrl(backColor);
backBgr = backImg.toBgr24();
backKGray =
backK != null && backK.isNotEmpty ? CardImage.fromDataUrl(backK).toGray8() : null;
} else {
backBgr = null;
backKGray = null;
}
await PavoPrinter.printOneCard(
api,
printer,
frontBgr: frontBgr,
frontK: frontKGray,
backBgr: backBgr,
backK: backKGray,
isLandscape: frontImg.isLandscape,
copies: copies,
);
} finally {
calloc.free(printer);
}
});
}
}
+214
View File
@@ -0,0 +1,214 @@
import 'dart:ffi';
import 'package:ffi/ffi.dart';
/// HiTi CS-2xx PavoApi 的 dart:ffi 绑定。
///
/// 依据 SDK Include/PavoApi.h(PrinterCardSDK_1_11_25)与 C# 版 Hiti/PavoApi.cs 翻译;
/// 使用宽字符 DLL `PavoApiW_x64.dll`。C# CharSet.Unicode 的导出名回退行为
/// (先找 PAVO_xxxW 再回退 PAVO_xxx)在此用 try-catch 复刻。
/// 返回值约定:0 = 成功,非 0 = 错误码(ds* 状态码常量)。
///
/// 所有导出函数只允许在 Windows 上实际调用(DLL 加载失败即抛错);
/// 结构体定义本身跨平台可编译,便于 mac 上 analyze 与单测。
class PavoApi {
PavoApi() : _lib = DynamicLibrary.open(_dllName) {
_enumUsb = _tryEnum('PAVO_EnumUSBCardPrintersW') ?? _tryEnum('PAVO_EnumUSBCardPrinters')!;
_checkStatus =
_tryCheckStatus('PAVO_CheckPrinterStatusW') ?? _tryCheckStatus('PAVO_CheckPrinterStatus')!;
_getDeviceInfo =
_tryGetDeviceInfo('PAVO_GetDeviceInfoW') ?? _tryGetDeviceInfo('PAVO_GetDeviceInfo')!;
_printOneCard = _tryPrintOneCard('PAVO_PrintOneCardW') ?? _tryPrintOneCard('PAVO_PrintOneCard')!;
}
static const String _dllName = 'PavoApiW_x64.dll';
// ---------------- 状态码(PAVO_DS_*,CheckPrinterStatus 输出 / 任务错误码) ----------------
static const int dsBusy = 0x00080000;
static const int dsOffline = 0x00000080;
static const int dsPrinting = 0x00000002;
static const int dsProcessingData = 0x00000005;
static const int dsSendingData = 0x00000006;
static const int dsCardJam1 = 0x00030000;
static const int dsCardJam2 = 0x00040000;
static const int dsCardJam3 = 0x00050000;
static const int dsCmdSeqError = 0x000301FE;
static const int dsPrintFail1 = 0x00030002;
static const int dsPrintFail2 = 0x00030003;
static const int dsCoverOpen = 0x00050001;
static const int dsCardOut = 0x00008000;
static const int dsCardLow = 0x00008001;
static const int dsCardThickWrong = 0x00008010;
static const int dsRibbonMissing = 0x00080004;
static const int dsOutOfRibbon = 0x00080103;
static const int dsUnsupportRibbon = 0x000806FE;
static const int dsUnknownRibbon = 0x000808FE;
static const int dsNoFlipperModule = 0x1100000D;
static const int dsWriteFail = 0x0000001F;
static const int dsReadFail = 0x0000002F;
static const int locked = 0x00007540;
// ---------------- GetDeviceInfo 信息类型 ----------------
static const int devinfoMfgSerial = 1;
static const int devinfoModelName = 2;
static const int devinfoFirmwareVersion = 3;
static const int devinfoRibbonInfo = 4;
static const int devinfoPrintCount = 5;
// ---------------- 色带类型 ----------------
static const int ribbonTypeYmcko = 0;
static const int ribbonTypeK = 1;
static const int ribbonTypeKo = 3;
static const int ribbonTypeYmckok = 4;
static const int ribbonTypeHalfYmcko = 5;
static const int ribbonTypeYmckfo = 12;
static const int ribbonMissing = 255;
final DynamicLibrary _lib;
// 枚举 USB 打印机:两段式(首传 nullptr 取所需大小,再传实际缓冲;缓冲内容编码见 PavoPrinter 解码)
late final int Function(Pointer<Uint8>, Pointer<Uint32>) _enumUsb;
late final int Function(Pointer<Utf16>, Pointer<Uint32>) _checkStatus;
late final int Function(Pointer<Utf16>, int, Pointer<Uint8>, Pointer<Uint32>) _getDeviceInfo;
late final int Function(
Pointer<Utf16>, Pointer<HitiCardPrintParameter>, Pointer<HitiHeatingEnergy>, Pointer<HitiSettings>) _printOneCard;
// 宽字符导出名兼容:先找 xxxW,失败回退原名(对齐 C# CharSet.Unicode 行为)。
int Function(Pointer<Uint8>, Pointer<Uint32>)? _tryEnum(String name) {
try {
return _lib.lookupFunction<Uint32 Function(Pointer<Uint8>, Pointer<Uint32>),
int Function(Pointer<Uint8>, Pointer<Uint32>)>(name);
} on ArgumentError {
return null;
}
}
int Function(Pointer<Utf16>, Pointer<Uint32>)? _tryCheckStatus(String name) {
try {
return _lib.lookupFunction<Uint32 Function(Pointer<Utf16>, Pointer<Uint32>),
int Function(Pointer<Utf16>, Pointer<Uint32>)>(name);
} on ArgumentError {
return null;
}
}
int Function(Pointer<Utf16>, int, Pointer<Uint8>, Pointer<Uint32>)? _tryGetDeviceInfo(String name) {
try {
return _lib.lookupFunction<Uint32 Function(Pointer<Utf16>, Uint32, Pointer<Uint8>, Pointer<Uint32>),
int Function(Pointer<Utf16>, int, Pointer<Uint8>, Pointer<Uint32>)>(name);
} on ArgumentError {
return null;
}
}
int Function(Pointer<Utf16>, Pointer<HitiCardPrintParameter>, Pointer<HitiHeatingEnergy>,
Pointer<HitiSettings>)? _tryPrintOneCard(String name) {
try {
return _lib.lookupFunction<
Uint32 Function(Pointer<Utf16>, Pointer<HitiCardPrintParameter>,
Pointer<HitiHeatingEnergy>, Pointer<HitiSettings>),
int Function(Pointer<Utf16>, Pointer<HitiCardPrintParameter>, Pointer<HitiHeatingEnergy>,
Pointer<HitiSettings>)>(name);
} on ArgumentError {
return null;
}
}
/// 两段式枚举 USB 证卡打印机:buf 传 nullptr 时返回所需缓冲大小。
int enumUsbCardPrinters(Pointer<Uint8> buf, Pointer<Uint32> size) => _enumUsb(buf, size);
/// 查询状态码(0=就绪);返回值非 0 时是 API 层错误码。
int checkPrinterStatus(Pointer<Utf16> printer, Pointer<Uint32> status) => _checkStatus(printer, status);
/// 读设备信息(缓冲写 UTF-16LE;RIBBON_INFO 为 dword[0]=类型 dword[1]=剩余张数)。
int getDeviceInfo(
Pointer<Utf16> printer, int infoType, Pointer<Uint8> data, Pointer<Uint32> dataLen) =>
_getDeviceInfo(printer, infoType, data, dataLen);
/// 单卡直打(分层 BITMAP + 默认加热能量 + 默认设置)。
int printOneCard(
Pointer<Utf16> printer,
Pointer<HitiCardPrintParameter> jobPara,
Pointer<HitiHeatingEnergy> heatEnergy,
Pointer<HitiSettings> settings) =>
_printOneCard(printer, jobPara, heatEnergy, settings);
}
/// wingdi BITMAP:指向裸像素缓冲(x64 下 32 字节,Dart Struct 按 C ABI 自然对齐,布局一致)。
final class GdiBitmap extends Struct {
@Int32()
external int bmType;
@Int32()
external int bmWidth;
@Int32()
external int bmHeight;
@Uint32()
external int bmWidthBytes;
@Uint16()
external int bmPlanes;
@Uint16()
external int bmBitsPixel;
external Pointer<Uint8> bmBits;
}
/// PAVO_PrintOneCard 参数。像素约定(头文件注释):
/// BGR 层 24bit BGRBGR…;K 层 8bit,0x00 的像素由树脂 K 转印到卡面;O 层 8bit,0xFF 的像素转印覆膜。
final class HitiCardPrintParameter extends Struct {
@Uint32()
external int dwSize;
@Uint32()
external int dwReserve1;
@Uint32()
external int dwReserve2;
@Uint32()
external int dwFlags;
/// 1=portrait 2=landscape
@Uint8()
external int byOrientation;
/// 0=0.3mm 1=0.5mm 2=0.8mm 3=1.0mm
@Uint8()
external int byCardThickness;
/// 0=白卡 1=透明卡
@Uint8()
external int byTransparentCard;
@Uint8()
external int byReserve4;
@Uint8()
external int byReserve5;
@Uint8()
external int byReserve6;
@Uint8()
external int byReserve7;
@Uint8()
external int byReserve8;
external Pointer<Uint8> lpFrontBGR;
external Pointer<Uint8> lpFrontK;
external Pointer<Uint8> lpFrontO;
external Pointer<Uint8> lpBackBGR;
external Pointer<Uint8> lpBackK;
external Pointer<Uint8> lpBackO;
external Pointer<Uint8> lpReserve1;
external Pointer<Uint8> lpReserve2;
}
/// 加热能量,全 0 表示用驱动默认值(x64 下 16 字节)。
final class HitiHeatingEnergy extends Struct {
@Array(16)
external Array<Uint8> data;
}
/// 打印附加设置,全 0 即可(x64 下 48 字节)。
final class HitiSettings extends Struct {
@Uint8()
external int bSpecialO;
@Array(15)
external Array<Uint8> bReserved;
@Array(8)
external Array<Uint16> wReserved;
@Array(4)
external Array<Uint32> dwReserved;
}
+272
View File
@@ -0,0 +1,272 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import '../../image/card_image.dart';
import 'pavo_ffi.dart';
/// 携带 HiTi 状态码的打印异常(错误码进任务 status,文案进 statusText)。
class HitiException implements Exception {
HitiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => message;
}
/// HiTi CS-2xx 打印机高层操作:枚举、状态、设备信息、单卡直打。
///
/// PavoApi 直打链路(对齐 SDK DemoCSharp buttonDirectPrint 与 C# Hiti/PavoPrinter.cs):
/// PAVO_PrintOneCard(打印机名, 分层 BITMAP, 默认加热能量, 默认设置) → 轮询 CheckPrinterStatus 直到非忙碌。
/// 全部为同步 ffi 调用,必须在独立 isolate 中运行(见 PavoPrintBackend)。
class PavoPrinter {
/// 枚举 USB 连接的证卡打印机名。SDK 缓冲可能是 UTF-8 或 UTF-16,两种都尝试解析。
static List<String> enumUsbPrinters(PavoApi api) {
final sizePtr = calloc<Uint32>();
try {
var ret = api.enumUsbCardPrinters(nullptr, sizePtr);
if (ret != 0 || sizePtr.value == 0) {
return const [];
}
final buf = calloc<Uint8>(sizePtr.value);
try {
final size = sizePtr.value;
sizePtr.value = size;
ret = api.enumUsbCardPrinters(buf, sizePtr);
if (ret != 0) {
return const [];
}
return decodeAuto(buf.asTypedList(size))
.split(RegExp('[\x00\n\r,]'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
} finally {
calloc.free(buf);
}
} finally {
calloc.free(sizePtr);
}
}
/// 按可打印字符占比自动判断 UTF-8 / UTF-16 编码。
static String decodeAuto(Uint8List buf) {
final utf8Text = utf8.decode(buf, allowMalformed: true);
if (printableRatio(utf8Text) >= 0.9) {
return utf8Text;
}
final codes = <int>[];
for (var i = 0; i + 1 < buf.length; i += 2) {
codes.add(buf[i] | (buf[i + 1] << 8));
}
final utf16Text = String.fromCharCodes(codes);
return printableRatio(utf16Text) >= 0.9 ? utf16Text : utf8Text;
}
static double printableRatio(String s) {
if (s.isEmpty) return 0;
var ok = 0;
for (final c in s.codeUnits) {
if ((c >= 0x20 && c <= 0x7e) || (c >= 0x4e00 && c <= 0x9fff) || c == 0) ok++;
}
return ok / s.length;
}
/// 查询设备状态码,0 = 就绪;API 调用失败时返回的就是错误码本身。
static int getStatus(PavoApi api, Pointer<Utf16> printer) {
final statusPtr = calloc<Uint32>();
try {
final ret = api.checkPrinterStatus(printer, statusPtr);
return ret != 0 ? ret : statusPtr.value;
} finally {
calloc.free(statusPtr);
}
}
/// 读字符串型设备信息(序列号/型号/固件,UTF-16LE)。
static String getDeviceInfoText(PavoApi api, Pointer<Utf16> printer, int infoType) {
final buf = calloc<Uint8>(512);
final lenPtr = calloc<Uint32>()..value = 512;
try {
final ret = api.getDeviceInfo(printer, infoType, buf, lenPtr);
if (ret != 0 || lenPtr.value == 0) {
return '';
}
final n = lenPtr.value;
final codes = <int>[];
for (var i = 0; i + 1 < n; i += 2) {
final c = buf[i] | (buf[i + 1] << 8);
if (c != 0) codes.add(c);
}
return String.fromCharCodes(codes);
} finally {
calloc.free(buf);
calloc.free(lenPtr);
}
}
/// 色带信息:(类型码, 剩余张数)。
static (int, int) getRibbonInfo(PavoApi api, Pointer<Utf16> printer) {
final buf = calloc<Uint8>(64);
final lenPtr = calloc<Uint32>()..value = 64;
try {
final ret = api.getDeviceInfo(printer, PavoApi.devinfoRibbonInfo, buf, lenPtr);
if (ret != 0 || lenPtr.value < 8) {
return (PavoApi.ribbonMissing, 0);
}
final v = buf.asTypedList(8);
final type = v[0] | (v[1] << 8) | (v[2] << 16) | (v[3] << 24);
final count = v[4] | (v[5] << 8) | (v[6] << 16) | (v[7] << 24);
return (type, count);
} finally {
calloc.free(buf);
calloc.free(lenPtr);
}
}
static String ribbonTypeName(int type) => switch (type) {
PavoApi.ribbonTypeYmcko => 'YMCKO',
PavoApi.ribbonTypeK => 'Resin K',
PavoApi.ribbonTypeKo => 'KO',
PavoApi.ribbonTypeYmckok => 'YMCKOK',
PavoApi.ribbonTypeHalfYmcko => '1/2 YMCKO',
PavoApi.ribbonTypeYmckfo => 'YMCKFO',
PavoApi.ribbonMissing => '未安装',
_ => '未知($type)',
};
/// 常见状态码中文文案,与前端翻译表一致。
static String statusTextOf(int status) => switch (status) {
0 => '就绪',
PavoApi.dsPrinting => '打印机正在打印',
PavoApi.dsProcessingData => '正在处理打印数据',
PavoApi.dsSendingData => '正在向打印机发送数据',
PavoApi.dsWriteFail => '向打印机发送命令失败',
PavoApi.dsReadFail => '打印机响应失败',
PavoApi.dsOffline => '打印机已断开连接或已关机',
PavoApi.dsCardJam1 => '卡片通道卡纸,请开盖取出卡片',
PavoApi.dsCardJam2 => '翻卡器处卡纸,请开盖取出卡片',
PavoApi.dsCardJam3 => '出卡槽卡纸,请取出卡片',
PavoApi.dsCmdSeqError => '命令序列错误,请重启打印机',
PavoApi.dsCoverOpen => '打印机盖未关闭',
PavoApi.dsCardOut => '缺卡或进卡失败,请补充卡片',
PavoApi.dsCardLow => '卡量不足,请及时补充卡片',
PavoApi.dsCardThickWrong => '卡片厚度选择器未正确安装',
PavoApi.dsBusy => '设备忙碌,请稍候',
PavoApi.dsRibbonMissing => '色带未安装',
PavoApi.dsOutOfRibbon => '色带已用尽,请更换色带',
PavoApi.dsUnsupportRibbon => '不支持的色带类型',
PavoApi.dsUnknownRibbon => '无法识别色带',
PavoApi.dsNoFlipperModule => '翻卡模块未安装',
PavoApi.locked => '打印机已被锁定,请先解锁',
_ => '设备状态异常 0x${status.toRadixString(16).padLeft(8, '0').toUpperCase()}',
};
/// 打印一张卡。frontBgr 为彩色层(BGR),frontK 可选(树脂黑文字层)。
/// isLandscape 由正面图纵横比得出:横向 2 / 纵向 1(SDK byOrientation)。
/// copies 大于 1 时循环提交(直打接口无份数参数)。
static Future<void> printOneCard(
PavoApi api,
Pointer<Utf16> printer, {
required Uint8List frontBgr,
Uint8List? frontK,
Uint8List? backBgr,
Uint8List? backK,
required bool isLandscape,
required int copies,
}) async {
final width = isLandscape ? CardImage.landscapeWidth : CardImage.landscapeHeight;
final height = isLandscape ? CardImage.landscapeHeight : CardImage.landscapeWidth;
final n = copies < 1 ? 1 : copies;
for (var i = 0; i < n; i++) {
final holder = LayerHolder();
final para = calloc<HitiCardPrintParameter>();
final heat = calloc<HitiHeatingEnergy>();
final settings = calloc<HitiSettings>();
try {
para.ref
..dwSize = sizeOf<HitiCardPrintParameter>()
..byOrientation = isLandscape ? 2 : 1
..byCardThickness = 1 // 0.5mm 标准 PVC 卡
..lpFrontBGR = holder.pin(frontBgr, 24, width, height)
..lpFrontK = frontK == null ? nullptr : holder.pin(frontK, 8, width, height)
..lpBackBGR = backBgr == null ? nullptr : holder.pin(backBgr, 24, width, height)
..lpBackK = backK == null ? nullptr : holder.pin(backK, 8, width, height);
// heat/settings 由 calloc 零初始化,等价 C# 全 0(驱动默认能量/设置)
final ret = api.printOneCard(printer, para, heat, settings);
if (ret != 0) {
throw HitiException(ret, 'PAVO_PrintOneCard 返回错误 0x${_hex8(ret)}');
}
// 对齐 C# Demo(using 块内轮询):等固件处理数据后轮询,直到打印机脱离忙碌/打印态;
// 像素内存在轮询完成后才释放(finally)
await Future<void>.delayed(const Duration(seconds: 3));
final status = await waitIdle(api, printer);
if (status != 0) {
throw HitiException(status, '打印中断,设备状态 0x${_hex8(status)}');
}
} finally {
holder.dispose();
calloc.free(para);
calloc.free(heat);
calloc.free(settings);
}
}
}
/// 轮询直到非 BUSY/PRINTING/PROCESSING/SENDING;超时 3 分钟抛错。
static Future<int> waitIdle(PavoApi api, Pointer<Utf16> printer) async {
final deadline = DateTime.now().add(const Duration(minutes: 3));
while (DateTime.now().isBefore(deadline)) {
final status = getStatus(api, printer);
if (status != PavoApi.dsBusy &&
status != PavoApi.dsPrinting &&
status != PavoApi.dsProcessingData &&
status != PavoApi.dsSendingData) {
return status;
}
await Future<void>.delayed(const Duration(seconds: 1));
}
throw TimeoutException('等待打印机完成超时(3 分钟)');
}
static String _hex8(int v) => v.toRadixString(16).padLeft(8, '0').toUpperCase();
}
/// 打印期间持有非托管像素内存,dispose 统一释放(对齐 C# LayerHolder)。
class LayerHolder {
final _allocations = <Pointer>[];
/// 把像素缓冲包成 GDI BITMAP 结构并拷贝像素到非托管内存,返回 BITMAP 指针。
Pointer<Uint8> pin(Uint8List pixels, int bitsPerPixel, int width, int height) {
final widthBytes = width * (bitsPerPixel ~/ 8);
final bits = malloc<Uint8>(pixels.length);
bits.asTypedList(pixels.length).setAll(0, pixels);
final bmp = malloc<GdiBitmap>();
bmp.ref
..bmType = 0
..bmWidth = width
..bmHeight = height
..bmWidthBytes = widthBytes
..bmPlanes = 1
..bmBitsPixel = bitsPerPixel
..bmBits = bits;
_allocations
..add(bits)
..add(bmp);
return bmp.cast();
}
void dispose() {
for (final p in _allocations) {
malloc.free(p);
}
_allocations.clear();
}
}