Initial commit
This commit is contained in:
+592
@@ -0,0 +1,592 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user