printerxpl-forge 6.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. nse/README.md +204 -0
  2. nse/__init__.py +6 -0
  3. nse/install_nse.py +412 -0
  4. nse/lib/printerxpl.lua +238 -0
  5. nse/scripts/cups-info.nse +74 -0
  6. nse/scripts/cups-queue-info.nse +43 -0
  7. nse/scripts/hp-printers-cve-2022-1026.nse +121 -0
  8. nse/scripts/http-device-mac.nse +107 -0
  9. nse/scripts/http-hp-ilo-info.nse +121 -0
  10. nse/scripts/http-info-xerox-enum.nse +101 -0
  11. nse/scripts/http-vuln-cve2022-1026.nse +158 -0
  12. nse/scripts/lexmark-config.nse +89 -0
  13. nse/scripts/pjl-ready-message.nse +106 -0
  14. nse/scripts/printer-banner.nse +217 -0
  15. nse/scripts/printer-cups-rce.nse +189 -0
  16. nse/scripts/printer-cve-detect.nse +279 -0
  17. nse/scripts/printer-discover.nse +205 -0
  18. nse/scripts/printer-firmware-exposed.nse +219 -0
  19. nse/scripts/printer-hp-pjl.nse +192 -0
  20. nse/scripts/printer-http-ews.nse +293 -0
  21. nse/scripts/printer-ipp-info.nse +235 -0
  22. nse/scripts/printer-lexmark-ipp.nse +203 -0
  23. nse/scripts/printer-passback.nse +204 -0
  24. nse/scripts/printer-pjl-info.nse +146 -0
  25. nse/scripts/printer-printnightmare.nse +211 -0
  26. nse/scripts/printer-snmp-info.nse +176 -0
  27. nse/scripts/printer-vuln-check.nse +256 -0
  28. nse/scripts/snmp-device-mac.nse +93 -0
  29. nse/scripts/snmp-info.nse +146 -0
  30. nse/scripts/snmp-sysdescr.nse +70 -0
  31. printerxpl_forge-6.2.0.dist-info/METADATA +919 -0
  32. printerxpl_forge-6.2.0.dist-info/RECORD +97 -0
  33. printerxpl_forge-6.2.0.dist-info/WHEEL +5 -0
  34. printerxpl_forge-6.2.0.dist-info/entry_points.txt +4 -0
  35. printerxpl_forge-6.2.0.dist-info/licenses/LICENSE +21 -0
  36. printerxpl_forge-6.2.0.dist-info/top_level.txt +4 -0
  37. src/assets/fonts/gunplay.pfa +1671 -0
  38. src/assets/fonts/kshandwrt.pfa +315 -0
  39. src/assets/fonts/laksoner.pfa +2402 -0
  40. src/assets/fonts/paintcans.pfa +9699 -0
  41. src/assets/fonts/stencilod.pfa +4076 -0
  42. src/assets/fonts/takecover.pfa +26138 -0
  43. src/assets/fonts/topsecret.pfa +6652 -0
  44. src/assets/fonts/whoa.pfa +773 -0
  45. src/assets/mibs/HOST-RESOURCES-MIB +1540 -0
  46. src/assets/mibs/Printer-MIB +4389 -0
  47. src/assets/mibs/README.md +9 -0
  48. src/assets/mibs/SNMPv2-MIB +854 -0
  49. src/assets/overlays/hacker.eps +596 -0
  50. src/assets/overlays/smiley.eps +214 -0
  51. src/assets/overlays/smiley2.eps +240 -0
  52. src/core/attack_orchestrator.py +1025 -0
  53. src/core/capabilities.py +323 -0
  54. src/core/destructive_audit.py +430 -0
  55. src/core/discovery.py +488 -0
  56. src/core/osdetect.py +74 -0
  57. src/core/poly_runner.py +579 -0
  58. src/core/printer.py +1426 -0
  59. src/main.py +2134 -0
  60. src/modules/install_printer.py +318 -0
  61. src/modules/login_bruteforce.py +852 -0
  62. src/modules/pcl.py +506 -0
  63. src/modules/pjl.py +3575 -0
  64. src/modules/print_job.py +1290 -0
  65. src/modules/ps.py +1102 -0
  66. src/payloads/__init__.py +98 -0
  67. src/payloads/assets/overlays/notice.eps +9 -0
  68. src/protocols/__init__.py +19 -0
  69. src/protocols/firmware.py +738 -0
  70. src/protocols/ipp.py +216 -0
  71. src/protocols/ipp_attacks.py +609 -0
  72. src/protocols/lpd.py +141 -0
  73. src/protocols/network_map.py +1004 -0
  74. src/protocols/raw.py +173 -0
  75. src/protocols/smb.py +359 -0
  76. src/protocols/ssrf_pivot.py +427 -0
  77. src/protocols/storage.py +587 -0
  78. src/ui/__init__.py +6 -0
  79. src/ui/interactive.py +742 -0
  80. src/ui/spinner.py +112 -0
  81. src/ui/tables.py +132 -0
  82. src/utils/banner_grabber.py +852 -0
  83. src/utils/codebook.py +456 -0
  84. src/utils/config.py +522 -0
  85. src/utils/cve_loader.py +158 -0
  86. src/utils/default_creds.py +134 -0
  87. src/utils/discovery_online.py +1327 -0
  88. src/utils/exploit_manager.py +805 -0
  89. src/utils/fuzzer.py +220 -0
  90. src/utils/helper.py +732 -0
  91. src/utils/local_printers.py +307 -0
  92. src/utils/ml_engine.py +491 -0
  93. src/utils/operators.py +474 -0
  94. src/utils/ports.py +234 -0
  95. src/utils/vuln_scanner.py +823 -0
  96. src/utils/wordlist_loader.py +412 -0
  97. src/version.py +36 -0
