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;
}
}