Files
2026-07-06 00:24:30 -05:00

593 lines
21 KiB
Python

"""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