Explorar el Código

feat(inventory): printable PDF spool labels in 4 sizes (#809)

  Closes the longest-standing inventory gap — finding a specific spool
  in a closet of 50 partials. Per-spool icon button on every inventory
  card and table row, plus a "Print labels..." header action that opens
  a multi-select picker pre-loaded with the currently filtered spools.

  Four pre-built templates: AMS holder (30 x 15 mm) for the popular
  Makerworld AMS Filament Label Holder, single box label (62 x 29 mm)
  for Brother PT/QL or Dymo small labels, Avery L7160 (A4, 21 per
  sheet), and Avery 5160 (US Letter, 30 per sheet). Each label carries
  the colour swatch (with multi-colour gradient stripes for spools
  with extra_colors set), brand, material, name, the *spool ID*
  (bsaunder's articulated user-need: telling 8 spools of "PLA White"
  apart, especially partials), and a QR code that deep-links to
  /inventory?spool=<id> for phone-scan round-trips. Box-label adds
  storage location; AMS-holder drops the QR — at 30 x 15 mm there is
  no room for swatch + text + QR without truncating away the spool ID,
  and AMS-bay identification is at arm's length where the swatch and
  ID are enough.

  Server-side rendering via ReportLab + qrcode (already a dep). Pure
  Python, no headless browser, no system libs. Output is byte-identical
  across browsers, Avery sheets align to <0.1 mm, and bulk export is
  one click for one PDF. Two endpoints — POST /inventory/labels (local
  DB) and POST /spoolman/labels (Spoolman-backed) — gated on
  INVENTORY_READ, capped at 500 spools per request, returning
  application/pdf via StreamingResponse. The renderer is decoupled
  from the SQLAlchemy model via a LabelData dataclass so the same code
  path serves both modes.

  Modal picker scales to large libraries: search (substring match
  across name / brand / #ID), material filter chips derived from the
  visible spools, additive Select-all-visible / Deselect-visible /
  Clear-all actions so selections survive filter changes. Restyled
  twice in development — first cut used generic Tailwind which clashed
  with the inventory's bambu-dark palette; second cut switched to
  bambu-dark-secondary / bambu-green / bambu-gray to match.

  Two render bugs found during visual inspection of generated PDFs and
  fixed before commit:

    1. AMS-30x15 template originally produced labels with only swatch
       + QR and no text at all — the side-by-side layout left <5 mm
       for the text column, so the renderer bailed without drawing
       anything. Layout split into tight (h<20mm) and roomy (h>=20mm)
       regimes; tight regime drops the QR and gives the right column
       to brand + material + a 13pt-bold spool ID.

    2. Box-62x29 template aggressively truncated text — swatch + QR
       each at ~14 mm on a 26mm-tall label squeezed the text column
       to ~16 mm, turning "Polymaker Ivory" into "Polymak..." and
       "Polymaker . PLA . Matte" into "Polymaker ...". Swatch capped
       at 16 mm, QR capped at 18 mm and constrained to ~20% of width,
       leaving the text column ~30 mm — full names render without
       truncation.

  Both bugs pinned by regression tests in test_label_renderer.py that
  render with pageCompression=0 so the resulting PDF bytes contain the
  text as ASCII and `assert b"Polymaker" in pdf` works.
maziggy hace 3 semanas
padre
commit
864e5c990e

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -219,6 +219,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
 - **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
 - **Multi-colour gradients, transparency, and visual effects** — Paste a comma-separated hex list (e.g. from 3dfilamentprofiles.com) to render a spool as a gradient or conic colour wheel; transparency shows through a checkerboard so the alpha you set is the alpha you see; pick a visual effect (sparkle, wood, marble, glow, matte) for the swatch overlay. Same fields are editable on the colour catalog so combos can be reused across spools.
 - **Multi-colour gradients, transparency, and visual effects** — Paste a comma-separated hex list (e.g. from 3dfilamentprofiles.com) to render a spool as a gradient or conic colour wheel; transparency shows through a checkerboard so the alpha you set is the alpha you see; pick a visual effect (sparkle, wood, marble, glow, matte) for the swatch overlay. Same fields are editable on the colour catalog so combos can be reused across spools.
+- **Printable spool labels** — Generate PDF labels for any selection of spools in four pre-built sizes: AMS holder (30×15 mm), box label (62×29 mm), Avery L7160 sheet (A4, 21 per page), and Avery 5160 sheet (US Letter, 30 per page). Each label shows the colour swatch, brand, material, name, the **spool ID** (for at-a-glance identification across many similar spools), and a QR code that deep-links straight back to the spool's row in Bambuddy when scanned with a phone. Pick from the inventory page — search, filter by material, multi-select spools, then print or save to PDF.
 
 
 ### 🔧 Integrations
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display

+ 209 - 0
backend/app/api/routes/labels.py

@@ -0,0 +1,209 @@
+"""Spool label printing routes (#809).
+
+Two endpoints, one per inventory backend:
+
+- ``POST /inventory/labels``  — local-DB spools
+- ``POST /spoolman/labels``   — Spoolman-backed spools
+
+Both accept ``{spool_ids: [int], template: str}`` and return a PDF stream.
+The QR code on each label deep-links to ``/inventory?spool=<id>`` so a phone
+scan jumps straight back into Bambuddy at that spool's row.
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+from typing import Literal
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.settings import get_setting
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.spool import Spool
+from backend.app.models.user import User
+from backend.app.services.label_renderer import LabelData, TemplateName, render_labels
+from backend.app.services.spoolman import get_spoolman_client
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(tags=["labels"])
+
+_VALID_TEMPLATES: tuple[TemplateName, ...] = (
+    "ams_30x15",
+    "box_62x29",
+    "avery_5160",
+    "avery_l7160",
+)
+
+# Cap how many labels can be requested in one go. Sane upper bound for the
+# largest realistic batch (an Avery sheet at 30/page × ~10 pages).
+MAX_LABELS_PER_REQUEST = 500
+
+
+class LabelRequest(BaseModel):
+    spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
+    template: Literal["ams_30x15", "box_62x29", "avery_5160", "avery_l7160"]
+
+
+def _split_extra_colors(raw: str | None) -> list[str] | None:
+    """Parse ``Spool.extra_colors`` (comma-separated hex tokens) into a list."""
+    if not raw:
+        return None
+    parts = [p.strip().lstrip("#") for p in raw.split(",") if p.strip()]
+    return parts or None
+
+
+async def _resolve_deeplink_base(request: Request, db: AsyncSession) -> str:
+    """Where the QR codes should point. Prefers `external_url` when set so a
+    phone scan reaches the user's public Bambuddy URL rather than an internal
+    address; falls back to the request's own scheme+host when no setting is
+    configured.
+    """
+    external = (await get_setting(db, "external_url") or "").strip().rstrip("/")
+    if external:
+        return external
+    return f"{request.url.scheme}://{request.url.netloc}"
+
+
+def _spool_to_label_data(spool: Spool, deeplink_base: str) -> LabelData:
+    name = spool.color_name or spool.slicer_filament_name or f"{spool.brand or ''} {spool.material}".strip()
+    return LabelData(
+        spool_id=spool.id,
+        name=name or spool.material,
+        material=spool.material,
+        brand=spool.brand,
+        subtype=spool.subtype,
+        rgba=spool.rgba,
+        extra_colors=_split_extra_colors(spool.extra_colors),
+        storage_location=getattr(spool, "storage_location", None),
+        deeplink_url=f"{deeplink_base}/inventory?spool={spool.id}",
+    )
+
+
+def _spoolman_dict_to_label_data(s: dict, deeplink_base: str) -> LabelData:
+    """Build LabelData from a raw Spoolman /spool response dict.
+
+    Spoolman models don't have a native 'spool name' — we derive it from the
+    embedded filament. Material and brand come from filament/vendor.
+    """
+    filament = s.get("filament") or {}
+    vendor = filament.get("vendor") or {}
+    fname = filament.get("name") or ""
+    material = filament.get("material") or ""
+    brand = vendor.get("name")
+    color_hex = filament.get("color_hex")
+    rgba = color_hex.lstrip("#") if isinstance(color_hex, str) else None
+
+    multi_colors = filament.get("multi_color_hexes")
+    extra: list[str] | None = None
+    if isinstance(multi_colors, str) and multi_colors.strip():
+        extra = [tok.strip().lstrip("#") for tok in multi_colors.split(",") if tok.strip()]
+    elif isinstance(multi_colors, list):
+        extra = [str(t).strip().lstrip("#") for t in multi_colors if str(t).strip()]
+
+    return LabelData(
+        spool_id=int(s.get("id", 0)),
+        name=fname or material or "Spool",
+        material=material or "",
+        brand=brand,
+        subtype=None,
+        rgba=rgba,
+        extra_colors=extra,
+        storage_location=s.get("location"),
+        deeplink_url=f"{deeplink_base}/inventory?spool={int(s.get('id', 0))}",
+    )
+
+
+def _stream_pdf(pdf: bytes, filename: str) -> StreamingResponse:
+    return StreamingResponse(
+        io.BytesIO(pdf),
+        media_type="application/pdf",
+        headers={
+            "Content-Disposition": f'inline; filename="{filename}"',
+            "Content-Length": str(len(pdf)),
+            # PDFs are deterministic per request; tell the browser not to cache
+            # so re-printing after edits picks up the new data.
+            "Cache-Control": "no-store",
+        },
+    )
+
+
+@router.post("/inventory/labels")
+async def render_local_inventory_labels(
+    body: LabelRequest,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> StreamingResponse:
+    """Render labels for spools in the local inventory."""
+    if body.template not in _VALID_TEMPLATES:
+        raise HTTPException(400, f"Unknown template: {body.template}")
+
+    result = await db.execute(select(Spool).where(Spool.id.in_(body.spool_ids)))
+    spools = list(result.scalars().all())
+
+    found_ids = {s.id for s in spools}
+    missing = [sid for sid in body.spool_ids if sid not in found_ids]
+    if missing:
+        raise HTTPException(404, f"Spool(s) not found: {missing}")
+
+    # Preserve caller's order so an Avery sheet print matches the on-screen list.
+    ordered = sorted(spools, key=lambda s: body.spool_ids.index(s.id))
+
+    deeplink_base = await _resolve_deeplink_base(request, db)
+    data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
+
+    pdf = render_labels(body.template, data_list)
+    filename = f"bambuddy-labels-{body.template}.pdf"
+    return _stream_pdf(pdf, filename)
+
+
+@router.post("/spoolman/labels")
+async def render_spoolman_labels(
+    body: LabelRequest,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> StreamingResponse:
+    """Render labels for spools tracked in Spoolman.
+
+    The Spoolman client doesn't expose a per-id endpoint, so this fetches the
+    full spool list and filters in-memory. For typical libraries (~50 spools)
+    that's negligible; for very large libraries this is the trade-off until
+    Spoolman gains a bulk filter.
+    """
+    if body.template not in _VALID_TEMPLATES:
+        raise HTTPException(400, f"Unknown template: {body.template}")
+
+    spoolman_on = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
+    if not spoolman_on:
+        raise HTTPException(400, "Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if client is None or not client.is_connected:
+        raise HTTPException(503, "Spoolman not reachable")
+
+    try:
+        all_spools = await client.get_spools()
+    except Exception as exc:
+        logger.warning("Spoolman fetch failed during label render: %s", exc)
+        raise HTTPException(502, "Failed to fetch spools from Spoolman") from exc
+
+    by_id = {int(s.get("id", 0)): s for s in all_spools if s.get("id") is not None}
+    missing = [sid for sid in body.spool_ids if sid not in by_id]
+    if missing:
+        raise HTTPException(404, f"Spool(s) not found in Spoolman: {missing}")
+
+    deeplink_base = await _resolve_deeplink_base(request, db)
+    data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids]
+
+    pdf = render_labels(body.template, data_list)
+    filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
+    return _stream_pdf(pdf, filename)

+ 2 - 0
backend/app/main.py

@@ -32,6 +32,7 @@ from backend.app.api.routes import (
     groups,
     groups,
     inventory,
     inventory,
     kprofiles,
     kprofiles,
+    labels,
     library,
     library,
     library_trash,
     library_trash,
     local_backup,
     local_backup,
@@ -5045,6 +5046,7 @@ app.include_router(printers.router, prefix=app_settings.api_prefix)
 app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(inventory.router, prefix=app_settings.api_prefix)
 app.include_router(inventory.router, prefix=app_settings.api_prefix)
+app.include_router(labels.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(local_presets.router, prefix=app_settings.api_prefix)

+ 397 - 0
backend/app/services/label_renderer.py

@@ -0,0 +1,397 @@
+"""PDF spool label rendering.
+
+Four fixed templates:
+
+- ``ams_30x15``  — 30×15 mm single label, fits the popular Makerworld AMS
+  Filament Label Holder (model 752566). One label per page.
+- ``box_62x29``  — 62×29 mm single label, sized for Brother PT/QL and Dymo
+  generic small labels. One label per page.
+- ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
+- ``avery_l7160`` — A4 sheet, 38.1×63.5 mm × 21 per sheet.
+
+The renderer is decoupled from the Spool model: callers build a ``LabelData``
+list from whatever source (local DB, Spoolman, future) so the same code path
+works in both modes.
+
+Layout principle, taken from the issue's user need (`#809`): the **spool ID**
+is the most-recognisable field at arm's length and dominates the layout. Other
+fields (brand, material, name, storage location) fill remaining space; the QR
+code provides the round-trip back to ``/inventory?spool=<id>``.
+"""
+
+from __future__ import annotations
+
+import io
+from dataclasses import dataclass
+from typing import Literal
+
+import qrcode
+from reportlab.lib.colors import Color, HexColor, black, white
+from reportlab.lib.pagesizes import A4, letter
+from reportlab.lib.units import mm
+from reportlab.pdfgen import canvas as rl_canvas
+
+TemplateName = Literal["ams_30x15", "box_62x29", "avery_5160", "avery_l7160"]
+
+
+@dataclass
+class LabelData:
+    """Per-spool data needed to render a label.
+
+    Decoupled from the SQLAlchemy model so the same renderer serves the local
+    inventory and the Spoolman-backed inventory.
+    """
+
+    spool_id: int
+    name: str
+    material: str
+    brand: str | None = None
+    subtype: str | None = None
+    rgba: str | None = None  # "RRGGBB" or "RRGGBBAA"; None → neutral grey
+    extra_colors: list[str] | None = None  # additional hex colours (no '#')
+    storage_location: str | None = None
+    deeplink_url: str = ""  # what the QR encodes; caller composes it
+
+
+# ── Colour helpers ───────────────────────────────────────────────────────────
+
+
+def _color_from_hex(hex_str: str | None, fallback: Color = HexColor(0x808080)) -> Color:
+    """Parse an RRGGBB or RRGGBBAA string (no '#') into a ReportLab Color.
+
+    Alpha is honoured so multi-colour spools with translucent overlays render
+    correctly. Falls back to ``fallback`` for None / malformed input rather
+    than raising — labels should always print.
+    """
+    if not hex_str:
+        return fallback
+    h = hex_str.lstrip("#").strip()
+    if len(h) not in (6, 8):
+        return fallback
+    try:
+        r = int(h[0:2], 16) / 255.0
+        g = int(h[2:4], 16) / 255.0
+        b = int(h[4:6], 16) / 255.0
+        a = int(h[6:8], 16) / 255.0 if len(h) == 8 else 1.0
+        return Color(r, g, b, alpha=a)
+    except ValueError:
+        return fallback
+
+
+def _luminance(color: Color) -> float:
+    """Perceived luminance of a ReportLab Color (0–1, WCAG-style approximation)."""
+    return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
+
+
+# ── QR generation ────────────────────────────────────────────────────────────
+
+
+def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
+    """Render ``payload`` as a tight QR PNG. Empty payload returns empty bytes
+    so callers can skip drawing without checking ahead of time.
+    """
+    if not payload:
+        return b""
+    qr = qrcode.QRCode(
+        version=None,
+        error_correction=qrcode.constants.ERROR_CORRECT_M,
+        box_size=box_size,
+        border=border,
+    )
+    qr.add_data(payload)
+    qr.make(fit=True)
+    img = qr.make_image(fill_color="black", back_color="white")
+    buf = io.BytesIO()
+    img.save(buf, format="PNG")
+    return buf.getvalue()
+
+
+# ── Single-label drawing ─────────────────────────────────────────────────────
+
+
+def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
+    """Draw the colour swatch. Multi-colour spools use vertical stripes
+    (matching the FilamentSwatch convention in the frontend)."""
+    primary = _color_from_hex(data.rgba)
+    extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
+    colors = [primary, *extras]
+
+    if not colors:
+        c.setFillColor(HexColor(0x808080))
+        c.rect(x, y, w, h, stroke=0, fill=1)
+        return
+
+    stripe_w = w / len(colors)
+    for i, col in enumerate(colors):
+        c.setFillColor(col)
+        c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
+
+    # Thin black border so light-colour swatches stay visible on white labels.
+    c.setStrokeColor(black)
+    c.setLineWidth(0.3)
+    c.rect(x, y, w, h, stroke=1, fill=0)
+
+
+def _draw_qr(c: rl_canvas.Canvas, x: float, y: float, size: float, payload: str) -> None:
+    """Embed a square QR at (x, y) with edge length ``size`` (in points)."""
+    png = _qr_png_bytes(payload)
+    if not png:
+        return
+    from reportlab.lib.utils import ImageReader
+
+    img = ImageReader(io.BytesIO(png))
+    c.drawImage(img, x, y, width=size, height=size, mask="auto")
+
+
+def _truncate_to_width(c: rl_canvas.Canvas, text: str, font: str, size: float, max_w: float) -> str:
+    """Truncate ``text`` with an ellipsis so it fits within ``max_w`` points."""
+    if c.stringWidth(text, font, size) <= max_w:
+        return text
+    ell = "…"
+    while text and c.stringWidth(text + ell, font, size) > max_w:
+        text = text[:-1]
+    return text + ell if text else ell
+
+
+def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
+    """Render one label inside the box (x, y, w, h). Origin is bottom-left.
+
+    Two layouts, picked by available height:
+
+    - **Tight** (h < 20 mm — AMS holder): swatch on the left, three lines of
+      text on the right (brand, material+subtype, big spool ID). No QR — at
+      30×15 mm there is not enough horizontal room for swatch + text + QR
+      without truncating away the user-need fields, and the AMS holder is an
+      at-a-glance identifier where the spool ID is the killer field. The
+      box-label and Avery templates carry the QR for the other use cases.
+
+    - **Roomy** (h >= 20 mm — box label, Avery sheets): swatch on the left,
+      QR on the right, multi-line text in the middle column. Large spool ID
+      anchored at bottom-left under the swatch so it stays readable when the
+      label is on a box on a shelf at arm's length.
+    """
+    pad = 1.2 * mm
+    inner_x, inner_y = x + pad, y + pad
+    inner_w = w - 2 * pad
+    inner_h = h - 2 * pad
+
+    # Outer hairline border so labels are easy to cut out from blank stock.
+    c.setStrokeColor(HexColor(0xCCCCCC))
+    c.setLineWidth(0.4)
+    c.rect(x, y, w, h, stroke=1, fill=0)
+
+    is_tight = h < 20 * mm
+
+    if is_tight:
+        _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
+    else:
+        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
+
+
+def _draw_label_tight(
+    c: rl_canvas.Canvas,
+    x: float,
+    y: float,
+    w: float,
+    h: float,
+    inner_x: float,
+    inner_y: float,
+    inner_w: float,
+    inner_h: float,
+    pad: float,
+    data: LabelData,
+) -> None:
+    """AMS-holder layout (e.g. 30×15 mm). Swatch + 3-line text, no QR."""
+    swatch_w = min(inner_h, inner_w * 0.35)
+    swatch_y = inner_y + (inner_h - swatch_w) / 2
+    _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
+
+    text_x = inner_x + swatch_w + pad
+    text_w = inner_w - swatch_w - pad
+    if text_w < 5 * mm:
+        return  # Pathological — even the swatch barely fits.
+
+    c.setFillColor(black)
+
+    # Top: brand (small)
+    brand_size = 5.5
+    if data.brand:
+        c.setFont("Helvetica", brand_size)
+        brand = _truncate_to_width(c, data.brand, "Helvetica", brand_size, text_w)
+        c.drawString(text_x, y + h - pad - brand_size, brand)
+
+    # Second line: material + subtype, small
+    sub_size = 5.5
+    sub_line = " ".join(filter(None, [data.material, data.subtype]))
+    if sub_line:
+        c.setFont("Helvetica", sub_size)
+        sub_line = _truncate_to_width(c, sub_line, "Helvetica", sub_size, text_w)
+        sub_y = y + h - pad - brand_size - 0.6 - sub_size
+        c.drawString(text_x, sub_y, sub_line)
+
+    # Bottom: BIG spool ID — the killer field at-a-glance.
+    id_size = 13
+    c.setFont("Helvetica-Bold", id_size)
+    id_text = _truncate_to_width(c, f"#{data.spool_id}", "Helvetica-Bold", id_size, text_w)
+    c.drawString(text_x, inner_y + 0.5, id_text)
+
+
+def _draw_label_roomy(
+    c: rl_canvas.Canvas,
+    x: float,
+    y: float,
+    w: float,
+    h: float,
+    inner_x: float,
+    inner_y: float,
+    inner_w: float,
+    inner_h: float,
+    pad: float,
+    data: LabelData,
+) -> None:
+    """Box-label / Avery layout. Swatch left, QR right, text middle."""
+    # Swatch: full inner height, ~18% of inner width but capped so we never
+    # eat the text column on extreme aspect ratios.
+    swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
+    swatch_h = inner_h
+    _draw_swatch(c, inner_x, inner_y, swatch_w, swatch_h, data)
+
+    # QR: square, capped at the smaller of (a fraction of width, the inner
+    # height, or 18 mm — beyond that the QR is overkill for the print size).
+    qr_size = min(inner_w * 0.20, inner_h, 18 * mm)
+    qr_x = x + w - pad - qr_size
+    qr_y = inner_y + (inner_h - qr_size) / 2
+    _draw_qr(c, qr_x, qr_y, qr_size, data.deeplink_url)
+
+    text_x = inner_x + swatch_w + 1.5 * mm
+    text_w = qr_x - text_x - 1.5 * mm
+    if text_w < 8 * mm:
+        return
+
+    c.setFillColor(black)
+
+    # Build the text rows we want to render, in top→bottom order.
+    line1 = data.brand or ""
+    line2 = " · ".join(filter(None, [data.material, data.subtype]))
+    name = data.name or ""
+
+    # Layout from the top of the text column.
+    cursor_y = y + h - pad
+
+    if line1:
+        size = 7
+        c.setFont("Helvetica", size)
+        text = _truncate_to_width(c, line1, "Helvetica", size, text_w)
+        cursor_y -= size
+        c.drawString(text_x, cursor_y, text)
+        cursor_y -= 1.2
+
+    if line2:
+        size = 7
+        c.setFont("Helvetica", size)
+        text = _truncate_to_width(c, line2, "Helvetica", size, text_w)
+        cursor_y -= size
+        c.drawString(text_x, cursor_y, text)
+        cursor_y -= 1.5
+
+    if name and name != line1:
+        size = 9
+        c.setFont("Helvetica-Bold", size)
+        text = _truncate_to_width(c, name, "Helvetica-Bold", size, text_w)
+        cursor_y -= size
+        c.drawString(text_x, cursor_y, text)
+        cursor_y -= 1.2
+
+    if data.storage_location:
+        size = 6.5
+        c.setFont("Helvetica-Oblique", size)
+        text = _truncate_to_width(c, data.storage_location, "Helvetica-Oblique", size, text_w)
+        cursor_y -= size
+        c.drawString(text_x, cursor_y, text)
+
+    # Spool ID — anchored at the bottom of the text column, big and bold.
+    id_size = 16
+    c.setFont("Helvetica-Bold", id_size)
+    id_text = _truncate_to_width(c, f"#{data.spool_id}", "Helvetica-Bold", id_size, text_w)
+    c.drawString(text_x, inner_y + 0.5, id_text)
+
+
+# ── Template entry points ────────────────────────────────────────────────────
+
+# (label_w_mm, label_h_mm) for single-label-per-page templates.
+_SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
+    "ams_30x15": (30.0, 15.0),
+    "box_62x29": (62.0, 29.0),
+}
+
+# Sheet template parameters: (page_size, label_w_mm, label_h_mm,
+#                              cols, rows, top_margin_mm, left_margin_mm,
+#                              col_gap_mm, row_gap_mm)
+_SHEET_TEMPLATES: dict[str, tuple] = {
+    "avery_5160": (letter, 66.675, 25.4, 3, 10, 12.7, 4.76, 3.175, 0.0),
+    "avery_l7160": (A4, 63.5, 38.1, 3, 7, 15.15, 7.0, 2.5, 0.0),
+}
+
+
+def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
+    w_mm, h_mm = _SINGLE_LABEL_SIZES_MM[template]
+    page_w, page_h = w_mm * mm, h_mm * mm
+
+    buf = io.BytesIO()
+    c = rl_canvas.Canvas(buf, pagesize=(page_w, page_h))
+    c.setTitle(f"Bambuddy spool labels ({template})")
+
+    for data in data_list:
+        _draw_label(c, 0, 0, page_w, page_h, data)
+        c.showPage()
+
+    c.save()
+    return buf.getvalue()
+
+
+def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
+    page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
+    page_w, page_h = page_size
+
+    label_w = w_mm * mm
+    label_h = h_mm * mm
+    top_margin = top_mm * mm
+    left_margin = left_mm * mm
+    col_gap = col_gap_mm * mm
+    row_gap = row_gap_mm * mm
+
+    buf = io.BytesIO()
+    c = rl_canvas.Canvas(buf, pagesize=page_size)
+    c.setTitle(f"Bambuddy spool labels ({template})")
+
+    per_page = cols * rows
+    for page_start in range(0, len(data_list), per_page):
+        chunk = data_list[page_start : page_start + per_page]
+        for idx, data in enumerate(chunk):
+            row = idx // cols
+            col = idx % cols
+            x = left_margin + col * (label_w + col_gap)
+            y = page_h - top_margin - (row + 1) * label_h - row * row_gap
+            _draw_label(c, x, y, label_w, label_h, data)
+        c.showPage()
+
+    c.save()
+    return buf.getvalue()
+
+
+def render_labels(template: TemplateName, data_list: list[LabelData]) -> bytes:
+    """Render ``data_list`` to a PDF using the named template. Returns bytes.
+
+    Empty ``data_list`` still produces a valid (empty) PDF — callers should
+    short-circuit beforehand if that's not desired.
+    """
+    if template in _SINGLE_LABEL_SIZES_MM:
+        return _render_single_label_pdf(template, data_list)
+    if template in _SHEET_TEMPLATES:
+        return _render_sheet_pdf(template, data_list)
+    raise ValueError(f"Unknown label template: {template!r}")
+
+
+__all__ = ["LabelData", "TemplateName", "render_labels"]
+# white re-exported for completeness; future templates may need a paper-tone variant.
+_ = white

+ 248 - 0
backend/tests/integration/test_labels.py

@@ -0,0 +1,248 @@
+"""Integration tests for the spool-label routes (#809).
+
+Covers both ``POST /inventory/labels`` (local DB) and ``POST /spoolman/labels``
+(Spoolman-backed). The renderer itself has its own unit tests; these tests
+focus on auth, request validation, mode gating, and the wiring between route
+and renderer.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    """Factory to create test spools."""
+    _counter = [0]
+
+    async def _create_spool(**kwargs):
+        _counter[0] += 1
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Polymaker",
+            "color_name": f"Test {_counter[0]}",
+            "rgba": "FF8800FF",
+            "label_weight": 1000,
+            "weight_used": 0,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create_spool
+
+
+# ── /inventory/labels (local DB) ─────────────────────────────────────────────
+
+
+class TestLocalInventoryLabels:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_renders_pdf_for_local_spools(self, async_client: AsyncClient, spool_factory):
+        s1 = await spool_factory()
+        s2 = await spool_factory(material="PETG", brand="Sunlu")
+
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            json={"spool_ids": [s1.id, s2.id], "template": "box_62x29"},
+        )
+        assert resp.status_code == 200
+        assert resp.headers["content-type"] == "application/pdf"
+        assert resp.content.startswith(b"%PDF")
+        assert int(resp.headers["content-length"]) == len(resp.content)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_all_four_templates_succeed(self, async_client: AsyncClient, spool_factory):
+        s = await spool_factory()
+        for template in ("ams_30x15", "box_62x29", "avery_5160", "avery_l7160"):
+            resp = await async_client.post(
+                "/api/v1/inventory/labels",
+                json={"spool_ids": [s.id], "template": template},
+            )
+            assert resp.status_code == 200, f"{template} returned {resp.status_code}: {resp.text}"
+            assert resp.content.startswith(b"%PDF")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_template_rejected(self, async_client: AsyncClient, spool_factory):
+        s = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            json={"spool_ids": [s.id], "template": "totally_made_up"},
+        )
+        # Pydantic Literal validation → 422
+        assert resp.status_code in (400, 422)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_spool_ids_rejected(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            json={"spool_ids": [], "template": "box_62x29"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_spool_id_returns_404(self, async_client: AsyncClient, spool_factory):
+        s = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            json={"spool_ids": [s.id, 99999], "template": "ams_30x15"},
+        )
+        assert resp.status_code == 404
+        assert "99999" in resp.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preserves_request_order(self, async_client: AsyncClient, spool_factory):
+        """Caller's `spool_ids` order should match the on-screen list — important
+        for Avery sheet layouts where users curate the layout via filtering."""
+        s1 = await spool_factory()
+        s2 = await spool_factory()
+        s3 = await spool_factory()
+
+        # Reverse order; assert the route doesn't sort them. We can't peek
+        # inside the PDF for assertion, but we can call render_labels directly
+        # under the same patches and compare bytes deterministically.
+        from backend.app.api.routes import labels as labels_module
+
+        captured = {}
+
+        original = labels_module.render_labels
+
+        def _capture(template, data_list):
+            captured["ids"] = [d.spool_id for d in data_list]
+            return original(template, data_list)
+
+        with patch.object(labels_module, "render_labels", side_effect=_capture):
+            resp = await async_client.post(
+                "/api/v1/inventory/labels",
+                json={"spool_ids": [s3.id, s1.id, s2.id], "template": "avery_l7160"},
+            )
+        assert resp.status_code == 200
+        assert captured["ids"] == [s3.id, s1.id, s2.id]
+
+
+# ── /spoolman/labels (Spoolman-backed) ───────────────────────────────────────
+
+
+class TestSpoolmanLabels:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_400_when_spoolman_disabled(self, async_client: AsyncClient):
+        # Default state in tests: spoolman_enabled is unset / "false"
+        resp = await async_client.post(
+            "/api/v1/spoolman/labels",
+            json={"spool_ids": [1], "template": "box_62x29"},
+        )
+        assert resp.status_code == 400
+        assert "Spoolman" in resp.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_503_when_spoolman_unreachable(self, async_client: AsyncClient, db_session: AsyncSession):
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        await db_session.commit()
+
+        with patch("backend.app.api.routes.labels.get_spoolman_client", AsyncMock(return_value=None)):
+            resp = await async_client.post(
+                "/api/v1/spoolman/labels",
+                json={"spool_ids": [1], "template": "box_62x29"},
+            )
+        assert resp.status_code == 503
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_renders_pdf_from_spoolman_data(self, async_client: AsyncClient, db_session: AsyncSession):
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        await db_session.commit()
+
+        spoolman_spool = {
+            "id": 42,
+            "filament": {
+                "name": "PolyTerra Sapphire Blue",
+                "material": "PLA",
+                "color_hex": "0033AA",
+                "vendor": {"name": "Polymaker"},
+            },
+            "location": "Shelf 5, slot C",
+        }
+        mock_client = MagicMock()
+        mock_client.is_connected = True
+        mock_client.get_spools = AsyncMock(return_value=[spoolman_spool])
+
+        with patch(
+            "backend.app.api.routes.labels.get_spoolman_client",
+            AsyncMock(return_value=mock_client),
+        ):
+            resp = await async_client.post(
+                "/api/v1/spoolman/labels",
+                json={"spool_ids": [42], "template": "avery_l7160"},
+            )
+
+        assert resp.status_code == 200
+        assert resp.headers["content-type"] == "application/pdf"
+        assert resp.content.startswith(b"%PDF")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_404_when_spool_missing_from_spoolman(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        await db_session.commit()
+
+        mock_client = MagicMock()
+        mock_client.is_connected = True
+        mock_client.get_spools = AsyncMock(return_value=[{"id": 1, "filament": {"name": "X", "material": "PLA"}}])
+
+        with patch(
+            "backend.app.api.routes.labels.get_spoolman_client",
+            AsyncMock(return_value=mock_client),
+        ):
+            resp = await async_client.post(
+                "/api/v1/spoolman/labels",
+                json={"spool_ids": [99], "template": "box_62x29"},
+            )
+        assert resp.status_code == 404
+        assert "99" in resp.text
+
+
+# ── Validation cross-cutting ─────────────────────────────────────────────────
+
+
+class TestValidation:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_request_body_size_capped(self, async_client: AsyncClient):
+        """spool_ids is bounded to MAX_LABELS_PER_REQUEST so a runaway client
+        can't flood the renderer."""
+        from backend.app.api.routes.labels import MAX_LABELS_PER_REQUEST
+
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            json={
+                "spool_ids": list(range(1, MAX_LABELS_PER_REQUEST + 2)),
+                "template": "box_62x29",
+            },
+        )
+        assert resp.status_code == 422

+ 225 - 0
backend/tests/unit/services/test_label_renderer.py

@@ -0,0 +1,225 @@
+"""Unit tests for the spool label renderer (#809)."""
+
+from __future__ import annotations
+
+import pytest
+
+from backend.app.services.label_renderer import LabelData, render_labels
+
+ALL_TEMPLATES = ("ams_30x15", "box_62x29", "avery_5160", "avery_l7160")
+
+
+def _sample(spool_id: int = 1, **overrides) -> LabelData:
+    return LabelData(
+        spool_id=spool_id,
+        name=overrides.pop("name", "Polymaker Ivory"),
+        material=overrides.pop("material", "PLA"),
+        brand=overrides.pop("brand", "Polymaker"),
+        subtype=overrides.pop("subtype", "Matte"),
+        rgba=overrides.pop("rgba", "F5E6D3FF"),
+        extra_colors=overrides.pop("extra_colors", None),
+        storage_location=overrides.pop("storage_location", None),
+        deeplink_url=overrides.pop("deeplink_url", f"https://example.test/inventory?spool={spool_id}"),
+    )
+
+
+@pytest.mark.parametrize("template", ALL_TEMPLATES)
+def test_renders_valid_pdf_for_each_template(template):
+    pdf = render_labels(template, [_sample(7), _sample(8)])
+    assert pdf.startswith(b"%PDF"), f"{template} did not produce a PDF header"
+    assert pdf.endswith(b"%%EOF\n") or pdf.rstrip().endswith(b"%%EOF")
+
+
+@pytest.mark.parametrize("template", ALL_TEMPLATES)
+def test_empty_input_still_returns_valid_pdf(template):
+    """Empty list is allowed; renderer returns a valid (mostly empty) PDF."""
+    pdf = render_labels(template, [])
+    assert pdf.startswith(b"%PDF")
+
+
+def test_unknown_template_raises():
+    with pytest.raises(ValueError, match="Unknown label template"):
+        render_labels("not_a_template", [_sample()])  # type: ignore[arg-type]
+
+
+def test_multi_color_swatch_does_not_crash():
+    data = [_sample(extra_colors=["FF0000", "00FF00", "0000FF", "FFFF00"])]
+    pdf = render_labels("box_62x29", data)
+    assert pdf.startswith(b"%PDF")
+
+
+def test_missing_optional_fields_does_not_crash():
+    """Brand/subtype/rgba/storage_location all None — should still render."""
+    data = [
+        LabelData(
+            spool_id=42,
+            name="Test",
+            material="PLA",
+            deeplink_url="https://example.test/inventory?spool=42",
+        )
+    ]
+    pdf = render_labels("ams_30x15", data)
+    assert pdf.startswith(b"%PDF")
+
+
+def test_malformed_rgba_falls_back_to_grey():
+    """rgba="zzz" (invalid hex) must not raise — fallback colour used."""
+    data = [_sample(rgba="not-a-color")]
+    pdf = render_labels("avery_l7160", data)
+    assert pdf.startswith(b"%PDF")
+
+
+def test_long_strings_are_truncated_not_overflowed():
+    """Very long brand/name shouldn't blow up the layout or raise."""
+    long_brand = "A" * 200
+    long_name = "B" * 300
+    data = [_sample(brand=long_brand, name=long_name)]
+    pdf = render_labels("ams_30x15", data)
+    assert pdf.startswith(b"%PDF")
+
+
+def test_sheet_template_paginates_when_count_exceeds_one_sheet():
+    """Avery 5160 = 30 per sheet; 31 spools must paginate to 2 pages.
+
+    We can't easily count pages from raw PDF bytes, but we can at least
+    verify the output is meaningfully larger than a single-page rendering.
+    """
+    one = render_labels("avery_5160", [_sample(i) for i in range(1, 31)])
+    two = render_labels("avery_5160", [_sample(i) for i in range(1, 32)])
+    assert len(two) > len(one)
+
+
+def test_qr_payload_is_present_in_pdf_stream():
+    """The QR encodes the deeplink URL via embedded PNG; we can at least
+    sanity-check that the PDF contains an image stream when a deeplink is set
+    and no image stream when the renderer skips QR generation for an empty URL.
+    """
+    with_qr = render_labels("box_62x29", [_sample(deeplink_url="https://example.test/inventory?spool=1")])
+    without_qr = render_labels("box_62x29", [_sample(deeplink_url="")])
+    # PDFs with embedded raster images are noticeably larger than pure-vector ones.
+    assert len(with_qr) > len(without_qr) + 200, (
+        "Expected QR-bearing PDF to be substantially larger than QR-less version"
+    )
+
+
+# ── Regression tests for the two render bugs found in the first cut ──
+
+
+def _render_uncompressed(template, data):
+    """Render with pageCompression=0 so the resulting PDF contains text as
+    ASCII bytes. Lets tests assert "X is on the label" by grepping the PDF.
+
+    Uses the same internal draw helpers as the real renderer; only the
+    page-level compression flag differs.
+    """
+    import io as _io
+
+    from reportlab.lib.pagesizes import A4, letter
+    from reportlab.lib.units import mm as _mm
+    from reportlab.pdfgen import canvas as _rl_canvas
+
+    from backend.app.services.label_renderer import _draw_label  # noqa: PLC0415
+
+    # Mirror the page-size choice from render_labels but force pageCompression=0.
+    if template in ("ams_30x15", "box_62x29"):
+        sizes = {"ams_30x15": (30.0, 15.0), "box_62x29": (62.0, 29.0)}
+        w_mm, h_mm = sizes[template]
+        page_w, page_h = w_mm * _mm, h_mm * _mm
+        buf = _io.BytesIO()
+        c = _rl_canvas.Canvas(buf, pagesize=(page_w, page_h), pageCompression=0)
+        for d in data:
+            _draw_label(c, 0, 0, page_w, page_h, d)
+            c.showPage()
+        c.save()
+        return buf.getvalue()
+    if template == "avery_5160":
+        page_size = letter
+        label_w_mm, label_h_mm = 66.675, 25.4
+        cols, rows = 3, 10
+        top_mm, left_mm, col_gap_mm = 12.7, 4.76, 3.175
+    else:  # avery_l7160
+        page_size = A4
+        label_w_mm, label_h_mm = 63.5, 38.1
+        cols, rows = 3, 7
+        top_mm, left_mm, col_gap_mm = 15.15, 7.0, 2.5
+    buf = _io.BytesIO()
+    c = _rl_canvas.Canvas(buf, pagesize=page_size, pageCompression=0)
+    page_w, page_h = page_size
+    label_w, label_h = label_w_mm * _mm, label_h_mm * _mm
+    per_page = cols * rows
+    for page_start in range(0, len(data), per_page):
+        chunk = data[page_start : page_start + per_page]
+        for idx, d in enumerate(chunk):
+            row = idx // cols
+            col = idx % cols
+            x = left_mm * _mm + col * (label_w + col_gap_mm * _mm)
+            y = page_h - top_mm * _mm - (row + 1) * label_h
+            _draw_label(c, x, y, label_w, label_h, d)
+        c.showPage()
+    c.save()
+    return buf.getvalue()
+
+
+def test_ams_template_actually_renders_text():
+    """Regression: the first cut of the AMS-holder layout produced labels with
+    only swatch + QR and no text at all because the side-by-side layout left
+    <5 mm for the text column. The redesign drops the QR on this template and
+    gives the right side to brand + material + spool ID. This pins that the
+    rendered PDF contains all three fields.
+    """
+    data = [
+        LabelData(
+            spool_id=42,
+            name="Test",
+            material="PLA",
+            brand="Polymaker",
+            subtype="Matte",
+            rgba="F5E6D3FF",
+            deeplink_url="https://example.test/inventory?spool=42",
+        )
+    ]
+    pdf = _render_uncompressed("ams_30x15", data)
+    assert b"Polymaker" in pdf, "AMS template must render the brand"
+    assert b"PLA" in pdf, "AMS template must render the material"
+    # The bracketed-hash style is what the renderer uses for the spool ID;
+    # ReportLab's `#` is in the BaseFont, so it appears as literal `#` in the
+    # uncompressed stream alongside the digits.
+    assert b"#42" in pdf or (b"42" in pdf and b"#" in pdf), (
+        "AMS template must render the spool ID — that's the killer field"
+    )
+
+
+def test_box_template_does_not_truncate_normal_brand_or_name():
+    """Regression: the first cut of the box-label layout sized the swatch and
+    QR each at ~14 mm on a 26-mm-wide text column, leaving only ~16 mm for
+    text and aggressively truncating "Polymaker · PLA · Matte" to
+    "Polymaker …" and "Polymaker Ivory" to "Polymak…". The redesign caps the
+    swatch and QR widths so a typical brand + name renders without truncation.
+    """
+    data = [
+        LabelData(
+            spool_id=7,
+            name="Polymaker Ivory",
+            material="PLA",
+            brand="Polymaker",
+            subtype="Matte",
+            rgba="F5E6D3FF",
+            storage_location="Shelf 3, slot B",
+            deeplink_url="https://example.test/inventory?spool=7",
+        )
+    ]
+    pdf = _render_uncompressed("box_62x29", data)
+    # Brand on its own line — must not be truncated.
+    assert b"Polymaker" in pdf, "box template must render the brand"
+    # Material + subtype on its own line — must not be truncated.
+    assert b"Matte" in pdf, "box template must render the subtype"
+    # Spool name (bold) — must include both words. Truncation would have
+    # produced "Polymak\xe2\x80\xa6" in the original bug, so asserting the
+    # second word "Ivory" is on the label is the regression-pin.
+    assert b"Ivory" in pdf, (
+        "box template must render the spool name fully — earlier layout truncated 'Polymaker Ivory' to 'Polymak…'"
+    )
+    # Storage location (italic).
+    assert b"Shelf 3, slot B" in pdf, "box template must render the storage location"
+    # Big spool ID at bottom.
+    assert b"#7" in pdf or (b"7" in pdf and b"#" in pdf), "box template must render the spool ID"

+ 277 - 0
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -0,0 +1,277 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { LabelTemplatePickerModal } from '../../components/LabelTemplatePickerModal';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    printSpoolLabels: vi.fn(),
+    printSpoolmanSpoolLabels: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+}));
+
+const PDF_BLOB = new Blob([new Uint8Array([0x25, 0x50, 0x44, 0x46])], { type: 'application/pdf' });
+
+const SPOOLS = [
+  { id: 1, material: 'PLA', subtype: 'Basic', brand: 'Polymaker', color_name: 'Red', rgba: 'FF0000FF' },
+  { id: 2, material: 'PETG', subtype: null, brand: 'Sunlu', color_name: 'Blue', rgba: '0000FFFF' },
+  { id: 3, material: 'ABS', subtype: null, brand: null, color_name: 'Black', rgba: '000000FF' },
+  { id: 4, material: 'PLA', subtype: 'Matte', brand: 'Polymaker', color_name: 'Ivory', rgba: 'F5E6D3FF' },
+];
+
+beforeEach(() => {
+  vi.clearAllMocks();
+  vi.mocked(api.getSettings).mockResolvedValue({} as never);
+  vi.mocked(api.getAuthStatus).mockResolvedValue({ auth_enabled: false } as never);
+  Object.defineProperty(window.URL, 'createObjectURL', {
+    value: vi.fn(() => 'blob:mock'),
+    configurable: true,
+  });
+  Object.defineProperty(window.URL, 'revokeObjectURL', {
+    value: vi.fn(),
+    configurable: true,
+  });
+  vi.spyOn(window, 'open').mockImplementation(() => ({}) as Window);
+});
+
+describe('LabelTemplatePickerModal', () => {
+  it('does not render when closed', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={false}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+    expect(screen.queryByText(/Print spool labels/i)).not.toBeInTheDocument();
+  });
+
+  it('lists all available spools by default', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+    expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
+    expect(screen.getByText(/Blue · Sunlu/)).toBeInTheDocument();
+    expect(screen.getByText(/Black/)).toBeInTheDocument();
+    expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
+  });
+
+  it('shows the live selected count in the header', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1, 4]}
+        spoolmanMode={false}
+      />,
+    );
+    expect(screen.getByText(/2 selected/i)).toBeInTheDocument();
+  });
+
+  it('search narrows the list but preserves selection state', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[3]}  // Black ABS pre-selected
+        spoolmanMode={false}
+      />,
+    );
+    const searchInput = screen.getByPlaceholderText(/Search name, brand, or #ID/i);
+    fireEvent.change(searchInput, { target: { value: 'polymaker' } });
+    // Polymaker spools (Red, Ivory) visible; Sunlu/no-brand hidden
+    expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
+    expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
+    expect(screen.queryByText(/Blue · Sunlu/)).not.toBeInTheDocument();
+    expect(screen.queryByText(/^Black$/)).not.toBeInTheDocument();
+    // Selection still includes the now-hidden Black ABS
+    expect(screen.getByText(/1 selected/i)).toBeInTheDocument();
+  });
+
+  it('search by spool ID works', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[]}
+        spoolmanMode={false}
+      />,
+    );
+    fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: '#2' } });
+    expect(screen.getByText(/Blue · Sunlu/)).toBeInTheDocument();
+    expect(screen.queryByText(/Red · Polymaker/)).not.toBeInTheDocument();
+  });
+
+  it('material chip narrows the visible list', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[]}
+        spoolmanMode={false}
+      />,
+    );
+    // Pick the "PLA" chip
+    fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
+    expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
+    expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
+    expect(screen.queryByText(/Blue · Sunlu/)).not.toBeInTheDocument();
+  });
+
+  it('Select all visible only adds visible spools to the selection', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[3]}  // start with Black ABS selected
+        spoolmanMode={false}
+      />,
+    );
+    // Filter to PLA, then Select all visible — should add the 2 PLA spools to
+    // the selection without dropping Black ABS.
+    fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
+    fireEvent.click(screen.getByText(/Select all visible/i));
+    expect(screen.getByText(/3 selected/i)).toBeInTheDocument();
+  });
+
+  it('Clear all empties the selection regardless of filter', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1, 2, 3, 4]}
+        spoolmanMode={false}
+      />,
+    );
+    fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
+    fireEvent.click(screen.getByText(/Clear all/i));
+    // Header count badge disappears once selection hits 0
+    expect(screen.queryByText(/selected/i)).not.toBeInTheDocument();
+  });
+
+  it('template buttons disabled when nothing is selected', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[]}
+        spoolmanMode={false}
+      />,
+    );
+    expect(screen.getByText(/AMS holder/i).closest('button')).toBeDisabled();
+  });
+
+  it('sends only the currently checked IDs to the local endpoint', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    const onClose = vi.fn();
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={onClose}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1, 2, 3]}
+        spoolmanMode={false}
+      />,
+    );
+
+    fireEvent.click(screen.getByText(/Blue · Sunlu/));  // uncheck spool 2
+    fireEvent.click(screen.getByText(/Box label/i));
+
+    await waitFor(() => {
+      expect(api.printSpoolLabels).toHaveBeenCalledWith({
+        spool_ids: [1, 3],
+        template: 'box_62x29',
+      });
+    });
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+
+  it('routes to the Spoolman endpoint when spoolmanMode is true', async () => {
+    vi.mocked(api.printSpoolmanSpoolLabels).mockResolvedValue(PDF_BLOB);
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={true}
+      />,
+    );
+
+    fireEvent.click(screen.getByText(/AMS holder/i));
+
+    await waitFor(() => {
+      expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
+        spool_ids: [1],
+        template: 'ams_30x15',
+      });
+    });
+    expect(api.printSpoolLabels).not.toHaveBeenCalled();
+  });
+
+  it('keeps the modal open and shows error when the API rejects', async () => {
+    vi.mocked(api.printSpoolLabels).mockRejectedValue(new Error('boom'));
+    const onClose = vi.fn();
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={onClose}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+
+    fireEvent.click(screen.getByText(/Avery L7160/i));
+
+    await waitFor(() => {
+      expect(api.printSpoolLabels).toHaveBeenCalled();
+    });
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('shows empty-state message when no spools are available at all', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={[]}
+        initialSelectedIds={[]}
+        spoolmanMode={false}
+      />,
+    );
+    expect(screen.getByText(/No spools to show/i)).toBeInTheDocument();
+  });
+
+  it('shows no-matches message when search excludes everything', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[]}
+        spoolmanMode={false}
+      />,
+    );
+    fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: 'zzz-no-match' } });
+    expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
+  });
+});

