"""Direct label printing via the Windows GDI printer API (win32print / win32ui). Prints formatted address + invoice labels to any locally installed label printer (Dymo, Zebra, Brother, etc.) without creating a PDF — the job goes straight to the print queue. Usage: from app.labels import print_address_label, list_printers, get_default_printer All public functions fail gracefully when pywin32 is not installed, returning False / [] rather than raising. """ from __future__ import annotations import logging import subprocess from typing import Callable log = logging.getLogger(__name__) # ── Optional win32 imports ──────────────────────────────────────────────────── try: import win32print import win32ui import win32con _WIN32_AVAILABLE = True except ImportError: win32print = None # type: ignore[assignment] win32ui = None # type: ignore[assignment] win32con = None # type: ignore[assignment] _WIN32_AVAILABLE = False log.debug("pywin32 not installed — direct label printing unavailable") # ── Public helpers ───────────────────────────────────────────────────────────── def is_available() -> bool: """Return True if pywin32 is installed and at least one printer exists.""" if not _WIN32_AVAILABLE: return False try: return bool(win32print.EnumPrinters( win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS )) except Exception: return False def list_printers() -> list[str]: """Return a list of locally available printer names.""" if not _WIN32_AVAILABLE: return [] try: return [ p[2] for p in win32print.EnumPrinters( win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS ) ] except Exception as exc: log.debug("Could not enumerate printers: %s", exc) return [] def get_default_printer() -> str: """Return the Windows default printer name, or '' if unavailable.""" if not _WIN32_AVAILABLE: return "" try: return win32print.GetDefaultPrinter() except Exception: return "" def print_address_label( address_lines: list[str], invoice_line: str = "", printer_name: str = "", copies: int = 1, label_width_mm: float = 89.0, label_height_mm: float = 36.0, ) -> bool: """Print an address label (+ optional invoice/reference line) to a printer. Args: address_lines: Lines of the recipient address block. invoice_line: Optional reference/invoice number printed below the address in bold. printer_name: Printer to use; defaults to the system default. copies: Number of copies to print. label_width_mm: Label width in millimetres (default: 89 mm / 3.5 in). label_height_mm: Label height in millimetres (default: 36 mm / 1.4 in). Returns: True on success, False on any failure (caller decides whether to alert). """ if not _WIN32_AVAILABLE: log.warning("print_address_label: pywin32 not available") return False printer = printer_name or get_default_printer() if not printer: log.warning("print_address_label: no printer found") return False try: hPrinter = win32print.OpenPrinter(printer) try: pInfo = win32print.GetPrinter(hPrinter, 2) finally: win32print.ClosePrinter(hPrinter) except Exception as exc: log.error("Could not open printer %r: %s", printer, exc) return False try: hDC = win32ui.CreateDC() hDC.CreatePrinterDC(printer) except Exception as exc: log.error("Could not create printer DC for %r: %s", printer, exc) return False try: for _ in range(max(1, copies)): _print_one( hDC = hDC, address_lines= address_lines, invoice_line = invoice_line, label_w_mm = label_width_mm, label_h_mm = label_height_mm, ) return True except Exception as exc: log.exception("Label print job failed: %s", exc) return False finally: try: hDC.DeleteDC() except Exception: pass def print_part_label( sku: str, description: str, price_net: str = "", grade: str = "", location: str = "", printer_name: str = "", copies: int = 1, ) -> bool: """Print a parts-bin label (SKU prominent, description + price below). Returns True on success, False otherwise. """ lines = [sku] if description: lines.append(description) detail_parts = [x for x in [grade, location, price_net] if x] if detail_parts: lines.append(" · ".join(detail_parts)) return print_address_label( address_lines = lines, printer_name = printer_name, copies = copies, label_width_mm = 57.0, label_height_mm = 32.0, ) # ── Internal GDI rendering ──────────────────────────────────────────────────── def _print_one( hDC, address_lines: list[str], invoice_line: str, label_w_mm: float, label_h_mm: float, ) -> None: """Render one label page on the given printer DC.""" # Device resolution dpi_x = hDC.GetDeviceCaps(win32con.LOGPIXELSX) dpi_y = hDC.GetDeviceCaps(win32con.LOGPIXELSY) # Convert mm → pixels mm_per_inch = 25.4 page_w = int(label_w_mm / mm_per_inch * dpi_x) page_h = int(label_h_mm / mm_per_inch * dpi_y) margin_x = int(page_w * 0.04) margin_y = int(page_h * 0.08) inner_w = page_w - 2 * margin_x inner_h = page_h - 2 * margin_y address_raw_lines = [ln for ln in address_lines if ln.strip()] hDC.StartDoc("Label") hDC.StartPage() _draw_address_block( hDC = hDC, address_raw_lines = address_raw_lines, invoice_line = invoice_line, inner_w = inner_w, inner_h = inner_h, margin_x = margin_x, margin_y = margin_y, dpi_y = dpi_y, ) hDC.EndPage() hDC.EndDoc() def _draw_address_block( hDC, address_raw_lines: list[str], invoice_line: str, inner_w: int, inner_h: int, margin_x: int, margin_y: int, dpi_y: int, start_font_pt: int = 24, ) -> None: """Fit address + invoice text into the available area, scaling font down if needed.""" # Convert font points → logical units (negative = character height in pixels) font_size = -int(dpi_y * start_font_pt / 72) min_size = -int(dpi_y * 8 / 72) # never go below 8pt # Binary-search the largest font that fits both dimensions while font_size >= min_size: addr_font_size = max(14, int(abs(font_size) * 0.80)) font_addr = win32ui.CreateFont({ "name": "Arial", "height": addr_font_size, "weight": 400, }) # Invoice is larger (100% of font_size), bold inv_font_size = font_size font_inv = win32ui.CreateFont({ "name": "Arial", "height": inv_font_size, "weight": 700, # Bold }) hDC.SelectObject(font_addr) # Wrap each address line if it exceeds inner_w final_lines = [] for raw_line in address_raw_lines: wrapped = _wrap_text(hDC, raw_line, inner_w) final_lines.extend(wrapped) line_h = hDC.GetTextExtent("Ag")[1] + 2 # Calculate invoice metrics under this scaling hDC.SelectObject(font_inv) inv_line_h = hDC.GetTextExtent("Ag")[1] + 2 if invoice_line else 0 gap = max(8, inv_font_size // 3) if invoice_line else 0 total_h = (len(final_lines) * line_h) + gap + inv_line_h if total_h <= inner_h: # Ensure no single word overflows horizontally in address hDC.SelectObject(font_addr) any_word_overflows = False for line in final_lines: line_w, _ = hDC.GetTextExtent(line) if line_w > inner_w: any_word_overflows = True break # Ensure invoice line doesn't overflow horizontally if invoice_line: hDC.SelectObject(font_inv) line_w, _ = hDC.GetTextExtent(invoice_line) if line_w > inner_w: any_word_overflows = True if not any_word_overflows: break font_size -= 1 # Re-ensure fonts are created if we fell through addr_font_size = max(14, int(font_size * 0.80)) font_addr = win32ui.CreateFont({ "name": "Arial", "height": addr_font_size, "weight": 400, }) inv_font_size = font_size font_inv = win32ui.CreateFont({ "name": "Arial", "height": inv_font_size, "weight": 700, }) # Recalculate heights for final draw hDC.SelectObject(font_addr) line_h = hDC.GetTextExtent("Ag")[1] + 2 hDC.SelectObject(font_inv) inv_line_h = hDC.GetTextExtent("Ag")[1] + 2 if invoice_line else 0 gap = max(8, inv_font_size // 3) if invoice_line else 0 total_h = (len(final_lines) * line_h) + gap + inv_line_h # Center vertically y = margin_y + max(0, (inner_h - total_h) // 2) bottom = margin_y + inner_h # Draw address lines hDC.SelectObject(font_addr) for line in final_lines: if y + line_h > bottom: break line_w, _ = hDC.GetTextExtent(line) x = margin_x + max(0, (inner_w - line_w) // 2) hDC.TextOut(x, y, line) y += line_h # Draw invoice line (centered, bold, and larger than the address) if invoice_line and y + gap + inv_line_h <= bottom: y += gap hDC.SelectObject(font_inv) line_w, _ = hDC.GetTextExtent(invoice_line) x = margin_x + max(0, (inner_w - line_w) // 2) hDC.TextOut(x, y, invoice_line) def _wrap_text(hDC, text: str, max_width: int) -> list[str]: """Greedy word-wrap that respects the device context's current font.""" words = text.split() if not words: return [] lines: list[str] = [] current = words[0] for word in words[1:]: candidate = current + " " + word w, _ = hDC.GetTextExtent(candidate) if w <= max_width: current = candidate else: lines.append(current) current = word lines.append(current) return lines # ── LabelPrinter ────────────────────────────────────────────────────────────── class LabelPrinter: """Owner of the label printer selection and the print operation itself. Mirrors the CutFileManager pattern: takes a reference to the shared state dict and a save_fn callback so it can persist its settings immediately. """ def __init__( self, state: dict, save_state: Callable[[dict], None], ) -> None: self._state = state self._save_fn = save_state def is_available(self) -> bool: """True if pywin32 is importable. False on dev machines without it.""" return _WIN32_AVAILABLE def available_printers(self) -> list[str]: """Return the list of installed printer names on this workstation.""" if not _WIN32_AVAILABLE: return [] try: return [ p[2] for p in win32print.EnumPrinters( win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS ) ] except Exception as exc: log.warning("Could not enumerate printers: %s", exc) return [] def get_printer(self) -> str: """Return the saved printer name, or '' if none chosen yet.""" return self._state.get("label_printer", "") or "" def set_printer(self, name: str) -> None: """Save the chosen printer name to ui_state.json.""" self._state["label_printer"] = name try: self._save_fn(self._state) except Exception as exc: log.warning("Failed to persist label_printer to ui_state.json: %s", exc) def get_last_printed(self) -> tuple[str, str]: """Return the (sku, description) of the previously printed label.""" return ( self._state.get("last_printed_sku", "") or "", self._state.get("last_printed_desc", "") or "", ) def open_preferences(self) -> None: """Open the Windows Printing Preferences dialog for the chosen printer. That's where Auto Cut / Chain Printing toggles live in the driver UI. """ if not _WIN32_AVAILABLE: raise RuntimeError("Printer settings are only available on Windows.") printer = self.get_printer() if not printer: raise RuntimeError("No label printer chosen yet. Pick one from the menu first.") try: subprocess.Popen(["rundll32.exe", "printui.dll,PrintUIEntry", "/e", "/n", printer]) except Exception as exc: raise RuntimeError(f"Could not open printer settings for '{printer}':\n\n{exc}") from exc def print_label(self, sku: str, description: str) -> None: """Forward a two-line label (SKU + Description) to the bridge print queue. Raises RuntimeError with a user-friendly message on any failure. """ if not sku: raise RuntimeError("Pick a part first, then click Print Label.") log.info("Forwarding print label request: sku=%r desc=%r", sku, description) import requests url = "http://localhost:5000/api/label-jobs/enqueue" payload = { "label_data": { "order_id": sku, "source": "parts_lookup", "skus": [{ "sku": sku, "description": description, "line_qty": 1, "unit_index": 1 }] } } try: resp = requests.post(url, json=payload, timeout=3) if resp.status_code != 200: raise RuntimeError(f"Server returned status {resp.status_code}: {resp.text}") except Exception as exc: raise RuntimeError( f"Could not connect to print bridge server at localhost:5000.\n\n" "Please verify that the QuickBooks Efficiency Bridge is running.\n\n" f"Error details: {exc}" ) from exc self._state["last_printed_sku"] = sku self._state["last_printed_desc"] = description try: self._save_fn(self._state) except Exception: log.warning("Failed to persist last printed label to state") def print_address_label(self, address: str, invoice: str = "") -> None: """Forward an Address + Invoice label to the bridge print queue. Raises RuntimeError with a user-friendly message on any failure. """ if not address.strip(): raise RuntimeError("Address cannot be empty.") log.info("Forwarding print address label request: invoice=%r", invoice) # Parse company and lines addr_lines = [ln.strip() for ln in address.splitlines() if ln.strip()] company = addr_lines[0] if addr_lines else "" lines = addr_lines[1:] if len(addr_lines) > 1 else [] import requests url = "http://localhost:5000/api/label-jobs/enqueue" payload = { "label_data": { "order_id": invoice or "SHIP", "source": "parts_lookup", "qb_invoice_number": invoice, "ship_to": { "company": company, "lines": lines } } } try: resp = requests.post(url, json=payload, timeout=3) if resp.status_code != 200: raise RuntimeError(f"Server returned status {resp.status_code}: {resp.text}") except Exception as exc: raise RuntimeError( f"Could not connect to print bridge server at localhost:5000.\n\n" "Please verify that the QuickBooks Efficiency Bridge is running.\n\n" f"Error details: {exc}" ) from exc def _render_label(self, sku: str, description: str) -> None: """Lay out the label in printer logical units.""" printer = self.get_printer() try: hDC = win32ui.CreateDC() hDC.CreatePrinterDC(printer) except Exception as exc: raise OSError(exc) from exc try: dpi_x = hDC.GetDeviceCaps(win32con.LOGPIXELSX) dpi_y = hDC.GetDeviceCaps(win32con.LOGPIXELSY) w = hDC.GetDeviceCaps(win32con.HORZRES) h = hDC.GetDeviceCaps(win32con.VERTRES) log.info("Label printable area: %d x %d (%d x %d DPI)", w, h, dpi_x, dpi_y) margin_x = max(10, int(w * 0.04)) margin_y = max(6, int(h * 0.06)) inner_w = w - 2 * margin_x inner_h = h - 2 * margin_y hDC.StartDoc("Parts Lookup Label") hDC.StartPage() # SKU (large bold) + description (smaller regular), auto-sized to fit y = margin_y for pt in range(36, 7, -1): sku_font_h = -int(dpi_y * pt / 72) desc_font_h = max(sku_font_h, -int(dpi_y * 10 / 72)) f_sku = win32ui.CreateFont({"name": "Arial", "height": sku_font_h, "weight": 700}) f_desc = win32ui.CreateFont({"name": "Arial", "height": desc_font_h, "weight": 400}) hDC.SelectObject(f_sku) tw, th = hDC.GetTextExtent(sku or " ") hDC.SelectObject(f_desc) desc_lines = _wrap_text(hDC, description, inner_w) if description else [] desc_h = sum(hDC.GetTextExtent(ln)[1] + 2 for ln in desc_lines) if desc_lines else 0 total_h = th + 4 + desc_h if tw <= inner_w and total_h <= inner_h: y = margin_y + max(0, (inner_h - total_h) // 2) hDC.SelectObject(f_sku) x = margin_x + max(0, (inner_w - tw) // 2) hDC.TextOut(x, y, sku) y += th + 4 hDC.SelectObject(f_desc) for line in desc_lines: lw, lh = hDC.GetTextExtent(line) hDC.TextOut(margin_x + max(0, (inner_w - lw) // 2), y, line) y += lh + 2 break hDC.EndPage() hDC.EndDoc() finally: try: hDC.DeleteDC() except Exception: pass def _render_address_label(self, address: str, invoice: str = "") -> None: """Lay out and print the address and invoice centered horizontally and vertically.""" printer = self.get_printer() try: hDC = win32ui.CreateDC() hDC.CreatePrinterDC(printer) except Exception as exc: raise OSError(exc) from exc try: dpi_y = hDC.GetDeviceCaps(win32con.LOGPIXELSY) w = hDC.GetDeviceCaps(win32con.HORZRES) h = hDC.GetDeviceCaps(win32con.VERTRES) margin_x = max(10, int(w * 0.04)) margin_y = max(6, int(h * 0.08)) inner_w = w - 2 * margin_x inner_h = h - 2 * margin_y address_lines = [ln.strip() for ln in address.splitlines() if ln.strip()] hDC.StartDoc("Parts Lookup Address Label") hDC.StartPage() _draw_address_block( hDC=hDC, address_raw_lines=address_lines, invoice_line=invoice, inner_w=inner_w, inner_h=inner_h, margin_x=margin_x, margin_y=margin_y, dpi_y=dpi_y, ) hDC.EndPage() hDC.EndDoc() finally: try: hDC.DeleteDC() except Exception: pass