@@ -0,0 +1,1290 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ PrinterXPL-Forge — Smart Print Job Sender
5
+ =======================================
6
+ Sends files or raw data to a printer via:
7
+ - IPP / IPPS (TCP 631) — AirPrint-compatible, auto-TLS upgrade
8
+ - LPD (TCP 515) — legacy, RFC 1179, uses ESC/P for Epson inkjets
9
+ - RAW (TCP 9100) — JetDirect passthrough (HP/PCL printers)
10
+
11
+ Format handling (in order of preference):
12
+ .txt → JPEG via Pillow → ESC/P → PostScript
13
+ .jpg/.png/... → ESC/P bitmap via Pillow → raw bytes
14
+ .ps/.eps → sent as-is
15
+ .pcl → sent as-is
16
+ .pdf → Ghostscript → PostScript → raw PDF
17
+ .doc/.docx → LibreOffice → PDF → PostScript
18
+ any → raw binary stream
19
+
20
+ Smart protocol probing:
21
+ - IPP: tests plain TCP first; if 426/connection-reset → auto-retries with TLS
22
+ - LPD: converts payload to ESC/P (native Epson) to avoid stuck-print issues
23
+ - RAW: passthrough; works for HP/PCL printers with port 9100 open
24
+
25
+ Printer readiness:
26
+ - SNMP hrPrinterStatus is checked when pysnmp is installed
27
+ - If printer is busy/printing, a clear warning is shown before sending
28
+ """
29
+ # Author : Andre Henrique (@mrhenrike)
30
+ # GitHub : https://github.com/mrhenrike
31
+ # LinkedIn : https://linkedin.com/in/mrhenrike
32
+ # X/Twitter : https://x.com/mrhenrike
33
+
34
+ from __future__ import annotations
35
+
36
+ import io
37
+ import logging
38
+ import os
39
+ import shutil
40
+ import socket
41
+ import ssl
42
+ import struct
43
+ import subprocess
44
+ import tempfile
45
+ import time
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+ from typing import List, Optional, Tuple
49
+
50
+ _log = logging.getLogger('PrinterXPL-Forge.print_job')
51
+
52
+ UEL = b'\x1b%-12345X'
53
+
54
+ # ── IPP status codes ──────────────────────────────────────────────────────────
55
+ _IPP_OK = 0x0000
56
+ _IPP_STATUS_BUSY = 0x0503 # server-error-busy
57
+ _IPP_DEVICE_ERROR = 0x0507 # server-error-device-error
58
+ _IPP_FORMAT_NOT_SUPPORTED = 0x0408 # client-error-document-format-not-supported
59
+ _IPP_NOT_AUTHORIZED = 0x0403 # client-error-forbidden
60
+ _IPP_NOT_FOUND = 0x0406 # client-error-not-found
61
+ _IPP_OP_NOT_SUPPORTED = 0x0501 # server-error-operation-not-supported
62
+
63
+ _IPP_ERRORS = {
64
+ 0x0400: "Bad Request",
65
+ 0x0401: "Forbidden (authentication required)",
66
+ 0x0402: "Not Authenticated",
67
+ 0x0403: "Forbidden — printer may be hardened",
68
+ 0x0404: "Not Found — printer-uri not recognized",
69
+ 0x0405: "Request Too Large",
70
+ 0x0406: "Printer URI not found",
71
+ 0x0407: "Attributes or values not supported",
72
+ 0x0408: "Document format not supported — printer rejects this MIME type",
73
+ 0x0409: "URI scheme not supported",
74
+ 0x040A: "Charset not supported",
75
+ 0x040B: "Conflicting attributes",
76
+ 0x040C: "Compression not supported",
77
+ 0x040D: "Compressed document too large",
78
+ 0x040E: "Document format error (corrupt/invalid data)",
79
+ 0x040F: "Document access error",
80
+ 0x0500: "Internal server error",
81
+ 0x0501: "Operation not supported by printer",
82
+ 0x0503: "Printer is busy — try again later",
83
+ 0x0505: "Multiple document jobs not supported",
84
+ 0x0506: "Printer device error",
85
+ 0x0507: "Printer device error",
86
+ }
87
+
88
+
89
+ # ── Result ─────────────────────────────────────────────────────────────────────
90
+
91
+ @dataclass
92
+ class PrintJobResult:
93
+ """Result of a print job submission."""
94
+ success: bool
95
+ protocol: str
96
+ host: str
97
+ port: int
98
+ file_path: str
99
+ file_size: int
100
+ job_id: Optional[int] = None
101
+ message: str = ''
102
+ error: str = ''
103
+ hint: str = '' # actionable hint for the operator
104
+ elapsed_ms: float = 0.0
105
+
106
+ def __str__(self) -> str:
107
+ status = 'OK' if self.success else 'FAILED'
108
+ out = (
109
+ f"[{status}] {self.protocol.upper()} {self.host}:{self.port} "
110
+ f"file={self.file_path} size={self.file_size}B "
111
+ f"elapsed={self.elapsed_ms:.0f}ms"
112
+ )
113
+ if self.job_id:
114
+ out += f" job_id={self.job_id}"
115
+ if self.message:
116
+ out += f" msg={self.message}"
117
+ if self.error:
118
+ out += f" err={self.error}"
119
+ if self.hint:
120
+ out += f" hint={self.hint}"
121
+ return out
122
+
123
+
124
+ @dataclass
125
+ class PrinterCapabilities:
126
+ """Quick probe result for a printer's print-job capabilities."""
127
+ host: str
128
+ ipp_available: bool = False
129
+ ipp_requires_tls:bool = False
130
+ ipp_status: int = -1 # last IPP status code
131
+ lpd_available: bool = False
132
+ raw_available: bool = False
133
+ snmp_status: int = -1 # hrPrinterStatus (-1 = unknown)
134
+ formats: List[str] = field(default_factory=list)
135
+
136
+ @property
137
+ def printer_ready(self) -> bool:
138
+ """True if SNMP says the printer is idle (3) or unknown (-1)."""
139
+ return self.snmp_status in (-1, 3)
140
+
141
+ @property
142
+ def printer_busy(self) -> bool:
143
+ return self.snmp_status == 4
144
+
145
+ @property
146
+ def best_protocol(self) -> str:
147
+ if self.ipp_available:
148
+ return 'ipp'
149
+ if self.lpd_available:
150
+ return 'lpd'
151
+ if self.raw_available:
152
+ return 'raw'
153
+ return ''
154
+
155
+
156
+ # ── SNMP helper ───────────────────────────────────────────────────────────────
157
+
158
+ def _snmp_printer_status(host: str, timeout: float = 3.0) -> int:
159
+ """
160
+ Return hrPrinterStatus via SNMP v1 (OID 1.3.6.1.2.1.25.3.5.1.1.1).
161
+ Returns -1 on error or if pysnmp is not installed.
162
+ Status codes: 1=other 2=unknown 3=idle 4=printing 5=warmup
163
+ """
164
+ try:
165
+ from pysnmp.hlapi import ( # type: ignore
166
+ SnmpEngine, CommunityData, UdpTransportTarget, ContextData,
167
+ ObjectType, ObjectIdentity, getCmd,
168
+ )
169
+ for ei, es, eI, vbs in getCmd(
170
+ SnmpEngine(),
171
+ CommunityData('public', mpModel=0),
172
+ UdpTransportTarget((host, 161), timeout=timeout, retries=0),
173
+ ContextData(),
174
+ ObjectType(ObjectIdentity('1.3.6.1.2.1.25.3.5.1.1.1')),
175
+ ):
176
+ if ei or es:
177
+ return -1
178
+ for vb in vbs:
179
+ return int(vb[1])
180
+ except Exception:
181
+ pass
182
+ return -1
183
+
184
+
185
+ _SNMP_STATUS_LABEL = {
186
+ 1: 'other',
187
+ 2: 'unknown',
188
+ 3: 'idle (ready)',
189
+ 4: 'printing — BUSY',
190
+ 5: 'warming up',
191
+ }
192
+
193
+
194
+ # ── Port probe ────────────────────────────────────────────────────────────────
195
+
196
+ def _tcp_open(host: str, port: int, timeout: float = 4.0) -> bool:
197
+ """Return True if the TCP port is open and accepts connections."""
198
+ try:
199
+ with socket.create_connection((host, port), timeout=timeout):
200
+ return True
201
+ except OSError:
202
+ return False
203
+
204
+
205
+ def _probe_ipp(host: str, port: int = 631, timeout: float = 6.0) -> Tuple[bool, bool]:
206
+ """
207
+ Probe IPP endpoint on *port*.
208
+ Returns (available, requires_tls).
209
+ - available=True if the port accepts IPP connections
210
+ - requires_tls=True if plain TCP returns 426 or connection-reset (Epson behaviour)
211
+ """
212
+ def _minimal_ipp() -> bytes:
213
+ """Build a Get-Printer-Attributes request with no document payload."""
214
+ def _s(name: str, value: str, tag: int = 0x44) -> bytes:
215
+ nb = name.encode(); vb = value.encode()
216
+ return (bytes([tag]) + struct.pack('>H', len(nb)) + nb +
217
+ struct.pack('>H', len(vb)) + vb)
218
+ uri = f'ipp://{host}:{port}/ipp/print'
219
+ attrs = b'\x01'
220
+ attrs += _s('attributes-charset', 'utf-8', 0x47)
221
+ attrs += _s('attributes-natural-language', 'en-us', 0x48)
222
+ attrs += _s('printer-uri', uri, 0x45)
223
+ attrs += b'\x03'
224
+ hdr = struct.pack('>BBHI', 1, 1, 0x000B, 1)
225
+ req = hdr + attrs
226
+ return (
227
+ f'POST /ipp/print HTTP/1.1\r\nHost: {host}:{port}\r\n'
228
+ f'Content-Type: application/ipp\r\nContent-Length: {len(req)}\r\n'
229
+ f'Connection: close\r\n\r\n'
230
+ ).encode() + req
231
+
232
+ ipp_req = _minimal_ipp()
233
+
234
+ # 1. Try plain TCP
235
+ try:
236
+ with socket.create_connection((host, port), timeout=timeout) as s:
237
+ s.settimeout(timeout)
238
+ s.sendall(ipp_req)
239
+ resp = b''
240
+ s.settimeout(3)
241
+ try:
242
+ while True:
243
+ c = s.recv(4096)
244
+ if not c:
245
+ break
246
+ resp += c
247
+ except socket.timeout:
248
+ pass
249
+ if resp.startswith(b'HTTP/1.1 426'):
250
+ return True, True # port open, needs TLS
251
+ if resp.startswith(b'HTTP/1.1'):
252
+ return True, False # port open, plain works
253
+ except ConnectionResetError:
254
+ # Epson resets non-TLS connections — treat as "TLS required"
255
+ pass
256
+ except OSError:
257
+ # Port not open
258
+ return False, False
259
+
260
+ # 2. Try TLS
261
+ ctx = ssl.create_default_context()
262
+ ctx.check_hostname = False
263
+ ctx.verify_mode = ssl.CERT_NONE
264
+ try:
265
+ raw = socket.create_connection((host, port), timeout=timeout)
266
+ with ctx.wrap_socket(raw, server_hostname=host) as s:
267
+ s.settimeout(timeout)
268
+ s.sendall(ipp_req)
269
+ resp = b''
270
+ s.settimeout(3)
271
+ try:
272
+ while True:
273
+ c = s.recv(4096)
274
+ if not c:
275
+ break
276
+ resp += c
277
+ except socket.timeout:
278
+ pass
279
+ if resp.startswith(b'HTTP/1.1'):
280
+ return True, True # TLS works
281
+ except OSError:
282
+ pass
283
+
284
+ return False, False
285
+
286
+
287
+ def probe_printer(host: str, timeout: float = 5.0) -> PrinterCapabilities:
288
+ """
289
+ Quickly probe a printer's available protocols and readiness.
290
+ Results are used by send_print_job() to choose the best protocol.
291
+ """
292
+ caps = PrinterCapabilities(host=host)
293
+
294
+ # SNMP status (non-blocking, best-effort)
295
+ caps.snmp_status = _snmp_printer_status(host, timeout=min(timeout, 3.0))
296
+
297
+ # Protocol probes in parallel (sequential to keep deps simple)
298
+ ipp_ok, ipp_tls = _probe_ipp(host, 631, timeout=timeout)
299
+ caps.ipp_available = ipp_ok
300
+ caps.ipp_requires_tls = ipp_tls
301
+
302
+ caps.lpd_available = _tcp_open(host, 515, timeout=timeout)
303
+ caps.raw_available = _tcp_open(host, 9100, timeout=timeout)
304
+
305
+ _log.debug(
306
+ "Printer probe %s: IPP=%s(tls=%s) LPD=%s RAW=%s SNMP=%s",
307
+ host, ipp_ok, ipp_tls, caps.lpd_available, caps.raw_available,
308
+ _SNMP_STATUS_LABEL.get(caps.snmp_status, caps.snmp_status),
309
+ )
310
+ return caps
311
+
312
+
313
+ # ── Format detection & conversion ─────────────────────────────────────────────
314
+
315
+ def _detect_format(path: str) -> str:
316
+ """Return detected format string from file extension and magic bytes."""
317
+ p = Path(path)
318
+ ext_map = {
319
+ '.ps': 'ps', '.eps': 'ps', '.pcl': 'pcl', '.prn': 'raw',
320
+ '.pdf': 'pdf', '.txt': 'text', '.rtf': 'text',
321
+ '.png': 'image', '.jpg': 'image', '.jpeg': 'image',
322
+ '.bmp': 'image', '.gif': 'image', '.tiff': 'image', '.tif': 'image',
323
+ '.doc': 'word', '.docx': 'word', '.odt': 'word',
324
+ }
325
+ fmt = ext_map.get(p.suffix.lower(), 'raw')
326
+ try:
327
+ with open(path, 'rb') as f:
328
+ magic = f.read(8)
329
+ if magic[:4] == b'%PDF':
330
+ return 'pdf'
331
+ if magic[:2] == b'%!':
332
+ return 'ps'
333
+ if magic[:2] == b'\x1b%':
334
+ return 'pcl'
335
+ if magic[:2] == b'\xff\xd8':
336
+ return 'image'
337
+ if magic[:8] == b'\x89PNG\r\n\x1a\n':
338
+ return 'image'
339
+ except OSError:
340
+ pass
341
+ return fmt
342
+
343
+
344
+ def _detect_mime(data: bytes) -> str:
345
+ """Detect MIME type from magic bytes for IPP document-format."""
346
+ if data[:4] == b'%PDF':
347
+ return 'application/pdf'
348
+ if data[:2] == b'%!':
349
+ return 'application/postscript'
350
+ if data[:2] == b'\xff\xd8':
351
+ return 'image/jpeg'
352
+ if data[:8] == b'\x89PNG\r\n\x1a\n':
353
+ return 'image/png'
354
+ if data[:2] == b'BM':
355
+ return 'image/bmp'
356
+ if data[:6] in (b'GIF87a', b'GIF89a'):
357
+ return 'image/gif'
358
+ if data[:4] == b'\x1b%G':
359
+ return 'application/vnd.hp-PCL'
360
+ if data[:2] == b'\x1b@':
361
+ return 'application/vnd.epson.escpr' # ESC/P payload
362
+ return 'application/octet-stream'
363
+
364
+
365
+ def _text_to_ps(text: str, copies: int = 1) -> bytes:
366
+ """Wrap plain text in a minimal PostScript document (laser printers)."""
367
+ lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
368
+ out = []
369
+ for _ in range(copies):
370
+ out += [
371
+ '%!PS-Adobe-3.0', '%%Pages: 1', '%%EndComments',
372
+ '/Courier 10 selectfont', '%%Page: 1 1',
373
+ ]
374
+ y = 750
375
+ for line in lines[:72]:
376
+ safe = line.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
377
+ out.append(f'72 {y} moveto')
378
+ out.append(f'({safe}) show')
379
+ y -= 14
380
+ if y < 50:
381
+ break
382
+ out += ['showpage', '%%Trailer', '%%EOF']
383
+ return '\n'.join(out).encode('latin-1', errors='replace')
384
+
385
+
386
+ def _text_to_escp(text: str, copies: int = 1) -> bytes:
387
+ """
388
+ Render text as ESC/P stream — native Epson language, zero dependencies.
389
+ Works on all Epson inkjets and dot-matrix printers via LPD passthrough.
390
+ """
391
+ ESC = b'\x1b'
392
+ buf = bytearray()
393
+ for _ in range(copies):
394
+ buf += ESC + b'@' # init / reset
395
+ buf += ESC + b'\x33\x18' # 24/180-inch line spacing
396
+ lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
397
+ for line in lines:
398
+ # Normalize common unicode punctuation to ASCII equivalents
399
+ normalized = (
400
+ line.replace('\u2014', '-') # em dash → hyphen
401
+ .replace('\u2013', '-') # en dash → hyphen
402
+ .replace('\u2018', "'") # left single quote
403
+ .replace('\u2019', "'") # right single quote
404
+ .replace('\u201c', '"') # left double quote
405
+ .replace('\u201d', '"') # right double quote
406
+ .replace('\u2026', '...') # ellipsis
407
+ .replace('\u00b7', '.') # middle dot
408
+ )
409
+ buf += normalized.encode('latin-1', errors='replace') + b'\r\n'
410
+ buf += b'\x0c' # form-feed — eject page
411
+ return bytes(buf)
412
+
413
+
414
+ def _image_to_pwg_raster(img: object, dpi: int = 150, color: bool = True) -> bytes:
415
+ """
416
+ Convert a PIL Image to PWG Raster (image/pwg-raster, RaS3 sync word).
417
+
418
+ PWG Raster is the AirPrint/Mopria native format accepted by Epson
419
+ L-series inkjets via IPP/IPPS. Field offsets follow cups_page_header2_t
420
+ (CUPS 2.x source: cups/raster.h).
421
+
422
+ Args:
423
+ img: PIL Image object.
424
+ dpi: Output resolution (150 is sufficient for inkjets).
425
+ color: True → srgb_8 (24-bit), False → sgray_8 (8-bit grayscale).
426
+ """
427
+ try:
428
+ from PIL import Image as _PIL # type: ignore
429
+ except ImportError:
430
+ return b''
431
+
432
+ if color:
433
+ img = img.convert('RGB') # type: ignore[union-attr]
434
+ cs, bpc, bpp, nc = 19, 8, 24, 3 # sRGB
435
+ else:
436
+ img = img.convert('L') # type: ignore[union-attr]
437
+ cs, bpc, bpp, nc = 18, 8, 8, 1 # sGray
438
+
439
+ pw = int(8.27 * dpi)
440
+ ph = int(11.69 * dpi)
441
+ ratio = pw / img.width # type: ignore[union-attr]
442
+ new_h = min(int(img.height * ratio), ph) # type: ignore[union-attr]
443
+ img = img.resize((pw, new_h), resample=3) # type: ignore[union-attr]
444
+ W, H = img.size # type: ignore[union-attr]
445
+ bpl = W * (bpp // 8)
446
+
447
+ hdr = bytearray(1796)
448
+
449
+ def _u32(off: int, v: int) -> None:
450
+ struct.pack_into('>I', hdr, off, v)
451
+
452
+ def _f32(off: int, v: float) -> None:
453
+ struct.pack_into('>f', hdr, off, v)
454
+
455
+ def _stn(off: int, s: str, ln: int = 64) -> None:
456
+ b = s.encode('ascii')[:ln - 1]
457
+ hdr[off:off + len(b)] = b
458
+ hdr[off + len(b)] = 0
459
+
460
+ # String fields (4 × 64 B = offsets 0-255)
461
+ _stn(0, 'PwgRaster') # MediaClass — REQUIRED for PWG Raster (RaS3)
462
+ _stn(64, '')
463
+ _stn(128, 'Plain')
464
+ _stn(192, '') # OutputType — must be empty
465
+
466
+ # uint32 / float fields (offsets 256+)
467
+ for off in range(256, 276, 4):
468
+ _u32(off, 0)
469
+ _u32(276, dpi); _u32(280, dpi) # HWResolution x, y
470
+ _u32(284, 0); _u32(288, 0); _u32(292, 595); _u32(296, 842) # ImagingBBox
471
+ for off in range(300, 340, 4):
472
+ _u32(off, 0)
473
+ _u32(340, 1) # NumCopies
474
+ _u32(344, 0) # Orientation (portrait)
475
+ _u32(348, 0) # OutputFaceUp
476
+ _u32(352, 595); _u32(356, 842) # PageSize A4 in pts (1 pt = 1/72 in)
477
+ for off in range(360, 372, 4):
478
+ _u32(off, 0)
479
+ _u32(372, W); _u32(376, H) # cupsWidth, cupsHeight (pixels)
480
+ _u32(380, 0)
481
+ _u32(384, bpc); _u32(388, bpp); _u32(392, bpl)
482
+ _u32(396, 0) # cupsColorOrder (chunked)
483
+ _u32(400, cs) # cupsColorSpace (18=sGray, 19=sRGB)
484
+ _u32(404, 0) # cupsCompression (raw, uncompressed)
485
+ for off in range(408, 420, 4):
486
+ _u32(off, 0)
487
+ _u32(420, nc) # cupsNumColors
488
+ _f32(424, 0.0)
489
+ _f32(428, 595.28); _f32(432, 841.89) # cupsPageSize (pts, float)
490
+ _f32(436, 0.0); _f32(440, 0.0)
491
+ _f32(444, 595.28); _f32(448, 841.89) # cupsImagingBBox
492
+ _stn(1732, 'iso_a4_210x297mm') # cupsPageSizeName
493
+
494
+ raster = img.tobytes() # type: ignore[union-attr]
495
+ return b'RaS\x03' + bytes(hdr) + raster
496
+
497
+
498
+ def _image_to_escp_bitmap(img: object) -> bytes: # img: PIL.Image.Image
499
+ """
500
+ Convert a PIL Image to ESC/P 24-pin bitmap stream (ESC * mode 39).
501
+ NOTE: This is a dot-matrix / legacy format — NOT suitable for Epson
502
+ inkjet printers (L-series, EcoTank). Use _image_to_pwg_raster() for
503
+ inkjets when printing via IPP/IPPS.
504
+ """
505
+ ESC = b'\x1b'
506
+
507
+ gray = img.convert('L') # type: ignore[attr-defined]
508
+ max_w = 480
509
+ if gray.width > max_w:
510
+ ratio = max_w / gray.width
511
+ gray = gray.resize((int(gray.width * ratio), int(gray.height * ratio)), resample=1)
512
+ bw = gray.convert('1')
513
+ pix = bw.load()
514
+ width, height = bw.size
515
+
516
+ buf = bytearray()
517
+ buf += ESC + b'@' # init
518
+ buf += ESC + b'\x33\x18' # tight line spacing for bitmap
519
+ BAND = 24
520
+ for y0 in range(0, height, BAND):
521
+ cols = bytearray(width * 3)
522
+ for x in range(width):
523
+ for pin in range(BAND):
524
+ py = y0 + pin
525
+ if py < height and not pix[x, py]: # PIL 1-bit: 0 = black
526
+ bi = x * 3 + pin // 8
527
+ bit = 7 - pin % 8
528
+ cols[bi] |= 1 << bit
529
+ buf += ESC + b'*' + bytes([39])
530
+ buf += struct.pack('<H', width)
531
+ buf += bytes(cols)
532
+ buf += b'\r\n'
533
+ buf += b'\x0c'
534
+ return bytes(buf)
535
+
536
+
537
+ def _text_to_jpeg(text: str, copies: int = 1) -> Optional[bytes]:
538
+ """
539
+ Render text as JPEG via Pillow — best choice for AirPrint/IPP inkjets.
540
+ Returns None if Pillow is unavailable.
541
+ """
542
+ try:
543
+ from PIL import Image, ImageDraw, ImageFont # type: ignore
544
+ W, H, MARGIN, LINE_H, FS = 2480, 3508, 150, 55, 40
545
+ try:
546
+ font = ImageFont.truetype('arial.ttf', FS)
547
+ except (IOError, OSError):
548
+ font = ImageFont.load_default()
549
+
550
+ lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
551
+ max_ln = (H - 2 * MARGIN) // LINE_H
552
+ pages = []
553
+ for start in range(0, max(1, len(lines)), max_ln):
554
+ img = Image.new('RGB', (W, H), (255, 255, 255))
555
+ draw = ImageDraw.Draw(img)
556
+ y = MARGIN
557
+ for ln in lines[start:start + max_ln]:
558
+ draw.text((MARGIN, y), ln, fill=(0, 0, 0), font=font)
559
+ y += LINE_H
560
+ pages.append(img)
561
+
562
+ result = b''
563
+ for img in pages * copies:
564
+ buf = io.BytesIO()
565
+ img.save(buf, format='JPEG', quality=85, dpi=(300, 300))
566
+ result += buf.getvalue()
567
+ return result
568
+ except ImportError:
569
+ return None
570
+
571
+
572
+ def _convert_to_ps(path: str) -> Optional[bytes]:
573
+ """Convert PDF/image/Word to PostScript via Ghostscript or LibreOffice."""
574
+ fmt = _detect_format(path)
575
+ gs = shutil.which('gs') or shutil.which('gswin64c') or shutil.which('gswin32c')
576
+ lo = shutil.which('libreoffice') or shutil.which('soffice')
577
+
578
+ for src_fmt, tool, args in [
579
+ ('pdf', gs, ['-q', '-dNOPAUSE', '-dBATCH', '-sDEVICE=pswrite']),
580
+ ('image', gs, ['-q', '-dNOPAUSE', '-dBATCH', '-sDEVICE=pswrite', '-sPAPERSIZE=a4']),
581
+ ]:
582
+ if fmt == src_fmt and tool:
583
+ try:
584
+ with tempfile.NamedTemporaryFile(suffix='.ps', delete=False) as tmp:
585
+ tp = tmp.name
586
+ r = subprocess.run([tool] + args + [f'-sOutputFile={tp}', path],
587
+ capture_output=True, timeout=30)
588
+ if r.returncode == 0:
589
+ data = Path(tp).read_bytes()
590
+ os.unlink(tp)
591
+ return data
592
+ except (subprocess.TimeoutExpired, OSError):
593
+ pass
594
+
595
+ if fmt == 'word' and lo:
596
+ try:
597
+ with tempfile.TemporaryDirectory() as tmpdir:
598
+ r = subprocess.run([lo, '--headless', '--convert-to', 'pdf',
599
+ '--outdir', tmpdir, path],
600
+ capture_output=True, timeout=30)
601
+ if r.returncode == 0:
602
+ pdf = Path(tmpdir) / (Path(path).stem + '.pdf')
603
+ if pdf.exists():
604
+ ps = _convert_to_ps(str(pdf))
605
+ return ps if ps else pdf.read_bytes()
606
+ except (subprocess.TimeoutExpired, OSError):
607
+ pass
608
+ return None
609
+
610
+
611
+ def _prepare_payload(path: str, copies: int = 1,
612
+ prefer_escp: bool = False,
613
+ prefer_pwg: bool = False) -> Tuple[bytes, str]:
614
+ """
615
+ Prepare the print payload and return (data, description).
616
+
617
+ Args:
618
+ prefer_escp: When True, prefer ESC/P over JPEG/PS (use for LPD → Epson inkjets).
619
+ prefer_pwg: When True, generate PWG Raster for images (use for IPP → Epson inkjets).
620
+ """
621
+ fmt = _detect_format(path)
622
+ raw_data = Path(path).read_bytes()
623
+ _log.debug("Detected format '%s' for %s", fmt, path)
624
+
625
+ if fmt == 'ps':
626
+ return (UEL + f'@PJL SET COPIES={copies}\r\n'.encode() + UEL + raw_data
627
+ if copies > 1 else raw_data, 'PostScript (as-is)')
628
+
629
+ if fmt == 'pcl':
630
+ return raw_data, 'PCL (as-is)'
631
+
632
+ if fmt == 'text':
633
+ text = raw_data.decode('utf-8', errors='replace')
634
+ if prefer_escp:
635
+ data = _text_to_escp(text, copies=copies)
636
+ return data, 'ESC/P (Epson native text mode)'
637
+ jpeg = _text_to_jpeg(text, copies=copies)
638
+ if jpeg:
639
+ return jpeg, 'JPEG via Pillow (300 dpi)'
640
+ data = _text_to_escp(text, copies=copies)
641
+ return data, 'ESC/P (Epson native text mode)'
642
+
643
+ if fmt in ('pdf', 'image', 'word'):
644
+ # 1. PWG Raster (AirPrint/Mopria native) for IPP to Epson inkjets
645
+ if prefer_pwg:
646
+ try:
647
+ from PIL import Image as _PIL # type: ignore
648
+ img = _PIL.open(path) if fmt == 'image' else None
649
+ if img is None:
650
+ raise ValueError("Not a direct image — needs conversion")
651
+ pwg = _image_to_pwg_raster(img, dpi=150, color=True)
652
+ if pwg:
653
+ return pwg, 'PWG Raster srgb_8 @ 150 DPI (AirPrint/Mopria)'
654
+ except Exception as exc:
655
+ _log.debug("PWG Raster conversion failed: %s", exc)
656
+
657
+ # 2. Try Ghostscript/LibreOffice → PostScript
658
+ ps = _convert_to_ps(path)
659
+ if ps:
660
+ return ps, 'PostScript (converted)'
661
+
662
+ # 3. ESC/P bitmap via Pillow — images only (LPD passthrough, NOT for inkjet IPP)
663
+ if prefer_escp and fmt == 'image':
664
+ try:
665
+ from PIL import Image as _PIL # type: ignore
666
+ img = _PIL.open(path)
667
+ data = _image_to_escp_bitmap(img)
668
+ return data, 'ESC/P bitmap via Pillow (LPD passthrough)'
669
+ except (ImportError, Exception):
670
+ pass
671
+
672
+ # 4. For PDF/Word: send raw bytes (some printers parse natively)
673
+ if fmt in ('pdf', 'word'):
674
+ return raw_data, f'{fmt.upper()} (raw bytes — use OS driver for reliable printing)'
675
+
676
+ # 5. Raw image bytes
677
+ _log.warning("No conversion available for '%s'; sending raw bytes", fmt)
678
+ return raw_data, f'raw {fmt} bytes (no conversion tools found)'
679
+
680
+ return raw_data, 'raw binary'
681
+
682
+
683
+ # ── Protocol senders ──────────────────────────────────────────────────────────
684
+
685
+ def send_raw(host: str, port: int, data: bytes, timeout: float = 15.0) -> PrintJobResult:
686
+ """Send data via RAW/JetDirect (TCP 9100) — HP/PCL printers."""
687
+ t0 = time.time()
688
+ try:
689
+ with socket.create_connection((host, port), timeout=timeout) as s:
690
+ s.settimeout(timeout)
691
+ sent = 0
692
+ while sent < len(data):
693
+ n = s.send(data[sent:sent + 4096])
694
+ if n == 0:
695
+ raise OSError("Connection closed by remote")
696
+ sent += n
697
+ return PrintJobResult(
698
+ success=True, protocol='raw', host=host, port=port,
699
+ file_path='', file_size=len(data),
700
+ elapsed_ms=(time.time() - t0) * 1000,
701
+ message=f"Sent {len(data)} bytes via RAW/JetDirect",
702
+ )
703
+ except OSError as exc:
704
+ hint = ''
705
+ err = str(exc)
706
+ if 'refused' in err.lower():
707
+ hint = 'Port 9100 closed — printer may not support RAW/JetDirect'
708
+ elif 'timed out' in err.lower():
709
+ hint = 'Connection timed out — printer unreachable or firewalled'
710
+ return PrintJobResult(
711
+ success=False, protocol='raw', host=host, port=port,
712
+ file_path='', file_size=0,
713
+ elapsed_ms=(time.time() - t0) * 1000,
714
+ error=err, hint=hint,
715
+ )
716
+
717
+
718
+ def _make_tls_ctx() -> ssl.SSLContext:
719
+ ctx = ssl.create_default_context()
720
+ ctx.check_hostname = False
721
+ ctx.verify_mode = ssl.CERT_NONE
722
+ return ctx
723
+
724
+
725
+ def send_ipp(host: str, port: int, data: bytes,
726
+ job_name: str = 'PrinterXPL-Forge-Job',
727
+ doc_format: str = '',
728
+ use_tls: bool = False,
729
+ timeout: float = 30.0) -> PrintJobResult:
730
+ """
731
+ Send data via IPP (HTTP) or IPPS (HTTPS).
732
+
733
+ Auto-upgrades to TLS when:
734
+ - use_tls=True (explicit)
735
+ - plain TCP returns HTTP 426
736
+ - plain TCP connection is reset (Epson firmware behaviour)
737
+ """
738
+ t0 = time.time()
739
+ mime = doc_format or _detect_mime(data)
740
+
741
+ def _s(name: str, value: str, tag: int = 0x44) -> bytes:
742
+ """Encode one IPP attribute per RFC 8011 §3.1.5 (tag + name-len + name + val-len + val)."""
743
+ nb = name.encode(); vb = value.encode()
744
+ return (bytes([tag]) + struct.pack('>H', len(nb)) + nb +
745
+ struct.pack('>H', len(vb)) + vb)
746
+
747
+ def _si(name: str, value: int, tag: int = 0x21) -> bytes:
748
+ """Encode one IPP integer attribute."""
749
+ nb = name.encode()
750
+ return (bytes([tag]) + struct.pack('>H', len(nb)) + nb +
751
+ struct.pack('>Hi', 4, value))
752
+
753
+ # Epson firmware requires ipp:// URI even over TLS connections
754
+ printer_uri = f'ipp://{host}:{port}/ipp/print'
755
+ ipp_attrs = b'\x01'
756
+ ipp_attrs += _s('attributes-charset', 'utf-8', 0x47)
757
+ ipp_attrs += _s('attributes-natural-language', 'en', 0x48)
758
+ ipp_attrs += _s('printer-uri', printer_uri, 0x45)
759
+ ipp_attrs += _s('requesting-user-name', 'PrinterXPL-Forge', 0x42)
760
+ ipp_attrs += _s('job-name', job_name, 0x42)
761
+ ipp_attrs += _s('document-format', mime, 0x49)
762
+ ipp_attrs += b'\x02'
763
+ ipp_attrs += _s('media', 'iso_a4_210x297mm', 0x44)
764
+ ipp_attrs += _si('copies', 1)
765
+ ipp_attrs += _si('print-quality', 4)
766
+ ipp_attrs += b'\x03'
767
+
768
+ ipp_req = struct.pack('>BBHI', 1, 1, 0x0002, 1) + ipp_attrs + data
769
+
770
+ http_req = (
771
+ f'POST /ipp/print HTTP/1.1\r\n'
772
+ f'Host: {host}:{port}\r\n'
773
+ f'Content-Type: application/ipp\r\n'
774
+ f'Content-Length: {len(ipp_req)}\r\n'
775
+ f'Connection: close\r\n\r\n'
776
+ ).encode() + ipp_req
777
+
778
+ def _send(sock: socket.socket) -> bytes:
779
+ sock.settimeout(timeout)
780
+ sock.sendall(http_req)
781
+ resp = b''
782
+ sock.settimeout(8)
783
+ try:
784
+ while True:
785
+ c = sock.recv(8192)
786
+ if not c:
787
+ break
788
+ resp += c
789
+ except socket.timeout:
790
+ pass
791
+ return resp
792
+
793
+ resp = b''
794
+ used_tls = use_tls
795
+ try:
796
+ raw = socket.create_connection((host, port), timeout=timeout)
797
+ if use_tls:
798
+ conn = _make_tls_ctx().wrap_socket(raw, server_hostname=host)
799
+ else:
800
+ conn = raw
801
+ with conn:
802
+ resp = _send(conn)
803
+
804
+ # Auto-TLS upgrade: 426 or empty response from reset
805
+ needs_tls = (resp.startswith(b'HTTP/1.1 426') or
806
+ (not resp and not use_tls))
807
+ if needs_tls and not use_tls:
808
+ _log.info("Plain TCP refused — retrying with TLS (IPPS)")
809
+ raw2 = socket.create_connection((host, port), timeout=timeout)
810
+ with _make_tls_ctx().wrap_socket(raw2, server_hostname=host) as tlsc:
811
+ resp = _send(tlsc)
812
+ used_tls = True
813
+
814
+ except ConnectionResetError:
815
+ # Epson forcibly resets non-TLS connections — upgrade to TLS
816
+ if not use_tls:
817
+ _log.info("Connection reset — retrying with TLS (IPPS)")
818
+ try:
819
+ raw2 = socket.create_connection((host, port), timeout=timeout)
820
+ with _make_tls_ctx().wrap_socket(raw2, server_hostname=host) as tlsc:
821
+ resp = _send(tlsc)
822
+ used_tls = True
823
+ except OSError as exc2:
824
+ return PrintJobResult(
825
+ success=False, protocol='ipp', host=host, port=port,
826
+ file_path='', file_size=0,
827
+ elapsed_ms=(time.time() - t0) * 1000,
828
+ error=str(exc2), hint='IPPS upgrade also failed',
829
+ )
830
+ else:
831
+ return PrintJobResult(
832
+ success=False, protocol='ipp', host=host, port=port,
833
+ file_path='', file_size=0,
834
+ elapsed_ms=(time.time() - t0) * 1000,
835
+ error='Connection reset by printer',
836
+ )
837
+ except OSError as exc:
838
+ err = str(exc)
839
+ hint = ''
840
+ if 'refused' in err.lower():
841
+ hint = 'Port 631 closed — printer does not support IPP'
842
+ elif 'timed out' in err.lower():
843
+ hint = 'IPP connection timed out — printer may be firewalled'
844
+ return PrintJobResult(
845
+ success=False, protocol='ipp', host=host, port=port,
846
+ file_path='', file_size=0,
847
+ elapsed_ms=(time.time() - t0) * 1000,
848
+ error=err, hint=hint,
849
+ )
850
+
851
+ # Parse IPP response
852
+ ipp_status = 0xFFFF
853
+ job_id = None
854
+ sep = resp.find(b'\r\n\r\n')
855
+ ipp_resp = resp[sep + 4:] if sep != -1 else b''
856
+ if len(ipp_resp) >= 4:
857
+ ipp_status = struct.unpack('>H', ipp_resp[2:4])[0]
858
+ success = (ipp_status == _IPP_OK)
859
+ else:
860
+ success = b'200' in resp[:100]
861
+
862
+ elapsed = (time.time() - t0) * 1000
863
+ proto_label = 'ipps' if used_tls else 'ipp'
864
+
865
+ if success:
866
+ return PrintJobResult(
867
+ success=True, protocol=proto_label, host=host, port=port,
868
+ file_path='', file_size=len(data), job_id=job_id,
869
+ elapsed_ms=elapsed,
870
+ message=f"Print-Job accepted ({len(data)} bytes, format={mime})",
871
+ )
872
+
873
+ # Map IPP error to actionable hint
874
+ hint = _classify_ipp_error(ipp_status, mime)
875
+ err_desc = _IPP_ERRORS.get(ipp_status, f'IPP error 0x{ipp_status:04X}')
876
+ return PrintJobResult(
877
+ success=False, protocol=proto_label, host=host, port=port,
878
+ file_path='', file_size=len(data),
879
+ elapsed_ms=elapsed,
880
+ error=err_desc,
881
+ hint=hint,
882
+ )
883
+
884
+
885
+ def _classify_ipp_error(status: int, mime: str) -> str:
886
+ """Return an operator-friendly hint for a given IPP error status."""
887
+ install_hint = (
888
+ "Como alternativa, instale a impressora no SO com --install-printer "
889
+ "e tente imprimir normalmente pelo driver do sistema operacional."
890
+ )
891
+ if status == _IPP_FORMAT_NOT_SUPPORTED:
892
+ return (
893
+ f"A impressora não aceita o formato '{mime}' via IPP. "
894
+ f"Tente --send-proto lpd (LPD/ESC-P) ou: {install_hint}"
895
+ )
896
+ if status == 0x0400: # client-error-bad-request
897
+ return (
898
+ "Requisição IPP rejeitada pela impressora (Bad Request). "
899
+ "A impressora pode estar ocupada ou o formato de dados é incompatível. "
900
+ f"{install_hint}"
901
+ )
902
+ if status == _IPP_STATUS_BUSY:
903
+ return (
904
+ "A impressora está ocupada processando outro job. "
905
+ "Aguarde o LED do painel frontal apagar e tente novamente. "
906
+ f"{install_hint}"
907
+ )
908
+ if status in (_IPP_NOT_AUTHORIZED, 0x0402):
909
+ return (
910
+ "A impressora exige autenticação para impressão via IPP (configuração reforçada). "
911
+ f"{install_hint}"
912
+ )
913
+ if status == _IPP_DEVICE_ERROR:
914
+ return "Erro de hardware na impressora — verifique papel, tinta e tampa."
915
+ if status == _IPP_OP_NOT_SUPPORTED:
916
+ return (
917
+ "A impressora não suporta operação IPP Print-Job via rede. "
918
+ f"{install_hint}"
919
+ )
920
+ return (
921
+ f"Verifique o status da impressora (IPP 0x{status:04X}). "
922
+ f"{install_hint}"
923
+ )
924
+
925
+
926
+ def send_lpd(host: str, port: int, data: bytes,
927
+ queue: str = 'lp',
928
+ job_name: str = 'PrinterXPL-Forge',
929
+ timeout: float = 20.0) -> PrintJobResult:
930
+ """
931
+ Send data via LPD (RFC 1179) on TCP 515.
932
+ Uses passthrough ('l') control-file command so ESC/P data reaches the print engine.
933
+ """
934
+ t0 = time.time()
935
+ try:
936
+ with socket.create_connection((host, port), timeout=timeout) as s:
937
+ s.settimeout(timeout)
938
+
939
+ s.sendall(b'\x02' + queue.encode() + b'\n')
940
+ ack = s.recv(1)
941
+ if ack != b'\x00':
942
+ raise OSError(f"LPD NAK on receive-job: {ack!r}")
943
+
944
+ ctrl = (
945
+ f'H{host}\nP{job_name}\nJ{job_name}\n'
946
+ f'ldfA001{host}\n' # 'l' = passthrough, no filter translation
947
+ f'N{job_name}\n'
948
+ ).encode()
949
+ ctrl_name = f'cfA001{host[:15]}'
950
+ s.sendall(f'\x02{len(ctrl)} {ctrl_name}\n'.encode())
951
+ if s.recv(1) != b'\x00':
952
+ raise OSError("LPD NAK on control-file header")
953
+ s.sendall(ctrl + b'\x00')
954
+ s.recv(1) # ACK for control data
955
+
956
+ data_name = f'dfA001{host[:15]}'
957
+ s.sendall(f'\x03{len(data)} {data_name}\n'.encode())
958
+ if s.recv(1) != b'\x00':
959
+ raise OSError("LPD NAK on data-file header")
960
+ s.sendall(data + b'\x00')
961
+ s.settimeout(8)
962
+ try:
963
+ s.recv(1) # final ACK (may timeout on some printers — OK)
964
+ except socket.timeout:
965
+ pass
966
+
967
+ return PrintJobResult(
968
+ success=True, protocol='lpd', host=host, port=port,
969
+ file_path='', file_size=len(data),
970
+ elapsed_ms=(time.time() - t0) * 1000,
971
+ message=f"LPD job submitted on queue '{queue}' ({len(data)} bytes)",
972
+ )
973
+ except OSError as exc:
974
+ err = str(exc)
975
+ hint = ''
976
+ if 'NAK' in err:
977
+ hint = 'Printer refused the LPD job (hardened or busy). Check queue name.'
978
+ elif 'refused' in err.lower():
979
+ hint = 'Port 515 closed — printer does not support LPD'
980
+ elif 'timed out' in err.lower():
981
+ hint = 'LPD connection timed out — printer may be firewalled or busy'
982
+ return PrintJobResult(
983
+ success=False, protocol='lpd', host=host, port=port,
984
+ file_path='', file_size=0,
985
+ elapsed_ms=(time.time() - t0) * 1000,
986
+ error=err, hint=hint,
987
+ )
988
+
989
+
990
+ def _clear_os_print_queue(printer_name: str = '') -> None:
991
+ """Clear stuck/errored/paused jobs from the OS print queue before sending.
992
+
993
+ On Windows: uses PowerShell Get-PrintJob | Remove-PrintJob.
994
+ On Linux/macOS: uses lprm -a or cancel -a.
995
+
996
+ Args:
997
+ printer_name: Target printer name. Empty → clears all printers' queues.
998
+ """
999
+ import platform
1000
+ os_name = platform.system()
1001
+ try:
1002
+ if os_name == 'Windows':
1003
+ if printer_name:
1004
+ pname_esc = printer_name.replace('"', '\\"')
1005
+ ps_cmd = (
1006
+ f'Get-PrintJob -PrinterName "{pname_esc}" '
1007
+ f'| Remove-PrintJob'
1008
+ )
1009
+ else:
1010
+ ps_cmd = (
1011
+ 'Get-Printer | ForEach-Object { '
1012
+ ' try { Get-PrintJob -PrinterName $_.Name '
1013
+ ' | Remove-PrintJob } catch {} '
1014
+ '}'
1015
+ )
1016
+ subprocess.run(
1017
+ ['powershell', '-NoProfile', '-NonInteractive',
1018
+ '-ExecutionPolicy', 'Bypass', '-Command', ps_cmd],
1019
+ capture_output=True, timeout=15,
1020
+ )
1021
+ else:
1022
+ if printer_name:
1023
+ subprocess.run(['cancel', '-a', '-U', printer_name],
1024
+ capture_output=True, timeout=10)
1025
+ else:
1026
+ subprocess.run(['cancel', '-a'],
1027
+ capture_output=True, timeout=10)
1028
+ except Exception as exc:
1029
+ _log.debug("Queue clear warning (non-fatal): %s", exc)
1030
+
1031
+
1032
+ def send_os_print(path: str, printer_name: str = '',
1033
+ timeout: float = 30.0,
1034
+ clear_queue: bool = True) -> PrintJobResult:
1035
+ """
1036
+ Print a file via the locally installed OS printer (Windows/Linux/macOS).
1037
+
1038
+ Clears stuck/errored jobs from the queue before sending to avoid
1039
+ the printer staying offline or blocked by a previous failed job.
1040
+
1041
+ On Windows: uses PowerShell Start-Process with -Verb PrintTo.
1042
+ On Linux/macOS: uses lpr command.
1043
+
1044
+ Args:
1045
+ path: File to print.
1046
+ printer_name: OS printer name. Empty → system default.
1047
+ timeout: Wait timeout in seconds.
1048
+ clear_queue: If True, clears stuck jobs before printing (default: True).
1049
+ """
1050
+ import platform
1051
+ t0 = time.time()
1052
+ p = Path(path)
1053
+ if not p.exists():
1054
+ return PrintJobResult(
1055
+ success=False, protocol='os', host='localhost', port=0,
1056
+ file_path=path, file_size=0,
1057
+ error=f'File not found: {path}',
1058
+ )
1059
+
1060
+ os_name = platform.system()
1061
+
1062
+ # Clear the print queue before submitting to avoid blocked/offline state
1063
+ if clear_queue:
1064
+ _log.info("Clearing OS print queue before sending job...")
1065
+ _clear_os_print_queue(printer_name)
1066
+ time.sleep(0.5) # brief pause so the spooler resets
1067
+
1068
+ try:
1069
+ if os_name == 'Windows':
1070
+ # Choose the best print method based on file type
1071
+ suffix = p.suffix.lower()
1072
+ img_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'}
1073
+
1074
+ if suffix in img_exts:
1075
+ # Images: use mspaint /pt which works for common image formats
1076
+ mspaint = shutil.which('mspaint') or 'mspaint'
1077
+ args_list = [mspaint, '/pt', str(p.resolve())]
1078
+ if printer_name:
1079
+ args_list.append(printer_name)
1080
+ r = subprocess.run(args_list, capture_output=True, timeout=timeout)
1081
+ elif suffix == '.txt':
1082
+ # Plain text: use notepad /pt
1083
+ notepad = shutil.which('notepad') or 'notepad'
1084
+ args_list = [notepad, '/pt', str(p.resolve())]
1085
+ if printer_name:
1086
+ args_list += [printer_name, printer_name, '']
1087
+ r = subprocess.run(args_list, capture_output=True, timeout=timeout)
1088
+ else:
1089
+ # PDF and others: try -Verb PrintTo via PowerShell
1090
+ pname_esc = printer_name.replace('"', '\\"')
1091
+ ps_cmd = (
1092
+ f'Start-Process -FilePath "{p.resolve()}" -Verb PrintTo '
1093
+ + (f'-ArgumentList "{pname_esc}"' if printer_name else '')
1094
+ + ' -Wait -ErrorAction Stop'
1095
+ )
1096
+ r = subprocess.run(
1097
+ ['powershell', '-NoProfile', '-NonInteractive',
1098
+ '-ExecutionPolicy', 'Bypass', '-Command', ps_cmd],
1099
+ capture_output=True, timeout=timeout,
1100
+ )
1101
+
1102
+ success = r.returncode == 0
1103
+ stderr = r.stderr.decode('utf-8', errors='replace').strip()
1104
+ if not success:
1105
+ return PrintJobResult(
1106
+ success=False, protocol='os', host='localhost', port=0,
1107
+ file_path=path, file_size=p.stat().st_size,
1108
+ elapsed_ms=(time.time() - t0) * 1000,
1109
+ error=f'OS print failed (rc={r.returncode}): {stderr}',
1110
+ hint=('Verifique se a impressora está instalada em Configurações → '
1111
+ 'Impressoras. Execute --install-printer para adicioná-la.'),
1112
+ )
1113
+ else:
1114
+ lpr = shutil.which('lpr') or shutil.which('lp')
1115
+ if not lpr:
1116
+ return PrintJobResult(
1117
+ success=False, protocol='os', host='localhost', port=0,
1118
+ file_path=path, file_size=p.stat().st_size,
1119
+ error='lpr/lp not found — CUPS not installed',
1120
+ hint='Install CUPS: sudo apt install cups (Debian/Ubuntu) '
1121
+ 'or brew install cups (macOS)',
1122
+ )
1123
+ cmd = [lpr]
1124
+ if printer_name:
1125
+ cmd += ['-P', printer_name]
1126
+ cmd.append(str(p.resolve()))
1127
+ r = subprocess.run(cmd, capture_output=True, timeout=timeout)
1128
+ success = r.returncode == 0
1129
+ stderr = r.stderr.decode('utf-8', errors='replace').strip()
1130
+ if not success:
1131
+ return PrintJobResult(
1132
+ success=False, protocol='os', host='localhost', port=0,
1133
+ file_path=path, file_size=p.stat().st_size,
1134
+ elapsed_ms=(time.time() - t0) * 1000,
1135
+ error=f'lpr failed (rc={r.returncode}): {stderr}',
1136
+ hint=('Check that the printer is configured in CUPS. '
1137
+ 'Run --install-printer to add it.'),
1138
+ )
1139
+
1140
+ return PrintJobResult(
1141
+ success=True, protocol='os', host='localhost', port=0,
1142
+ file_path=path, file_size=p.stat().st_size,
1143
+ elapsed_ms=(time.time() - t0) * 1000,
1144
+ message=(f'File sent to OS printer '
1145
+ f'"{printer_name or "default"}" via {os_name} spooler'),
1146
+ )
1147
+ except subprocess.TimeoutExpired:
1148
+ return PrintJobResult(
1149
+ success=False, protocol='os', host='localhost', port=0,
1150
+ file_path=path, file_size=p.stat().st_size,
1151
+ elapsed_ms=(time.time() - t0) * 1000,
1152
+ error='OS print command timed out',
1153
+ hint='Printer may be offline or unresponsive.',
1154
+ )
1155
+ except OSError as exc:
1156
+ return PrintJobResult(
1157
+ success=False, protocol='os', host='localhost', port=0,
1158
+ file_path=path, file_size=p.stat().st_size,
1159
+ elapsed_ms=(time.time() - t0) * 1000,
1160
+ error=str(exc),
1161
+ )
1162
+
1163
+
1164
+ # ── Public API ────────────────────────────────────────────────────────────────
1165
+
1166
+ def send_print_job(
1167
+ host: str,
1168
+ path: str,
1169
+ protocol: str = 'auto',
1170
+ port: int = 0,
1171
+ copies: int = 1,
1172
+ queue: str = 'lp',
1173
+ timeout: float = 25.0,
1174
+ caps: Optional[PrinterCapabilities] = None,
1175
+ ) -> PrintJobResult:
1176
+ """
1177
+ High-level print-job sender with automatic protocol/format selection.
1178
+
1179
+ Args:
1180
+ host: Target IP or hostname.
1181
+ path: File to print (absolute or relative path).
1182
+ protocol: 'auto' (smart probe) | 'ipp' | 'lpd' | 'raw'.
1183
+ port: Override default port (0 = use protocol default).
1184
+ copies: Number of copies to print.
1185
+ queue: LPD queue name (default 'lp').
1186
+ timeout: Socket timeout in seconds.
1187
+ caps: Pre-computed PrinterCapabilities (skips probe if provided).
1188
+
1189
+ Returns:
1190
+ PrintJobResult with .success, .message, .error, .hint.
1191
+ """
1192
+ proto = protocol.lower().strip()
1193
+ p = Path(path)
1194
+
1195
+ if not p.exists():
1196
+ return PrintJobResult(
1197
+ success=False, protocol=proto or 'unknown',
1198
+ host=host, port=port, file_path=path, file_size=0,
1199
+ error=f"File not found: {path}",
1200
+ )
1201
+
1202
+ # Probe printer if auto-selecting protocol
1203
+ if caps is None and (proto == 'auto' or not proto):
1204
+ _log.info("Probing printer %s for capabilities...", host)
1205
+ caps = probe_printer(host, timeout=min(timeout, 6.0))
1206
+
1207
+ # Resolve protocol
1208
+ if proto in ('auto', ''):
1209
+ if caps:
1210
+ proto = caps.best_protocol or 'ipp'
1211
+ else:
1212
+ proto = 'ipp'
1213
+
1214
+ # Resolve port
1215
+ default_ports = {'raw': 9100, 'ipp': 631, 'lpd': 515}
1216
+ actual_port = port or default_ports.get(proto, 9100)
1217
+ use_tls = bool(caps and caps.ipp_requires_tls) if proto == 'ipp' else False
1218
+
1219
+ # Prepare payload
1220
+ prefer_escp = (proto == 'lpd')
1221
+ prefer_pwg = (proto == 'ipp') # IPP to Epson inkjets → PWG Raster
1222
+ try:
1223
+ payload, fmt_desc = _prepare_payload(
1224
+ str(p), copies=copies,
1225
+ prefer_escp=prefer_escp,
1226
+ prefer_pwg=prefer_pwg,
1227
+ )
1228
+ except Exception as exc:
1229
+ return PrintJobResult(
1230
+ success=False, protocol=proto, host=host, port=actual_port,
1231
+ file_path=path, file_size=0,
1232
+ error=f"Payload preparation failed: {exc}",
1233
+ )
1234
+
1235
+ _log.info("Sending %s → %s:%d via %s (format: %s, %d bytes)",
1236
+ path, host, actual_port, proto.upper(), fmt_desc, len(payload))
1237
+
1238
+ # Dispatch
1239
+ result: PrintJobResult
1240
+ if proto == 'raw':
1241
+ result = send_raw(host, actual_port, payload, timeout=timeout)
1242
+
1243
+ elif proto == 'ipp':
1244
+ mime = _detect_mime(payload)
1245
+ result = send_ipp(
1246
+ host, actual_port, payload,
1247
+ job_name = p.name,
1248
+ doc_format = mime,
1249
+ use_tls = use_tls,
1250
+ timeout = timeout,
1251
+ )
1252
+ # Fallback 1: LPD (ESC/P) if available
1253
+ if not result.success and caps and caps.lpd_available:
1254
+ _log.info("IPP rejected — falling back to LPD with ESC/P")
1255
+ escp_payload, escp_desc = _prepare_payload(
1256
+ str(p), copies=copies, prefer_escp=True)
1257
+ lpd_result = send_lpd(host, 515, escp_payload, queue=queue,
1258
+ job_name=p.stem, timeout=timeout)
1259
+ if lpd_result.success:
1260
+ lpd_result.message = f"[IPP failed → LPD fallback] {lpd_result.message}"
1261
+ result = lpd_result
1262
+
1263
+ # Annotate failure with OS install suggestion
1264
+ if not result.success:
1265
+ _log.info("All network protocols failed for %s — OS printing may work", path)
1266
+ if not result.hint:
1267
+ result.hint = (
1268
+ "Não foi possível imprimir via protocolos de rede (IPP/LPD). "
1269
+ "Tente instalar a impressora no SO com --install-printer e "
1270
+ "imprimir normalmente pelo spooler do sistema operacional."
1271
+ )
1272
+
1273
+ elif proto == 'lpd':
1274
+ result = send_lpd(host, actual_port, payload, queue=queue,
1275
+ job_name=p.stem, timeout=timeout)
1276
+ if not result.success and not result.hint:
1277
+ result.hint = (
1278
+ "Falha no envio via LPD. Se a impressora estiver instalada "
1279
+ "localmente via driver do SO, tente imprimir normalmente. "
1280
+ "Caso contrário, use --install-printer."
1281
+ )
1282
+ else:
1283
+ return PrintJobResult(
1284
+ success=False, protocol=proto, host=host, port=actual_port,
1285
+ file_path=path, file_size=0, error=f"Unknown protocol: {proto}",
1286
+ )
1287
+
1288
+ result.file_path = path
1289
+ result.file_size = len(payload)
1290
+ return result