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> _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 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 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(back.color); } final copies = request.copies < 1 ? 1 : request.copies; for (var i = 0; i < copies; i++) { await runCommand('lp', ['-d', queue.name, frontFile]); if (backFile != null) { await runCommand('lp', ['-d', queue.name, backFile]); } } _log('已提交到 CUPS 队列 ${queue.name}(份数 $copies${backFile != null ? ',含背面' : ''})'); } finally { await _deleteQuiet(frontFile); await _deleteQuiet(backFile); } } /// data URL PNG 落临时文件供 lp 提交。 Future _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 _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; }