diff --git a/pyproject.toml b/pyproject.toml index e614740..5bde9b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ dependencies = [ "aiofiles>=24.1.0", "httpx>=0.28.0", "segno>=1.6.0", + "pystrich>=0.19", + "reportlab>=4.0.0", "packaging>=24.0", "mcp>=1.28.1", # CVE-2026-52869/52870/59950 (not reachable on stdio, cleared anyway) "argon2-cffi>=25.1.0", diff --git a/src/opal/api/routes/inventory.py b/src/opal/api/routes/inventory.py index 9a49c39..4e2faf1 100644 --- a/src/opal/api/routes/inventory.py +++ b/src/opal/api/routes/inventory.py @@ -262,6 +262,34 @@ def get_inventory_qrcode( return Response(content=buf.getvalue(), media_type="image/svg+xml") +@router.get("/{inventory_id}/datamatrix") +def get_inventory_datamatrix( + db: DbSession, + inventory_id: int, +) -> Response: + """Generate a Data Matrix SVG of the OPAL number. + + A short identifier needs far fewer modules per side as Data Matrix + than as a QR code (~16x16 vs. QR's 21x21+), which matters on a + narrow continuous-tape label printer (e.g. the DYMO LabelManager + 280's 12mm/180dpi print strip): more px/module survives the print + resolution instead of blurring into an unscannable square. + """ + from pystrich.datamatrix import DataMatrixEncoder + + record = ( + db.query(InventoryRecord) + .join(Part) + .filter(InventoryRecord.id == inventory_id, Part.deleted_at.is_(None)) + .first() + ) + if not record: + raise HTTPException(status_code=404, detail=f"Inventory record {inventory_id} not found") + + encoder = DataMatrixEncoder(record.opal_number) + return Response(content=encoder.get_svg(), media_type="image/svg+xml") + + @router.get("/locations") def list_locations(db: DbSession) -> list[LocationSummary]: """List all inventory locations with summary.""" diff --git a/src/opal/api/routes/parts.py b/src/opal/api/routes/parts.py index 89fda60..95b2f03 100644 --- a/src/opal/api/routes/parts.py +++ b/src/opal/api/routes/parts.py @@ -504,6 +504,29 @@ def get_part_qrcode( return Response(content=buf.getvalue(), media_type="image/svg+xml") +@router.get("/{part_id}/datamatrix") +def get_part_datamatrix( + db: DbSession, + part_id: int, +) -> Response: + """Generate a Data Matrix SVG of the part number. + + A short identifier needs far fewer modules per side as Data Matrix + than as a QR code (~16x16 vs. QR's 21x21+), which matters on a + narrow continuous-tape label printer (e.g. the DYMO LabelManager + 280's 12mm/180dpi print strip): more px/module survives the print + resolution instead of blurring into an unscannable square. + """ + from pystrich.datamatrix import DataMatrixEncoder + + part = db.query(Part).filter(Part.id == part_id, Part.deleted_at.is_(None)).first() + if not part: + raise HTTPException(status_code=404, detail=f"Part {part_id} not found") + + encoder = DataMatrixEncoder(part.internal_pn) + return Response(content=encoder.get_svg(), media_type="image/svg+xml") + + @router.get("/{part_id}", response_model=PartResponse) def get_part( db: DbSession, diff --git a/src/opal/core/dymo_label.py b/src/opal/core/dymo_label.py new file mode 100644 index 0000000..14c3176 --- /dev/null +++ b/src/opal/core/dymo_label.py @@ -0,0 +1,102 @@ +"""Native PDF rendering for direct-print DYMO LabelManager 280 labels. + +A separate render path from web/templates/label_dymo.html (the browser +print-dialog fallback) — reportlab keeps this dependency-light instead of +bundling a headless browser, at the cost of maintaining the layout twice. +Geometry (page presets, rotation direction, cross-tape budget) is carried +over from the HTML/CSS version, which was tuned against physical hardware; +keep the two in sync by hand if either changes. +""" + +from io import BytesIO + +from pystrich.datamatrix import DataMatrixEncoder +from reportlab.lib.units import inch +from reportlab.lib.utils import ImageReader +from reportlab.pdfgen import canvas + +PAGE_WIDTH_IN = 0.4861 # 35pt native tape-width canvas, 12mm (max) D1 tape +_SHORT_PAGE_IN = 2.0 +_LONG_PAGE_IN = 3.5 +_PAGE_MARGIN_IN = 0.833 # fixed on both DYMO "Label" presets +_SHORT_PRINTABLE_IN = _SHORT_PAGE_IN - _PAGE_MARGIN_IN +_CROSS_TAPE_IN = 0.32 # printable width for 12mm tape, both presets +_DM_SIZE_IN = 0.32 +_GAP_IN = 0.05 +_PN_FONT = ("Courier-Bold", 8) +_NAME_FONT = ("Courier-Bold", 6.5) +_META_FONT = ("Courier-Bold", 5.5) + + +def _lines_and_fonts( + pn: str, name: str, meta: str | None +) -> tuple[list[str], list[tuple[str, float]]]: + lines = [pn, name] + fonts = [_PN_FONT, _NAME_FONT] + if meta: + lines.append(meta) + fonts.append(_META_FONT) + return lines, fonts + + +def page_height_in(pn: str, name: str, meta: str | None) -> float: + """Pick the shortest DYMO page preset that fits this label's content.""" + lines, fonts = _lines_and_fonts(pn, name, meta) + widths_in = [ + canvas.Canvas(BytesIO()).stringWidth(text, font, size) / 72 + for text, (font, size) in zip(lines, fonts, strict=True) + ] + needed_in = _DM_SIZE_IN + _GAP_IN + max(widths_in) + _GAP_IN + return _SHORT_PAGE_IN if needed_in <= _SHORT_PRINTABLE_IN else _LONG_PAGE_IN + + +def render_dymo_label_pdf(pn: str, name: str, meta: str | None, datamatrix_data: str) -> bytes: + """Render a DYMO LabelManager 280 label as a print-ready PDF. + + datamatrix_data is the identifier encoded in the Data Matrix — the + part number for a part label, the OPAL number for an inventory label. + """ + page_h_in = page_height_in(pn, name, meta) + page_w = PAGE_WIDTH_IN * inch + page_h = page_h_in * inch + + buf = BytesIO() + c = canvas.Canvas(buf, pagesize=(page_w, page_h)) + # The driver's native page is portrait (narrow = tape width, long = + # feed direction); rotate +90deg so content reads correctly once the + # tape is peeled off — confirmed against physical hardware. + c.translate(page_w / 2, page_h / 2) + c.rotate(90) + + lines, fonts = _lines_and_fonts(pn, name, meta) + text_width_in = max( + c.stringWidth(text, font, size) / 72 for text, (font, size) in zip(lines, fonts, strict=True) + ) + content_width_in = _DM_SIZE_IN + _GAP_IN + text_width_in + x0 = -(content_width_in * inch) / 2 + band_top = (_CROSS_TAPE_IN * inch) / 2 + + # Data Matrix, flush left within the content block. Rendered via + # pystrich's own image encoder rather than its `.matrix` attribute, + # which turned out to be stale/wrong — get_pilimage() is the same + # code path that produced the SVG confirmed scannable on hardware. + dm_image = DataMatrixEncoder(datamatrix_data).get_pilimage(cellsize=4) + c.drawImage( + ImageReader(dm_image), + x0, + band_top - _DM_SIZE_IN * inch, + width=_DM_SIZE_IN * inch, + height=_DM_SIZE_IN * inch, + ) + + # Text stack, vertically split across the cross-tape band + text_x = x0 + (_DM_SIZE_IN + _GAP_IN) * inch + line_height_in = _CROSS_TAPE_IN / len(lines) + for i, (text, (font_name, font_size)) in enumerate(zip(lines, fonts, strict=True)): + baseline_y = band_top - (i + 0.8) * line_height_in * inch + c.setFont(font_name, font_size) + c.drawString(text_x, baseline_y, text) + + c.showPage() + c.save() + return buf.getvalue() diff --git a/src/opal/core/printing.py b/src/opal/core/printing.py new file mode 100644 index 0000000..22cb21f --- /dev/null +++ b/src/opal/core/printing.py @@ -0,0 +1,72 @@ +"""Local CUPS printer discovery and job dispatch. + +OPAL runs on one machine (see CLAUDE.md); direct-print means printing to +whatever's attached to that machine, so this shells out to the system's +own `lpstat`/`lp` rather than speaking a print protocol directly. +""" + +import subprocess +import tempfile +from pathlib import Path + + +class PrinterError(Exception): + """Raised when listing printers or dispatching a print job fails.""" + + +def list_printers() -> list[dict[str, str]]: + """List CUPS printers known to this machine, with their status.""" + try: + result = subprocess.run( + ["lpstat", "-p"], capture_output=True, text=True, timeout=5, check=False + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise PrinterError(f"Could not query printers: {exc}") from exc + + printers = [] + for line in result.stdout.splitlines(): + if not line.startswith("printer "): + continue + parts = line.split() + if len(parts) < 2: + continue + if " is idle" in line: + status = "idle" + elif " is printing" in line or " now printing" in line: + status = "printing" + elif " disabled " in line: + status = "disabled" + else: + status = "unknown" + printers.append({"name": parts[1], "status": status}) + return printers + + +def print_file(printer: str, pdf_bytes: bytes) -> None: + """Send a PDF to a named CUPS printer. + + Validates the printer name against the live `lpstat -p` list rather + than trusting the caller — the name ends up as a CLI argument to + `lp`, so this also guards against an unexpected/mistyped target. + """ + known = {p["name"] for p in list_printers()} + if printer not in known: + raise PrinterError(f"Unknown printer: {printer}") + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f: + f.write(pdf_bytes) + tmp_path = Path(f.name) + try: + result = subprocess.run( + ["lp", "-d", printer, str(tmp_path)], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if result.returncode != 0: + raise PrinterError(result.stderr.strip() or "lp failed") + except (OSError, subprocess.TimeoutExpired) as exc: + raise PrinterError(f"Could not send print job: {exc}") from exc + finally: + tmp_path.unlink(missing_ok=True) diff --git a/src/opal/web/routes.py b/src/opal/web/routes.py index 118e4d3..249cb48 100644 --- a/src/opal/web/routes.py +++ b/src/opal/web/routes.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any -from fastapi import APIRouter, Form, Query, Request +from fastapi import APIRouter, Form, HTTPException, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy import case, func, or_ @@ -4216,6 +4216,61 @@ def users_issue_claim_link(request: Request, db: DbSession, user_id: int) -> HTM # ============ LABEL PRINT ============ +# DYMO LabelManager 280: only two fixed "Label" page lengths are safe to +# request without risking an unvalidated custom size (see label_dymo.html) +# — 2in (1.167in printable) and 3.5in (2.667in printable). The driver +# doesn't trim a fixed page to content, so picking the shorter one +# whenever content fits avoids feeding blank tape past the label. +_DYMO_SHORT_PAGE_IN = 2.0 +_DYMO_LONG_PAGE_IN = 3.5 +_DYMO_PAGE_MARGIN_IN = 0.833 # fixed on both presets: (page - printable) / 2 * 2 +_DYMO_SHORT_PRINTABLE_IN = _DYMO_SHORT_PAGE_IN - _DYMO_PAGE_MARGIN_IN +_DYMO_DATAMATRIX_IN = 0.32 # matches the 0.32in cross-tape budget, see template +_DYMO_MONO_CHAR_WIDTH_EM = 0.6 # typical monospace advance width + + +def _dymo_page_height_in(pn: str, name: str, meta: str | None) -> float: + """Pick the shortest DYMO page preset that fits this label's content.""" + + def _line_width_in(text: str, font_pt: float) -> float: + return len(text) * _DYMO_MONO_CHAR_WIDTH_EM * font_pt / 72.0 + + lines = [_line_width_in(pn, 8), _line_width_in(name, 6.5)] + if meta: + lines.append(_line_width_in(meta, 5.5)) + needed_in = _DYMO_DATAMATRIX_IN + 0.05 + max(lines) + 0.05 + return _DYMO_SHORT_PAGE_IN if needed_in <= _DYMO_SHORT_PRINTABLE_IN else _DYMO_LONG_PAGE_IN + + +def _dymo_label_fields( + type: str, id: int, db: DbSession +) -> tuple[str, str, str | None, str] | None: + """Resolve (pn, name, meta, datamatrix_data) for a part or inventory + record. Shared by the HTML preview route and the direct-print + dispatch route so the meta-line format lives in one place. + """ + if type == "inventory": + record = ( + db.query(InventoryRecord) + .join(Part) + .filter(InventoryRecord.id == id, Part.deleted_at.is_(None)) + .first() + ) + if not record: + return None + identifier = record.opal_number or f"INV-{record.id}" + uom = (record.part.unit_of_measure or "ea").upper() + meta = " · ".join( + [record.lot_number or "—", identifier, f"QTY: {float(record.quantity):g} {uom}"] + ) + return record.part.internal_pn, record.part.name, meta, identifier + if type == "part": + part = db.query(Part).filter(Part.id == id, Part.deleted_at.is_(None)).first() + if not part: + return None + return part.internal_pn, part.name, None, part.internal_pn + return None + @router.get("/label", response_class=HTMLResponse) def label_print( @@ -4223,8 +4278,13 @@ def label_print( db: DbSession, type: str = Query(...), id: int = Query(...), + fmt: str = Query("default"), ) -> HTMLResponse: - """Print label with QR code for a part or inventory record.""" + """Print label with QR code for a part or inventory record. + + fmt=dymo renders a compact label sized for the DYMO LabelManager 280 + (12mm / 1/2in D1 tape, its max width) instead of the full-size tag. + """ if type == "inventory": record = ( db.query(InventoryRecord) @@ -4234,13 +4294,28 @@ def label_print( ) if not record: return HTMLResponse("Not found", status_code=404) + identifier = record.opal_number or f"INV-{record.id}" + if fmt == "dymo": + pn, name, meta, _ = _dymo_label_fields(type, id, db) + return templates.TemplateResponse( + "label_dymo.html", + { + "request": request, + "entity_type": "inventory", + "entity_id": record.id, + "pn": pn, + "name": name, + "meta": meta, + "page_height_in": _dymo_page_height_in(pn, name, meta), + }, + ) return templates.TemplateResponse( "label_print.html", { "request": request, "entity_type": "inventory", "entity_id": record.id, - "identifier": record.opal_number or f"INV-{record.id}", + "identifier": identifier, "name": record.part.name, "location": record.location, }, @@ -4251,6 +4326,19 @@ def label_print( part = db.query(Part).filter(Part.id == id, Part.deleted_at.is_(None)).first() if not part: return HTMLResponse("Not found", status_code=404) + if fmt == "dymo": + return templates.TemplateResponse( + "label_dymo.html", + { + "request": request, + "entity_type": "parts", + "entity_id": part.id, + "pn": part.internal_pn, + "name": part.name, + "meta": None, + "page_height_in": _dymo_page_height_in(part.internal_pn, part.name, None), + }, + ) # The label IS the tag component in print mode — one identity, # one rendering, everywhere project = get_active_project() @@ -4269,6 +4357,45 @@ def label_print( return HTMLResponse("Invalid type", status_code=400) +@router.get("/label/printers") +def label_printers() -> list[dict[str, str]]: + """List CUPS printers on this machine for the direct-print picker.""" + from opal.core.printing import PrinterError, list_printers + + try: + return list_printers() + except PrinterError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.post("/label/print-direct") +def label_print_direct( + db: DbSession, + type: str = Form(...), + id: int = Form(...), + printer: str = Form(...), +) -> dict[str, str]: + """Render a DYMO label natively and send it straight to a CUPS printer. + + No browser print dialog — OPAL runs on one machine, so "print" means + print to whatever's attached to that machine (see printing.py). + """ + from opal.core.dymo_label import render_dymo_label_pdf + from opal.core.printing import PrinterError, print_file + + fields = _dymo_label_fields(type, id, db) + if fields is None: + raise HTTPException(status_code=404, detail=f"{type} {id} not found") + pn, name, meta, datamatrix_data = fields + + pdf_bytes = render_dymo_label_pdf(pn, name, meta, datamatrix_data) + try: + print_file(printer, pdf_bytes) + except PrinterError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return {"printer": printer} + + # ============ DOCUMENTATION ============ diff --git a/src/opal/web/templates/_dymo_print_modal.html b/src/opal/web/templates/_dymo_print_modal.html new file mode 100644 index 0000000..70f9b55 --- /dev/null +++ b/src/opal/web/templates/_dymo_print_modal.html @@ -0,0 +1,97 @@ +{# Shared DYMO direct-print modal — included from parts/detail.html and + inventory/opal_detail.html. Lists CUPS printers on this machine and + sends the job straight to the chosen one; no browser print dialog. #} +
+ + diff --git a/src/opal/web/templates/inventory/opal_detail.html b/src/opal/web/templates/inventory/opal_detail.html index 6b6c33a..fa7dc25 100644 --- a/src/opal/web/templates/inventory/opal_detail.html +++ b/src/opal/web/templates/inventory/opal_detail.html @@ -20,6 +20,7 @@ {{ ok.btn("RECORD CALIBRATION", variant="primary", size="sm", attrs='onclick="recordCalibration()"') }} {% endif %} {{ ok.btn("PRINT LABEL", size="sm", attrs='onclick="window.open(\'/label?type=inventory&id=' ~ record.id ~ '\', \'_blank\', \'width=500,height=300\')"') }} + {{ ok.btn("PRINT LABEL · DYMO", size="sm", attrs='onclick="showDymoPrintModal(\'inventory\', ' ~ record.id ~ ')"') }} {{ ok.btn("ADJUST QTY", size="sm", href="/inventory/" ~ record.id ~ "/adjust") }} {% if record.quantity > 0 %} {{ ok.btn("SCRAP", variant="danger", size="sm", attrs='onclick="showScrapModal()"') }} @@ -733,4 +734,5 @@