Files
desktop-plugin/lib/print/cups_backend.dart
T
2026-09-23 16:49:42 +08:00

203 lines
8.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import '../server/protocol.dart';
import '../utils/command_runner.dart';
import 'backend.dart';
/// 默认空日志(构造初始化列表里不能写 `(_) {}` 块体闭包,会被当成构造体花括号)。
void noopLog(String line) {}
/// macOS 打印后端:HiTi 官方驱动(HiTiCardTypeMacDrivers pkg)安装后打印机成为系统
/// CUPS 队列,直接把前端渲染好的 PNG 交给队列(lp 命令),驱动负责光栅化与色带分色。
///
/// 说明:CUPS 路径无树脂 K 层直控与色带余量读取,由驱动默认分色策略处理;
/// 单面机(CS-200e)的背面按第二个独立作业顺序提交。
class CupsPrintBackend implements PrintBackend {
CupsPrintBackend({void Function(String line)? onLog}) : _log = onLog ?? noopLog;
final void Function(String line) _log;
int _tempSeq = 0;
@override
String get name => 'cups';
/// 打印队列信息(来自 lpoptions,字段纯 ASCII 不受系统语言本地化影响)。
static const _noQueueHint =
'未添加打印机:① 安装 HiTi Mac 驱动(HiTiCardTypeMacDrivers dmg 内的 pkg)→ ② USB 连接打印机 → ③ 系统设置→打印机与扫描仪 确认出现 HiTi 打印机后点刷新';
/// 发现打印队列。
///
/// macOS 会把 lpstat 输出本地化(中文系统"打印机xx正在接受请求"无空格粘连,LC_ALL=C 压不住),
/// 因此首选 lpoptions:printer-uri-supported=ipp://localhost/printers/<队列名> 纯 ASCII;
/// printer-state 为标准 IPP 状态(3 空闲 / 4 打印中 / 5 停止)。
/// 兜底:lpstat -v 的中英双正则(用于(x)的设备 / device for (x):)。
Future<List<_QueueInfo>> _discoverQueues() async {
final result = <_QueueInfo>[];
try {
final lpopt = await runCommand('lpoptions', const [], tolerateExit: true);
final uri = RegExp(r'printer-uri-supported=\S*/printers/(\S+)').firstMatch(lpopt);
if (uri != null) {
final name = uri.group(1)!;
final state = RegExp(r'printer-state=(\d)').firstMatch(lpopt);
final model = RegExp(r"printer-make-and-model='([^']*)'").firstMatch(lpopt);
result.add(_QueueInfo(
name,
model?.group(1) ?? '',
state != null ? int.parse(state.group(1)!) : 3,
));
}
} on CommandException {
// lpoptions 不可用时走 lpstat -v 兜底
}
if (result.isNotEmpty) {
return result;
}
final vout = await runCommand('lpstat', const ['-v'], tolerateExit: true);
for (final raw in vout.split('\n')) {
final line = raw.trim();
if (line.isEmpty) continue;
// 英文 "device for NAME: ..." / 中文 "用于NAME的设备:..."
final m = RegExp(r'device for (\S+):|用于(\S+)的设备').firstMatch(line);
if (m != null) {
final name = m.group(1) ?? m.group(2) ?? '';
if (name.isNotEmpty && !result.any((q) => q.name == name)) {
result.add(_QueueInfo(name, '', 3));
}
}
}
return result;
}
_QueueInfo? _pickQueue(List<_QueueInfo> queues) {
if (queues.isEmpty) return null;
return queues.where((q) => q.name.toLowerCase().contains('hiti')).firstOrNull ??
queues.where((q) => RegExp(r'CS[-_ ]?\d').hasMatch(q.name)).firstOrNull ??
queues.first;
}
@override
Future<PrinterStatus> getStatus() async {
try {
final queue = _pickQueue(await _discoverQueues());
if (queue == null) {
return PrinterStatus.offline(_noQueueHint);
}
// IPP printer-state:3 空闲 / 4 打印中 / 5 停止
final (status, statusText) = switch (queue.printerState) {
4 => (0x00000002, '打印机正在打印'),
5 => (0x00000080, '打印机已停止:请在 系统设置→打印机与扫描仪 里检查(缺纸/卡纸/暂停)'),
_ => (0, '就绪'),
};
return PrinterStatus(
online: queue.printerState != 5,
model: queue.model.isEmpty ? queue.name : queue.model,
connection: 'USB',
ribbon: RibbonInfo(),
status: status,
statusText: statusText,
);
} catch (e) {
return PrinterStatus.offline('状态读取失败:$e');
}
}
@override
Future<void> printOneCard(PrintRequest request) async {
final queue = _pickQueue(await _discoverQueues());
if (queue == null) {
throw StateError('未找到系统打印队列,请先安装 HiTi Mac 驱动并添加打印机');
}
final frontFile = await _writeTempPng(request.front!.color);
String? backFile;
try {
final back = request.back;
if (back != null && back.color.isNotEmpty) {
backFile = await _writeTempPng(request.back!.color);
}
final copies = request.copies < 1 ? 1 : request.copies;
for (var i = 0; i < copies; i++) {
await _submitAndTrack(queue.name, frontFile);
if (backFile != null) {
await _submitAndTrack(queue.name, backFile);
}
}
_log('打印完成:CUPS 队列 ${queue.name}(份数 $copies${backFile != null ? ',含背面' : ''})');
} finally {
await _deleteQuiet(frontFile);
await _deleteQuiet(backFile);
}
}
/// CUPS 作业跟踪超时(秒):单卡物理打印约 20-30s,留足余量;
/// 超时视为卡纸/缺卡/暂停等异常,任务转失败而非假装完成。
static const _jobTrackTimeoutSec = 170;
/// lp 提交并跟踪到**物理打印完成**(作业离开 CUPS 活动队列)才返回。
///
/// lp 返回只代表"已入队"(约 0.1s),若不跟踪,任务瞬间变"完成",
/// 前端批量进度会整体提前跑满(第一张还没打页面就 100%)。
/// 解析 lp 输出的作业号,轮询 `lpstat -o`(仅列未完成作业)直到作业号消失。
Future<void> _submitAndTrack(String queueName, String file) async {
final out = await runCommand('lp', ['-d', queueName, file]);
// lp 输出被系统本地化(中文"请求id是X(1个文件)",LC_ALL=C 压不住,同 lpstat 的坑),
// 作业号取到空白或中英文括号为止,避免把"(1个文件)"尾巴带进作业号
final m = RegExp(r'request id is\s+([^\s((]+)|请求id是\s*([^\s((]+)').firstMatch(out);
final jobId = (m?.group(1) ?? m?.group(2) ?? '').trim();
if (jobId.isEmpty) {
// 输出格式异常时退化为旧行为(提交即完成),不影响出卡
_log('lp 已提交(未解析到作业号,跳过完成跟踪):${out.trim()}');
return;
}
_log('CUPS 作业 $jobId 已入队,等待物理打印完成…');
final deadline = DateTime.now().add(const Duration(seconds: _jobTrackTimeoutSec));
while (DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(seconds: 1));
String active;
try {
active = await runCommand('lpstat', ['-o'], tolerateExit: true);
} on CommandException {
return; // lpstat 不可用,放弃跟踪(作业仍会打印)
}
if (!active.contains(jobId)) {
_log('CUPS 作业 $jobId 打印完成');
return;
}
}
throw StateError('打印超时:作业 $jobId 长时间未完成,请检查打印机(卡纸/缺卡/被暂停)');
}
/// data URL PNG 落临时文件供 lp 提交。
///
/// 2026-09-23 方向验证:打印机链路做上下翻转,此处曾加 flipVertical 抵消并打出
/// 正向成品(白卡实证);当前按用户要求**临时去掉翻转做消融测试**——预测:上下颠倒。
/// 验证后若需恢复,把 out 改回 img.encodePng(img.flipVertical(decoded))。
Future<String> _writeTempPng(String dataUrl) async {
final comma = dataUrl.indexOf(',');
final base64Part = comma >= 0 ? dataUrl.substring(comma + 1) : dataUrl;
final bytes = base64.decode(base64Part.replaceAll(RegExp(r'\s'), ''));
final path =
'${Directory.systemTemp.path}/card-${DateTime.now().microsecondsSinceEpoch}-${_tempSeq++}.png';
await File(path).writeAsBytes(bytes);
return path;
}
Future<void> _deleteQuiet(String? path) async {
if (path == null) return;
try {
await File(path).delete();
} catch (_) {}
}
}
class _QueueInfo {
const _QueueInfo(this.name, this.model, this.printerState);
final String name;
final String model;
/// IPP printer-state:3 空闲 / 4 打印中 / 5 停止
final int printerState;
}