+ 34 - 0
frontend/src/api/client.ts

@@ -2259,6 +2259,9 @@ export interface LinkedSpoolsMap {
 }
 }
 
 
 // Inventory types
 // Inventory types
+// Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
+export type SpoolLabelTemplate = 'ams_30x15' | 'box_62x29' | 'avery_5160' | 'avery_l7160';
+
 export interface InventorySpool {
 export interface InventorySpool {
   id: number;
   id: number;
   material: string;
   material: string;
@@ -4382,6 +4385,37 @@ export const api = {
     }),
     }),
   unassignSpool: (printerId: number, amsId: number, trayId: number) =>
   unassignSpool: (printerId: number, amsId: number, trayId: number) =>
     request<{ status: string }>(`/inventory/assignments/${printerId}/${amsId}/${trayId}`, { method: 'DELETE' }),
     request<{ status: string }>(`/inventory/assignments/${printerId}/${amsId}/${trayId}`, { method: 'DELETE' }),
+  // ── Spool label printing (#809) ──────────────────────────────────────────
+  // Both endpoints return application/pdf. Frontend opens the resulting Blob
+  // in a new tab so the user can print or save from the browser's PDF viewer.
+  printSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate }): Promise<Blob> => {
+    const headers: Record<string, string> = { 'Content-Type': 'application/json' };
+    if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
+    const response = await fetch(`${API_BASE}/inventory/labels`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify(data),
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.blob();
+  },
+  printSpoolmanSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate }): Promise<Blob> => {
+    const headers: Record<string, string> = { 'Content-Type': 'application/json' };
+    if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
+    const response = await fetch(`${API_BASE}/spoolman/labels`, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify(data),
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.blob();
+  },
   getSpoolCatalog: () =>
   getSpoolCatalog: () =>
     request<SpoolCatalogEntry[]>('/inventory/catalog'),
     request<SpoolCatalogEntry[]>('/inventory/catalog'),
   addCatalogEntry: (data: { name: string; weight: number }) =>
   addCatalogEntry: (data: { name: string; weight: number }) =>

+ 387 - 0
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -0,0 +1,387 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { X, Loader2, Printer, CheckSquare, Square, Search } from 'lucide-react';
+import { api, type SpoolLabelTemplate, type InventorySpool } from '../api/client';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+/** Subset of InventorySpool the modal needs for checkbox rendering. */
+type SpoolForLabel = Pick<
+  InventorySpool,
+  'id' | 'material' | 'subtype' | 'brand' | 'color_name' | 'rgba'
+>;
+
+interface LabelTemplatePickerModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  /** All spools the modal can choose from. Typically the page's current
+   *  filter result so the modal stays consistent with what the user sees. */
+  availableSpools: SpoolForLabel[];
+  /** IDs to pre-check when the modal opens. Per-card icon passes a single ID;
+   *  the bulk header button passes every visible ID so the user lands in
+   *  "all checked" and refines downward. */
+  initialSelectedIds: number[];
+  spoolmanMode: boolean;
+}
+
+interface TemplateOption {
+  value: SpoolLabelTemplate;
+  i18nKey: string;
+  fallbackLabel: string;
+  fallbackHint: string;
+}
+
+const TEMPLATE_OPTIONS: TemplateOption[] = [
+  {
+    value: 'ams_30x15',
+    i18nKey: 'ams',
+    fallbackLabel: 'AMS holder (30 × 15 mm)',
+    fallbackHint: 'Single label per page; fits the popular AMS filament label holder.',
+  },
+  {
+    value: 'box_62x29',
+    i18nKey: 'box',
+    fallbackLabel: 'Box label (62 × 29 mm)',
+    fallbackHint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+  },
+  {
+    value: 'avery_l7160',
+    i18nKey: 'averyL7160',
+    fallbackLabel: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+    fallbackHint: 'EU sheet stock; 21 labels per A4 page.',
+  },
+  {
+    value: 'avery_5160',
+    i18nKey: 'avery5160',
+    fallbackLabel: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+    fallbackHint: 'US sheet stock; 30 labels per Letter page.',
+  },
+];
+
+function openBlobInNewTab(blob: Blob): void {
+  const url = window.URL.createObjectURL(blob);
+  const win = window.open(url, '_blank', 'noopener,noreferrer');
+  if (!win) {
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = 'bambuddy-labels.pdf';
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+  }
+  setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
+}
+
+function swatchStyle(rgba: string | null | undefined): React.CSSProperties {
+  if (!rgba) return { backgroundColor: '#808080' };
+  const cleaned = rgba.replace(/^#/, '').slice(0, 6);
+  return cleaned.length === 6 ? { backgroundColor: `#${cleaned}` } : { backgroundColor: '#808080' };
+}
+
+function spoolDisplayName(s: SpoolForLabel): string {
+  const head = s.color_name ?? `${s.material}${s.subtype ? ` ${s.subtype}` : ''}`;
+  const brand = s.brand ? ` · ${s.brand}` : '';
+  return `${head}${brand}`;
+}
+
+/** Build a lowercased haystack that the search input matches against. */
+function searchableText(s: SpoolForLabel): string {
+  return [s.color_name, s.material, s.subtype, s.brand, `#${s.id}`]
+    .filter(Boolean)
+    .join(' ')
+    .toLowerCase();
+}
+
+export function LabelTemplatePickerModal({
+  isOpen,
+  onClose,
+  availableSpools,
+  initialSelectedIds,
+  spoolmanMode,
+}: LabelTemplatePickerModalProps) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const [pending, setPending] = useState<SpoolLabelTemplate | null>(null);
+  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [search, setSearch] = useState('');
+  const [materialFilter, setMaterialFilter] = useState<string>('');
+
+  // Sync from caller and reset transient state on open. Intentionally not
+  // reactive to props while open — once the user starts editing we don't want
+  // a parent re-render to clobber their selection / filter / search.
+  useEffect(() => {
+    if (isOpen) {
+      const allowed = new Set(availableSpools.map((s) => s.id));
+      setSelectedIds(new Set(initialSelectedIds.filter((id) => allowed.has(id))));
+      setSearch('');
+      setMaterialFilter('');
+      setPending(null);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [isOpen]);
+
+  const sortedSpools = useMemo(
+    () => [...availableSpools].sort((a, b) => a.id - b.id),
+    [availableSpools],
+  );
+
+  // Material chips are derived from the *full* available set so they stay
+  // stable when search/material filter narrows the visible list.
+  const materials = useMemo(() => {
+    const set = new Set<string>();
+    for (const s of sortedSpools) {
+      if (s.material) set.add(s.material.toUpperCase());
+    }
+    return [...set].sort();
+  }, [sortedSpools]);
+
+  const visibleSpools = useMemo(() => {
+    const q = search.trim().toLowerCase();
+    return sortedSpools.filter((s) => {
+      if (materialFilter && (s.material || '').toUpperCase() !== materialFilter) return false;
+      if (q && !searchableText(s).includes(q)) return false;
+      return true;
+    });
+  }, [sortedSpools, search, materialFilter]);
+
+  const allVisibleChecked =
+    visibleSpools.length > 0 && visibleSpools.every((s) => selectedIds.has(s.id));
+
+  if (!isOpen) return null;
+
+  const selectedCount = selectedIds.size;
+  const noSelection = selectedCount === 0;
+
+  function toggleOne(id: number) {
+    setSelectedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  }
+
+  function selectAllVisible() {
+    setSelectedIds((prev) => {
+      const next = new Set(prev);
+      for (const s of visibleSpools) next.add(s.id);
+      return next;
+    });
+  }
+
+  function deselectVisible() {
+    setSelectedIds((prev) => {
+      const next = new Set(prev);
+      for (const s of visibleSpools) next.delete(s.id);
+      return next;
+    });
+  }
+
+  function clearAll() {
+    setSelectedIds(new Set());
+  }
+
+  async function handlePick(template: SpoolLabelTemplate) {
+    if (noSelection || pending) return;
+    const ids = [...selectedIds].sort((a, b) => a - b);
+    setPending(template);
+    try {
+      const blob = spoolmanMode
+        ? await api.printSpoolmanSpoolLabels({ spool_ids: ids, template })
+        : await api.printSpoolLabels({ spool_ids: ids, template });
+      openBlobInNewTab(blob);
+      onClose();
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err);
+      showToast(
+        t('inventory.labels.error', 'Could not generate labels: {{msg}}', { msg }),
+        'error',
+      );
+    } finally {
+      setPending(null);
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-start sm:items-center justify-center p-4 overflow-y-auto">
+      <div
+        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+        onClick={onClose}
+      />
+
+      <div className="relative w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col my-auto">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2">
+            <Printer className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">
+              {t('inventory.labels.title', 'Print spool labels')}
+            </h2>
+            {selectedCount > 0 && (
+              <span className="text-sm text-bambu-gray">
+                ({t('inventory.labels.selectedCount', '{{count}} selected', { count: selectedCount })})
+              </span>
+            )}
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
+            aria-label={t('common.close', 'Close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Search + material chips */}
+        <div className="p-4 space-y-2 border-b border-bambu-dark-tertiary">
+          <div className="relative">
+            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+            <input
+              type="search"
+              value={search}
+              onChange={(e) => setSearch(e.target.value)}
+              placeholder={t('inventory.labels.searchPlaceholder', 'Search name, brand, or #ID')}
+              className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
+            />
+          </div>
+          {materials.length > 1 && (
+            <div className="flex flex-wrap items-center gap-1.5">
+              <span className="text-xs text-bambu-gray mr-1">
+                {t('inventory.labels.filterByMaterial', 'Material:')}
+              </span>
+              <button
+                type="button"
+                onClick={() => setMaterialFilter('')}
+                className={`px-2 py-0.5 text-xs rounded-full border transition ${
+                  materialFilter === ''
+                    ? 'bg-bambu-green text-bambu-dark border-bambu-green'
+                    : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
+                }`}
+              >
+                {t('inventory.labels.allMaterials', 'All')}
+              </button>
+              {materials.map((m) => (
+                <button
+                  key={m}
+                  type="button"
+                  onClick={() => setMaterialFilter(m)}
+                  className={`px-2 py-0.5 text-xs rounded-full border transition ${
+                    materialFilter === m
+                      ? 'bg-bambu-green text-bambu-dark border-bambu-green'
+                      : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
+                  }`}
+                >
+                  {m}
+                </button>
+              ))}
+            </div>
+          )}
+        </div>
+
+        {/* Action bar */}
+        <div className="px-4 pt-3 pb-2 flex items-center justify-between gap-3 flex-wrap">
+          <span className="text-sm text-bambu-gray">
+            {t('inventory.labels.pickSpools', 'Pick which spools to print labels for:')}
+          </span>
+          <div className="flex items-center gap-3 text-xs">
+            <button
+              type="button"
+              onClick={allVisibleChecked ? deselectVisible : selectAllVisible}
+              disabled={visibleSpools.length === 0}
+              className="text-bambu-green hover:underline disabled:opacity-50 disabled:no-underline disabled:cursor-not-allowed"
+            >
+              {allVisibleChecked
+                ? t('inventory.labels.deselectVisible', 'Deselect visible')
+                : t('inventory.labels.selectVisible', 'Select all visible ({{count}})', {
+                    count: visibleSpools.length,
+                  })}
+            </button>
+            <button
+              type="button"
+              onClick={clearAll}
+              disabled={selectedCount === 0}
+              className="text-bambu-gray hover:text-white hover:underline disabled:opacity-50 disabled:no-underline disabled:cursor-not-allowed"
+            >
+              {t('inventory.labels.clearAll', 'Clear all')}
+            </button>
+          </div>
+        </div>
+
+        {/* Spool list */}
+        <div className="flex-1 overflow-y-auto px-2 pb-2 min-h-[160px]">
+          {visibleSpools.length === 0 ? (
+            <div className="text-center text-sm text-bambu-gray py-6">
+              {sortedSpools.length === 0
+                ? t('inventory.labels.noSpoolsToShow', 'No spools to show. Adjust your filter and try again.')
+                : t('inventory.labels.noMatches', 'No spools match the current search or filter.')}
+            </div>
+          ) : (
+            <ul className="space-y-0.5">
+              {visibleSpools.map((s) => {
+                const checked = selectedIds.has(s.id);
+                return (
+                  <li key={s.id}>
+                    <label className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary/50 cursor-pointer">
+                      {checked ? (
+                        <CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
+                      ) : (
+                        <Square className="w-4 h-4 text-bambu-gray shrink-0" />
+                      )}
+                      <input
+                        type="checkbox"
+                        checked={checked}
+                        onChange={() => toggleOne(s.id)}
+                        className="sr-only"
+                      />
+                      <span
+                        className="w-4 h-4 rounded border border-black/20 shrink-0"
+                        style={swatchStyle(s.rgba)}
+                      />
+                      <span className="flex-1 min-w-0 truncate text-sm text-white">
+                        {spoolDisplayName(s)}
+                      </span>
+                      <span className="text-xs font-mono text-bambu-gray shrink-0">
+                        #{s.id}
+                      </span>
+                    </label>
+                  </li>
+                );
+              })}
+            </ul>
+          )}
+        </div>
+
+        {/* Templates */}
+        <div className="px-3 pb-3 pt-3 space-y-2 border-t border-bambu-dark-tertiary">
+          {TEMPLATE_OPTIONS.map((opt) => {
+            const isPending = pending === opt.value;
+            return (
+              <button
+                key={opt.value}
+                disabled={noSelection || pending !== null}
+                onClick={() => handlePick(opt.value)}
+                className="w-full text-left p-3 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-green hover:bg-bambu-green/10 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-bambu-dark-tertiary disabled:hover:bg-bambu-dark transition flex items-center gap-3"
+              >
+                <div className="flex-1 min-w-0">
+                  <div className="font-medium text-white">
+                    {t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel)}
+                  </div>
+                  <div className="text-xs text-bambu-gray mt-0.5">
+                    {t(`inventory.labels.templates.${opt.i18nKey}.hint`, opt.fallbackHint)}
+                  </div>
+                </div>
+                {isPending && <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />}
+              </button>
+            );
+          })}
+        </div>
+
+        <div className="flex justify-end gap-2 px-5 py-3 border-t border-bambu-dark-tertiary">
+          <Button variant="secondary" onClick={onClose} disabled={pending !== null}>
+            {t('common.cancel', 'Cancel')}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 36 - 0
frontend/src/i18n/locales/de.ts

@@ -3356,6 +3356,42 @@ export default {
     spoolmanMixedContentFixReverseProxy: 'Stelle Spoolman hinter denselben Reverse-Proxy wie Bambuddy (Traefik / Nginx / Caddy) mit HTTPS und aktualisiere die Spoolman-URL in den Einstellungen auf die neue HTTPS-Adresse.',
     spoolmanMixedContentFixReverseProxy: 'Stelle Spoolman hinter denselben Reverse-Proxy wie Bambuddy (Traefik / Nginx / Caddy) mit HTTPS und aktualisiere die Spoolman-URL in den Einstellungen auf die neue HTTPS-Adresse.',
     spoolmanMixedContentFixOpenNewTab: 'Als Workaround kannst du Spoolman in einem neuen Tab über HTTP öffnen — gemischte Inhalte werden nur innerhalb eingebetteter Frames blockiert, ein eigener Tab funktioniert weiterhin.',
     spoolmanMixedContentFixOpenNewTab: 'Als Workaround kannst du Spoolman in einem neuen Tab über HTTP öffnen — gemischte Inhalte werden nur innerhalb eingebetteter Frames blockiert, ein eigener Tab funktioniert weiterhin.',
     spoolmanOpenInNewTab: 'Spoolman in neuem Tab öffnen',
     spoolmanOpenInNewTab: 'Spoolman in neuem Tab öffnen',
+    labels: {
+      title: 'Print spool labels',
+      selectedCount: '{{count}} selected',
+      pickSpools: 'Pick which spools to print labels for:',
+      searchPlaceholder: 'Search name, brand, or #ID',
+      filterByMaterial: 'Material:',
+      allMaterials: 'All',
+      selectVisible: 'Select all visible ({{count}})',
+      deselectVisible: 'Deselect visible',
+      clearAll: 'Clear all',
+      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
+      noMatches: 'No spools match the current search or filter.',
+      printOne: 'Print label for this spool',
+      printLabels: 'Print labels…',
+      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
+      noSpoolsTitle: 'No spools to label',
+      error: 'Could not generate labels: {{msg}}',
+      templates: {
+        ams: {
+          label: 'AMS holder (30 × 15 mm)',
+          hint: 'Single label per page; fits the popular AMS filament label holder.',
+        },
+        box: {
+          label: 'Box label (62 × 29 mm)',
+          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+        },
+        averyL7160: {
+          label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+          hint: 'EU sheet stock; 21 labels per A4 page.',
+        },
+        avery5160: {
+          label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+          hint: 'US sheet stock; 30 labels per Letter page.',
+        },
+      },
+    },
     addSpool: 'Spule hinzufügen',
     addSpool: 'Spule hinzufügen',
     editSpool: 'Spule bearbeiten',
     editSpool: 'Spule bearbeiten',
     material: 'Material',
     material: 'Material',

+ 36 - 0
frontend/src/i18n/locales/en.ts

@@ -3359,6 +3359,42 @@ export default {
     spoolmanMixedContentFixReverseProxy: 'Put Spoolman behind the same reverse proxy as Bambuddy (Traefik / Nginx / Caddy) with HTTPS, then update the Spoolman URL in Settings to the new HTTPS address.',
     spoolmanMixedContentFixReverseProxy: 'Put Spoolman behind the same reverse proxy as Bambuddy (Traefik / Nginx / Caddy) with HTTPS, then update the Spoolman URL in Settings to the new HTTPS address.',
     spoolmanMixedContentFixOpenNewTab: 'As a workaround, open Spoolman in a new browser tab over HTTP — mixed-content rules only apply to embedded frames, so a standalone tab still works.',
     spoolmanMixedContentFixOpenNewTab: 'As a workaround, open Spoolman in a new browser tab over HTTP — mixed-content rules only apply to embedded frames, so a standalone tab still works.',
     spoolmanOpenInNewTab: 'Open Spoolman in a new tab',
     spoolmanOpenInNewTab: 'Open Spoolman in a new tab',
+    labels: {
+      title: 'Print spool labels',
+      selectedCount: '{{count}} selected',
+      pickSpools: 'Pick which spools to print labels for:',
+      searchPlaceholder: 'Search name, brand, or #ID',
+      filterByMaterial: 'Material:',
+      allMaterials: 'All',
+      selectVisible: 'Select all visible ({{count}})',
+      deselectVisible: 'Deselect visible',
+      clearAll: 'Clear all',
+      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
+      noMatches: 'No spools match the current search or filter.',
+      printOne: 'Print label for this spool',
+      printLabels: 'Print labels…',
+      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
+      noSpoolsTitle: 'No spools to label',
+      error: 'Could not generate labels: {{msg}}',
+      templates: {
+        ams: {
+          label: 'AMS holder (30 × 15 mm)',
+          hint: 'Single label per page; fits the popular AMS filament label holder.',
+        },
+        box: {
+          label: 'Box label (62 × 29 mm)',
+          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+        },
+        averyL7160: {
+          label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+          hint: 'EU sheet stock; 21 labels per A4 page.',
+        },
+        avery5160: {
+          label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+          hint: 'US sheet stock; 30 labels per Letter page.',
+        },
+      },
+    },
     addSpool: 'Add Spool',
     addSpool: 'Add Spool',
     editSpool: 'Edit Spool',
     editSpool: 'Edit Spool',
     material: 'Material',
     material: 'Material',

+ 36 - 0
frontend/src/i18n/locales/fr.ts

@@ -3343,6 +3343,42 @@ export default {
     spoolmanMixedContentFixReverseProxy: 'Placez Spoolman derrière le même reverse proxy que Bambuddy (Traefik / Nginx / Caddy) en HTTPS, puis mettez à jour l\'URL Spoolman dans les Paramètres avec la nouvelle adresse HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Placez Spoolman derrière le même reverse proxy que Bambuddy (Traefik / Nginx / Caddy) en HTTPS, puis mettez à jour l\'URL Spoolman dans les Paramètres avec la nouvelle adresse HTTPS.',
     spoolmanMixedContentFixOpenNewTab: 'Alternative : ouvrez Spoolman dans un nouvel onglet en HTTP — les règles de contenu mixte ne s\'appliquent qu\'aux cadres intégrés, un onglet autonome fonctionne.',
     spoolmanMixedContentFixOpenNewTab: 'Alternative : ouvrez Spoolman dans un nouvel onglet en HTTP — les règles de contenu mixte ne s\'appliquent qu\'aux cadres intégrés, un onglet autonome fonctionne.',
     spoolmanOpenInNewTab: 'Ouvrir Spoolman dans un nouvel onglet',
     spoolmanOpenInNewTab: 'Ouvrir Spoolman dans un nouvel onglet',
+    labels: {
+      title: 'Print spool labels',
+      selectedCount: '{{count}} selected',
+      pickSpools: 'Pick which spools to print labels for:',
+      searchPlaceholder: 'Search name, brand, or #ID',
+      filterByMaterial: 'Material:',
+      allMaterials: 'All',
+      selectVisible: 'Select all visible ({{count}})',
+      deselectVisible: 'Deselect visible',
+      clearAll: 'Clear all',
+      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
+      noMatches: 'No spools match the current search or filter.',
+      printOne: 'Print label for this spool',
+      printLabels: 'Print labels…',
+      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
+      noSpoolsTitle: 'No spools to label',
+      error: 'Could not generate labels: {{msg}}',
+      templates: {
+        ams: {
+          label: 'AMS holder (30 × 15 mm)',
+          hint: 'Single label per page; fits the popular AMS filament label holder.',
+        },
+        box: {
+          label: 'Box label (62 × 29 mm)',
+          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+        },
+        averyL7160: {
+          label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+          hint: 'EU sheet stock; 21 labels per A4 page.',
+        },
+        avery5160: {
+          label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+          hint: 'US sheet stock; 30 labels per Letter page.',
+        },
+      },
+    },
     addSpool: 'Ajouter Bobine',
     addSpool: 'Ajouter Bobine',
     editSpool: 'Modifier Bobine',
     editSpool: 'Modifier Bobine',
     material: 'Matériau',
     material: 'Matériau',

+ 36 - 0
frontend/src/i18n/locales/it.ts

@@ -3342,6 +3342,42 @@ export default {
     spoolmanMixedContentFixReverseProxy: 'Metti Spoolman dietro lo stesso reverse proxy di Bambuddy (Traefik / Nginx / Caddy) in HTTPS, poi aggiorna l\'URL di Spoolman nelle Impostazioni con il nuovo indirizzo HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Metti Spoolman dietro lo stesso reverse proxy di Bambuddy (Traefik / Nginx / Caddy) in HTTPS, poi aggiorna l\'URL di Spoolman nelle Impostazioni con il nuovo indirizzo HTTPS.',
     spoolmanMixedContentFixOpenNewTab: 'Come alternativa, apri Spoolman in una nuova scheda via HTTP — le regole sul contenuto misto si applicano solo ai frame incorporati, una scheda autonoma funziona.',
     spoolmanMixedContentFixOpenNewTab: 'Come alternativa, apri Spoolman in una nuova scheda via HTTP — le regole sul contenuto misto si applicano solo ai frame incorporati, una scheda autonoma funziona.',
     spoolmanOpenInNewTab: 'Apri Spoolman in una nuova scheda',
     spoolmanOpenInNewTab: 'Apri Spoolman in una nuova scheda',
+    labels: {
+      title: 'Print spool labels',
+      selectedCount: '{{count}} selected',
+      pickSpools: 'Pick which spools to print labels for:',
+      searchPlaceholder: 'Search name, brand, or #ID',
+      filterByMaterial: 'Material:',
+      allMaterials: 'All',
+      selectVisible: 'Select all visible ({{count}})',
+      deselectVisible: 'Deselect visible',
+      clearAll: 'Clear all',
+      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
+      noMatches: 'No spools match the current search or filter.',
+      printOne: 'Print label for this spool',
+      printLabels: 'Print labels…',
+      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
+      noSpoolsTitle: 'No spools to label',
+      error: 'Could not generate labels: {{msg}}',
+      templates: {
+        ams: {
+          label: 'AMS holder (30 × 15 mm)',
+          hint: 'Single label per page; fits the popular AMS filament label holder.',
+        },
+        box: {
+          label: 'Box label (62 × 29 mm)',
+          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+        },
+        averyL7160: {
+          label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+          hint: 'EU sheet stock; 21 labels per A4 page.',
+        },
+        avery5160: {
+          label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+          hint: 'US sheet stock; 30 labels per Letter page.',
+        },
+      },
+    },
     addSpool: 'Aggiungi Bobina',
     addSpool: 'Aggiungi Bobina',
     editSpool: 'Modifica Bobina',
     editSpool: 'Modifica Bobina',
     material: 'Materiale',
     material: 'Materiale',

+ 36 - 0
frontend/src/i18n/locales/ja.ts

@@ -3355,6 +3355,42 @@ export default {
     spoolmanMixedContentFixReverseProxy: 'Spoolman を Bambuddy と同じリバースプロキシ(Traefik / Nginx / Caddy)の後ろに HTTPS で配置し、設定で Spoolman URL を新しい HTTPS アドレスに更新してください。',
     spoolmanMixedContentFixReverseProxy: 'Spoolman を Bambuddy と同じリバースプロキシ(Traefik / Nginx / Caddy)の後ろに HTTPS で配置し、設定で Spoolman URL を新しい HTTPS アドレスに更新してください。',
     spoolmanMixedContentFixOpenNewTab: '回避策として Spoolman を新しいタブで HTTP として開くことができます — 混在コンテンツのルールは埋め込みフレームのみに適用され、独立したタブは問題なく動作します。',
     spoolmanMixedContentFixOpenNewTab: '回避策として Spoolman を新しいタブで HTTP として開くことができます — 混在コンテンツのルールは埋め込みフレームのみに適用され、独立したタブは問題なく動作します。',
     spoolmanOpenInNewTab: 'Spoolman を新しいタブで開く',
     spoolmanOpenInNewTab: 'Spoolman を新しいタブで開く',
+    labels: {
+      title: 'Print spool labels',
+      selectedCount: '{{count}} selected',
+      pickSpools: 'Pick which spools to print labels for:',
+      searchPlaceholder: 'Search name, brand, or #ID',
+      filterByMaterial: 'Material:',
+      allMaterials: 'All',
+      selectVisible: 'Select all visible ({{count}})',
+      deselectVisible: 'Deselect visible',
+      clearAll: 'Clear all',
+      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
+      noMatches: 'No spools match the current search or filter.',
+      printOne: 'Print label for this spool',
+      printLabels: 'Print labels…',
+      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
+      noSpoolsTitle: 'No spools to label',
+      error: 'Could not generate labels: {{msg}}',
+      templates: {
+        ams: {
+          label: 'AMS holder (30 × 15 mm)',
+          hint: 'Single label per page; fits the popular AMS filament label holder.',
+        },
+        box: {
+          label: 'Box label (62 × 29 mm)',
+          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+        },
+        averyL7160: {
+          label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+          hint: 'EU sheet stock; 21 labels per A4 page.',
+        },
+        avery5160: {
+          label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+          hint: 'US sheet stock; 30 labels per Letter page.',
+        },
+      },
+    },
     addSpool: 'スプールを追加',
     addSpool: 'スプールを追加',
     editSpool: 'スプールを編集',
     editSpool: 'スプールを編集',
     material: '素材',
     material: '素材',

+ 36 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3342,6 +3342,42 @@ export default {
     spoolmanMixedContentFixReverseProxy: 'Coloque o Spoolman atrás do mesmo reverse proxy do Bambuddy (Traefik / Nginx / Caddy) com HTTPS e atualize a URL do Spoolman em Configurações com o novo endereço HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Coloque o Spoolman atrás do mesmo reverse proxy do Bambuddy (Traefik / Nginx / Caddy) com HTTPS e atualize a URL do Spoolman em Configurações com o novo endereço HTTPS.',
     spoolmanMixedContentFixOpenNewTab: 'Como alternativa, abra o Spoolman em uma nova aba via HTTP — as regras de conteúdo misto só se aplicam a frames embutidos, uma aba independente funciona normalmente.',
     spoolmanMixedContentFixOpenNewTab: 'Como alternativa, abra o Spoolman em uma nova aba via HTTP — as regras de conteúdo misto só se aplicam a frames embutidos, uma aba independente funciona normalmente.',
     spoolmanOpenInNewTab: 'Abrir Spoolman em nova aba',
     spoolmanOpenInNewTab: 'Abrir Spoolman em nova aba',
+    labels: {
+      title: 'Print spool labels',
+      selectedCount: '{{count}} selected',
+      pickSpools: 'Pick which spools to print labels for:',
+      searchPlaceholder: 'Search name, brand, or #ID',
+      filterByMaterial: 'Material:',
+      allMaterials: 'All',
+      selectVisible: 'Select all visible ({{count}})',
+      deselectVisible: 'Deselect visible',
+      clearAll: 'Clear all',
+      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
+      noMatches: 'No spools match the current search or filter.',
+      printOne: 'Print label for this spool',
+      printLabels: 'Print labels…',
+      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
+      noSpoolsTitle: 'No spools to label',
+      error: 'Could not generate labels: {{msg}}',
+      templates: {
+        ams: {
+          label: 'AMS holder (30 × 15 mm)',
+          hint: 'Single label per page; fits the popular AMS filament label holder.',
+        },
+        box: {
+          label: 'Box label (62 × 29 mm)',
+          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+        },
+        averyL7160: {
+          label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+          hint: 'EU sheet stock; 21 labels per A4 page.',
+        },
+        avery5160: {
+          label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+          hint: 'US sheet stock; 30 labels per Letter page.',
+        },
+      },
+    },
     addSpool: 'Adicionar Carretel',
     addSpool: 'Adicionar Carretel',
     editSpool: 'Editar Carretel',
     editSpool: 'Editar Carretel',
     material: 'Material',
     material: 'Material',

+ 36 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3343,6 +3343,42 @@ export default {
     spoolmanMixedContentFixReverseProxy: '请将 Spoolman 置于与 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之后并启用 HTTPS,然后在设置中将 Spoolman URL 更新为新的 HTTPS 地址。',
     spoolmanMixedContentFixReverseProxy: '请将 Spoolman 置于与 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之后并启用 HTTPS,然后在设置中将 Spoolman URL 更新为新的 HTTPS 地址。',
     spoolmanMixedContentFixOpenNewTab: '作为变通方案,可在新标签页中通过 HTTP 打开 Spoolman — 混合内容规则仅适用于嵌入式框架,独立标签页仍可正常使用。',
     spoolmanMixedContentFixOpenNewTab: '作为变通方案,可在新标签页中通过 HTTP 打开 Spoolman — 混合内容规则仅适用于嵌入式框架,独立标签页仍可正常使用。',
     spoolmanOpenInNewTab: '在新标签页中打开 Spoolman',
     spoolmanOpenInNewTab: '在新标签页中打开 Spoolman',
+    labels: {
+      title: 'Print spool labels',
+      selectedCount: '{{count}} selected',
+      pickSpools: 'Pick which spools to print labels for:',
+      searchPlaceholder: 'Search name, brand, or #ID',
+      filterByMaterial: 'Material:',
+      allMaterials: 'All',
+      selectVisible: 'Select all visible ({{count}})',
+      deselectVisible: 'Deselect visible',
+      clearAll: 'Clear all',
+      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
+      noMatches: 'No spools match the current search or filter.',
+      printOne: 'Print label for this spool',
+      printLabels: 'Print labels…',
+      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
+      noSpoolsTitle: 'No spools to label',
+      error: 'Could not generate labels: {{msg}}',
+      templates: {
+        ams: {
+          label: 'AMS holder (30 × 15 mm)',
+          hint: 'Single label per page; fits the popular AMS filament label holder.',
+        },
+        box: {
+          label: 'Box label (62 × 29 mm)',
+          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+        },
+        averyL7160: {
+          label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+          hint: 'EU sheet stock; 21 labels per A4 page.',
+        },
+        avery5160: {
+          label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+          hint: 'US sheet stock; 30 labels per Letter page.',
+        },
+      },
+    },
     addSpool: '添加耗材',
     addSpool: '添加耗材',
     editSpool: '编辑耗材',
     editSpool: '编辑耗材',
     material: '材料',
     material: '材料',

+ 36 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3343,6 +3343,42 @@ export default {
     spoolmanMixedContentFixReverseProxy: '請將 Spoolman 置於與 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之後並啟用 HTTPS,然後在設定中將 Spoolman URL 更新為新的 HTTPS 位址。',
     spoolmanMixedContentFixReverseProxy: '請將 Spoolman 置於與 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之後並啟用 HTTPS,然後在設定中將 Spoolman URL 更新為新的 HTTPS 位址。',
     spoolmanMixedContentFixOpenNewTab: '作為替代方案,可在新分頁以 HTTP 開啟 Spoolman — 混合內容規則僅適用於內嵌框架,獨立分頁仍可正常運作。',
     spoolmanMixedContentFixOpenNewTab: '作為替代方案,可在新分頁以 HTTP 開啟 Spoolman — 混合內容規則僅適用於內嵌框架,獨立分頁仍可正常運作。',
     spoolmanOpenInNewTab: '在新分頁開啟 Spoolman',
     spoolmanOpenInNewTab: '在新分頁開啟 Spoolman',
+    labels: {
+      title: 'Print spool labels',
+      selectedCount: '{{count}} selected',
+      pickSpools: 'Pick which spools to print labels for:',
+      searchPlaceholder: 'Search name, brand, or #ID',
+      filterByMaterial: 'Material:',
+      allMaterials: 'All',
+      selectVisible: 'Select all visible ({{count}})',
+      deselectVisible: 'Deselect visible',
+      clearAll: 'Clear all',
+      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
+      noMatches: 'No spools match the current search or filter.',
+      printOne: 'Print label for this spool',
+      printLabels: 'Print labels…',
+      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
+      noSpoolsTitle: 'No spools to label',
+      error: 'Could not generate labels: {{msg}}',
+      templates: {
+        ams: {
+          label: 'AMS holder (30 × 15 mm)',
+          hint: 'Single label per page; fits the popular AMS filament label holder.',
+        },
+        box: {
+          label: 'Box label (62 × 29 mm)',
+          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+        },
+        averyL7160: {
+          label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+          hint: 'EU sheet stock; 21 labels per A4 page.',
+        },
+        avery5160: {
+          label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+          hint: 'US sheet stock; 30 labels per Letter page.',
+        },
+      },
+    },
     addSpool: '新增耗材',
     addSpool: '新增耗材',
     editSpool: '編輯耗材',
     editSpool: '編輯耗材',
     material: '材料',
     material: '材料',

+ 68 - 10
frontend/src/pages/InventoryPage.tsx

@@ -15,6 +15,7 @@ import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
 import { SpoolFormModal } from '../components/SpoolFormModal';
 import { SpoolFormModal } from '../components/SpoolFormModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
+import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { resolveSpoolColorName } from '../utils/colors';
 import { resolveSpoolColorName } from '../utils/colors';
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
@@ -483,6 +484,8 @@ function InventoryPage() {
   const { showToast } = useToast();
   const { showToast } = useToast();
   const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
   const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
   const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
   const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
+  // Label printing (#809). null = closed; otherwise the IDs to print labels for.
+  const [labelPickerSpoolIds, setLabelPickerSpoolIds] = useState<number[] | null>(null);
 
 
   // Filter state
   // Filter state
   const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
   const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
@@ -883,10 +886,28 @@ function InventoryPage() {
           </div>
           </div>
           <p className="text-sm text-bambu-gray mt-1 ml-9">{t('inventory.noSpools').split('.')[0] ? '' : ''}</p>
           <p className="text-sm text-bambu-gray mt-1 ml-9">{t('inventory.noSpools').split('.')[0] ? '' : ''}</p>
         </div>
         </div>
-        <Button onClick={() => setFormModal({ spool: null })}>
-          <Plus className="w-4 h-4" />
-          {t('inventory.addSpool')}
-        </Button>
+        <div className="flex items-center gap-2">
+          <Button
+            variant="secondary"
+            disabled={filteredSpools.length === 0}
+            // Pre-select every visible spool so the user lands in "all
+            // checked", then refines downward in the modal. Per-card icon
+            // pre-selects only that spool — both flows share the same picker.
+            onClick={() => setLabelPickerSpoolIds(filteredSpools.map((s) => s.id))}
+            title={
+              filteredSpools.length === 0
+                ? t('inventory.labels.noSpoolsTitle', 'No spools to label')
+                : t('inventory.labels.bulkTitle', 'Pick spools to print labels for from the {{count}} currently shown', { count: filteredSpools.length })
+            }
+          >
+            <Printer className="w-4 h-4" />
+            {t('inventory.labels.printLabels', 'Print labels…')}
+          </Button>
+          <Button onClick={() => setFormModal({ spool: null })}>
+            <Plus className="w-4 h-4" />
+            {t('inventory.addSpool')}
+          </Button>
+        </div>
       </div>
       </div>
 
 
       {/* Stats Bar */}
       {/* Stats Bar */}
@@ -1345,6 +1366,7 @@ function InventoryPage() {
                                 remaining={remaining}
                                 remaining={remaining}
                                 pct={pct}
                                 pct={pct}
                                 onClick={() => setFormModal({ spool })}
                                 onClick={() => setFormModal({ spool })}
+                                onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
                                 t={t}
                                 t={t}
                               />
                               />
                             );
                             );
@@ -1364,6 +1386,7 @@ function InventoryPage() {
                     remaining={remaining}
                     remaining={remaining}
                     pct={pct}
                     pct={pct}
                     onClick={() => setFormModal({ spool })}
                     onClick={() => setFormModal({ spool })}
+                    onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
                     t={t}
                     t={t}
                   />
                   />
                 );
                 );
@@ -1441,6 +1464,7 @@ function InventoryPage() {
                           onEdit={(s) => setFormModal({ spool: s })}
                           onEdit={(s) => setFormModal({ spool: s })}
                           onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
                           onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
                           onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
                           onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
+                          onPrintLabel={(id) => setLabelPickerSpoolIds([id])}
                           visibleColumns={visibleColumns}
                           visibleColumns={visibleColumns}
                           assignmentMap={assignmentMap}
                           assignmentMap={assignmentMap}
                           catalogMap={catalogMap}
                           catalogMap={catalogMap}
@@ -1464,6 +1488,7 @@ function InventoryPage() {
                         onRestore={() => restoreMutation.mutate(spool.id)}
                         onRestore={() => restoreMutation.mutate(spool.id)}
                         onArchive={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
                         onArchive={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
                         onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
                         onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
+                        onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
                         visibleColumns={visibleColumns}
                         visibleColumns={visibleColumns}
                         assignmentMap={assignmentMap}
                         assignmentMap={assignmentMap}
                         catalogMap={catalogMap}
                         catalogMap={catalogMap}
@@ -1588,6 +1613,18 @@ function InventoryPage() {
         defaultColumns={DEFAULT_COLUMNS}
         defaultColumns={DEFAULT_COLUMNS}
         onSave={handleColumnConfigSave}
         onSave={handleColumnConfigSave}
       />
       />
+
+      {/* Label printing (#809) — local-mode only on dev. The Spoolman path
+          on this branch hands users an iframe straight to Spoolman, so the
+          per-spool button never shows in that context. The Spoolman label
+          endpoint is wired and tested for when the inventory UI lands. */}
+      <LabelTemplatePickerModal
+        isOpen={labelPickerSpoolIds !== null}
+        onClose={() => setLabelPickerSpoolIds(null)}
+        availableSpools={filteredSpools}
+        initialSelectedIds={labelPickerSpoolIds ?? []}
+        spoolmanMode={false}
+      />
     </div>
     </div>
   );
   );
 }
 }
@@ -1671,12 +1708,13 @@ function PaginationBar({
 
 
 /* Spool card for cards view */
 /* Spool card for cards view */
 function SpoolCard({
 function SpoolCard({
-  spool, remaining, pct, onClick, t,
+  spool, remaining, pct, onClick, onPrintLabel, t,
 }: {
 }: {
   spool: InventorySpool;
   spool: InventorySpool;
   remaining: number;
   remaining: number;
   pct: number;
   pct: number;
   onClick: () => void;
   onClick: () => void;
+  onPrintLabel?: () => void;
   t: (key: string, opts?: Record<string, unknown>) => string;
   t: (key: string, opts?: Record<string, unknown>) => string;
 }) {
 }) {
   const bannerStyle = buildFilamentBackground({
   const bannerStyle = buildFilamentBackground({
@@ -1704,9 +1742,21 @@ function SpoolCard({
             </h3>
             </h3>
             <p className="text-sm text-bambu-gray">{spool.brand || '-'}</p>
             <p className="text-sm text-bambu-gray">{spool.brand || '-'}</p>
           </div>
           </div>
-          <span className="text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded">
-            #{spool.id}
-          </span>
+          <div className="flex items-center gap-1">
+            {onPrintLabel && (
+              <button
+                onClick={(e) => { e.stopPropagation(); onPrintLabel(); }}
+                className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
+                title={t('inventory.labels.printOne')}
+                aria-label={t('inventory.labels.printOne')}
+              >
+                <Printer className="w-4 h-4" />
+              </button>
+            )}
+            <span className="text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded">
+              #{spool.id}
+            </span>
+          </div>
         </div>
         </div>
         <div>
         <div>
           <div className="flex justify-between text-xs text-bambu-gray mb-1">
           <div className="flex justify-between text-xs text-bambu-gray mb-1">
@@ -1752,7 +1802,7 @@ function SpoolCard({
 
 
 /* Single spool row for table view */
 /* Single spool row for table view */
 function SpoolTableRow({
 function SpoolTableRow({
-  spool, remaining, pct, onEdit, onRestore, onArchive, onDelete,
+  spool, remaining, pct, onEdit, onRestore, onArchive, onDelete, onPrintLabel,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
 }: {
   spool: InventorySpool;
   spool: InventorySpool;
@@ -1762,6 +1812,7 @@ function SpoolTableRow({
   onRestore: () => void;
   onRestore: () => void;
   onArchive: () => void;
   onArchive: () => void;
   onDelete: () => void;
   onDelete: () => void;
+  onPrintLabel?: () => void;
   visibleColumns: string[];
   visibleColumns: string[];
   assignmentMap: Record<number, SpoolAssignment>;
   assignmentMap: Record<number, SpoolAssignment>;
   catalogMap: Record<number, SpoolCatalogEntry>;
   catalogMap: Record<number, SpoolCatalogEntry>;
@@ -1787,6 +1838,11 @@ function SpoolTableRow({
           <button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('common.edit')}>
           <button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('common.edit')}>
             <Edit2 className="w-4 h-4" />
             <Edit2 className="w-4 h-4" />
           </button>
           </button>
+          {onPrintLabel && (
+            <button onClick={onPrintLabel} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('inventory.labels.printOne')}>
+              <Printer className="w-4 h-4" />
+            </button>
+          )}
           {spool.archived_at ? (
           {spool.archived_at ? (
             <button onClick={onRestore} className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors" title={t('inventory.restore')}>
             <button onClick={onRestore} className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors" title={t('inventory.restore')}>
               <RotateCcw className="w-4 h-4" />
               <RotateCcw className="w-4 h-4" />
@@ -1808,7 +1864,7 @@ function SpoolTableRow({
 /* Grouped spool rows for table view */
 /* Grouped spool rows for table view */
 function SpoolTableGroup({
 function SpoolTableGroup({
   spools, representative, remaining, pct, isExpanded, onToggle,
   spools, representative, remaining, pct, isExpanded, onToggle,
-  onEdit, onArchive, onDelete,
+  onEdit, onArchive, onDelete, onPrintLabel,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
 }: {
   spools: InventorySpool[];
   spools: InventorySpool[];
@@ -1820,6 +1876,7 @@ function SpoolTableGroup({
   onEdit: (spool: InventorySpool) => void;
   onEdit: (spool: InventorySpool) => void;
   onArchive: (id: number) => void;
   onArchive: (id: number) => void;
   onDelete: (id: number) => void;
   onDelete: (id: number) => void;
+  onPrintLabel?: (spoolId: number) => void;
   visibleColumns: string[];
   visibleColumns: string[];
   assignmentMap: Record<number, SpoolAssignment>;
   assignmentMap: Record<number, SpoolAssignment>;
   catalogMap: Record<number, SpoolCatalogEntry>;
   catalogMap: Record<number, SpoolCatalogEntry>;
@@ -1871,6 +1928,7 @@ function SpoolTableGroup({
             onRestore={() => {}}
             onRestore={() => {}}
             onArchive={() => onArchive(spool.id)}
             onArchive={() => onArchive(spool.id)}
             onDelete={() => onDelete(spool.id)}
             onDelete={() => onDelete(spool.id)}
+            onPrintLabel={onPrintLabel ? () => onPrintLabel(spool.id) : undefined}
             visibleColumns={visibleColumns}
             visibleColumns={visibleColumns}
             assignmentMap={assignmentMap}
             assignmentMap={assignmentMap}
             catalogMap={catalogMap}
             catalogMap={catalogMap}

+ 3 - 0
requirements.txt

@@ -41,6 +41,9 @@ aiofiles>=23.0.0
 # QR Code generation
 # QR Code generation
 qrcode[pil]>=7.4.0
 qrcode[pil]>=7.4.0
 
 
+# PDF generation (spool label printing — #809)
+reportlab>=4.0.0
+
 # STL Thumbnail Generation
 # STL Thumbnail Generation
 trimesh>=4.0.0
 trimesh>=4.0.0
 matplotlib>=3.8.0
 matplotlib>=3.8.0

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-Cw7zekS6.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-DA6QDhrM.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-N3WJn-iA.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="./assets/index-wu_Yq5x1.js"></script>
-    <link rel="stylesheet" crossorigin href="./assets/index-Cw7zekS6.css">
+    <script type="module" crossorigin src="./assets/index-DA6QDhrM.js"></script>
+    <link rel="stylesheet" crossorigin href="./assets/index-N3WJn-iA.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio