Jelajahi Sumber

Choose what a spool label shows, preview it, and save it as PNG (#2981, #2980)

maziggy 1 hari lalu
induk
melakukan
bf7fc67dc0
33 mengubah file dengan 2488 tambahan dan 587 penghapusan
  1. 2 0
      CHANGELOG.md
  2. 194 93
      backend/app/api/routes/labels.py
  3. 228 69
      backend/app/services/label_renderer.py
  4. 253 0
      backend/tests/integration/test_labels.py
  5. 197 0
      backend/tests/unit/services/test_label_renderer_fields.py
  6. 343 88
      frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx
  7. 182 0
      frontend/src/__tests__/pages/InventoryPagePrintLabels.test.tsx
  8. 64 30
      frontend/src/api/client.ts
  9. 577 267
      frontend/src/components/LabelTemplatePickerModal.tsx
  10. 29 2
      frontend/src/i18n/locales/de.ts
  11. 29 2
      frontend/src/i18n/locales/en.ts
  12. 29 2
      frontend/src/i18n/locales/es.ts
  13. 29 2
      frontend/src/i18n/locales/fr.ts
  14. 29 2
      frontend/src/i18n/locales/it.ts
  15. 29 2
      frontend/src/i18n/locales/ja.ts
  16. 29 2
      frontend/src/i18n/locales/ko.ts
  17. 29 2
      frontend/src/i18n/locales/nl.ts
  18. 29 2
      frontend/src/i18n/locales/pt-BR.ts
  19. 29 2
      frontend/src/i18n/locales/ru.ts
  20. 29 2
      frontend/src/i18n/locales/sv.ts
  21. 29 2
      frontend/src/i18n/locales/tr.ts
  22. 29 2
      frontend/src/i18n/locales/uk.ts
  23. 29 2
      frontend/src/i18n/locales/zh-CN.ts
  24. 29 2
      frontend/src/i18n/locales/zh-TW.ts
  25. 10 5
      frontend/src/pages/InventoryPage.tsx
  26. 0 0
      static/assets/ImagePreviewModal-CHxXVMhZ.js
  27. 0 1
      static/assets/PdfPreviewModal-BE-QK9vM.js
  28. 0 0
      static/assets/SpreadsheetPreviewModal-EdHQ73sd.js
  29. 0 1
      static/assets/index-2v_Z1nnP.css
  30. 1 0
      static/assets/index-BdFVHW40.css
  31. 0 1
      static/assets/index-CcfSTIKL.js
  32. 0 0
      static/assets/pdf-BVNSFz5v.js
  33. 2 2
      static/index.html

+ 2 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Choose what goes on a spool label, see it before printing, and save labels as PNG (#2981, requested by @apizz)** — Spool labels always carried the same lines, and you only saw the result once the PDF opened. The label picker now has a checkbox for each line: brand, material and subtype, colour code, name, storage location, material number, nozzle temperature, net weight, note, date added, QR code and spool ID. Material number, temperature, weight, note and date were never printed before, and leaving out the QR code gives the text its space. A preview beside the options shows the first selected spool's label as it will print, and updates as you change them. Lines that don't fit on the chosen size are left out rather than printed over the spool ID. Labels can now also be saved as PNG at 203, 300 or 600 dpi for label printer software that takes images, such as Brother P-touch Editor: one label comes as a PNG, several as a ZIP. The picker remembers the size, the lines chosen for each size, monochrome and the output settings in your browser. Spoolman labels now carry the same data as built-in ones, including the subtype and the colour name you set, where before they showed neither.
 - **The outcome prompt can be answered by reacting to the Telegram message (#3046, requested and contributed by @Thomansky in #3129)** — The Good/Reject buttons in a Telegram outcome prompt open a Bambuddy link, which only works when the phone can reach Bambuddy. A thumbs-up or thumbs-down on the message now records the verdict instead: the phone only talks to Telegram, so it works away from home, behind NAT or with the VPN down, and needs no external URL. A Telegram provider gains **Outcome verdict via**: **Inline buttons (link)** (the default, unchanged), **Reaction (👍 / 👎)**, or **Buttons and reaction**. Bambuddy then polls the bot for reactions (one poll per bot, outbound only) and edits the message to say "marked as good" or "marked as reject". The first verdict wins, whichever way it arrives, and a reaction stops counting exactly when the prompt's links would, so a message from an earlier run can't answer a reprint. Use a bot only Bambuddy uses for reactions: another app polling the same bot, such as Home Assistant's Telegram integration, stops receiving its messages. In a group the bot must be an admin to see reactions. The Archives page's **Unconfirmed** filter now lists completed prints only, like the **outcome?** badge.
 - **SSO logins can keep a user's Bambuddy groups in step with their groups at the identity provider (#3107, requested and contributed by @willuhmjs in #3122)** — Each OIDC provider in Settings → Authentication → SSO/OIDC gains a **Group Claim** (default `groups`) and a **Group Mapping**, a list of rows pairing an identity provider group with a Bambuddy group. On every SSO login, existing accounts included, the mapped Bambuddy groups follow the provider: joining a provider group grants its Bambuddy group, leaving it removes that group at the next login. Groups the mapping doesn't name are left alone, so a manual promotion sticks. This is the same rule the LDAP group mapping uses. Groups are read from the signed ID token, as a list or as a space- or comma-separated string, and matched ignoring case. Claims with a namespace such as `app/roles` work, for Auth0. A missing claim, or a sync that fails, never blocks the login; the user keeps the groups they had. The form offers only existing Bambuddy groups, flags a row pointing at a deleted group, and won't save a half-filled row or a second row for the same provider group. The provider card shows whether group sync is on. With no mapping, a provider behaves exactly as before. Docker and env-var setups use `BAMBUDDY_OIDC_GROUP_CLAIM` and `BAMBUDDY_OIDC_GROUP_MAPPING`, a JSON object whose values are Bambuddy group names. A mapping naming a group that doesn't exist, or one that isn't valid, is refused at startup with the reason in the log, and the app still starts.
 - **Spools can carry your own material number, and statistics group by it (#2870, requested and contributed by @Thomansky in #2994)** — A new **Material No.** field in the spool dialog's Additional section holds the internal number you buy and cost a product by, such as `15` for Bambu Lab PLA Basic. Several spools of the same product share it. The field suggests numbers already in use, and surrounding spaces are trimmed, so `15` and `15 ` stay one number. A new spool of a product that already has a number gets it automatically: same brand, material, subtype and colour, whether it is added by hand, in bulk, through the API or by the RFID auto-add. CSV import is the exception: the file decides. The inventory has an optional sortable **Material No.** column, which sorts `2` before `15`, and a filter for one number or for spools without one. Search finds the number, and Bulk Edit can assign it to many spools at once. The Statistics page has a **By Material Number** widget: active spools and remaining stock per number, plus consumption and cost from the usage history, archived spools included, following the dashboard's date range. The number round-trips through CSV export/import and is part of the GitHub backup. The GitHub backup now also keeps a spool's category, low-stock threshold and storage location; a restore dropped them before. In Spoolman mode the number is Spoolman's own article number, shown read-only and edited in Spoolman. Bulk Edit there no longer offers category and low-stock threshold, which Spoolman cannot store, so setting them never took effect.
@@ -52,6 +53,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **"Print labels…" starts from the spools you ticked (#2980, reported by @apizz)** — With spools ticked in the inventory list, the header's **Print labels…** button still opened the label picker with every visible spool selected, so you had to find your spools again. It now starts from the ticked spools, and from every visible spool only when none is ticked.
 - **In Spoolman mode, a new Bambu Lab roll links to a filament of its own product line (#2907, reported and contributed by @ojimpo in #2944)** — Bambuddy matched a new roll to a Spoolman filament by material and colour alone. Several product lines share a colour, so a PLA Matte Charcoal roll was linked to the PLA Basic "Black" filament, took its name and density, and showed up as Black. The same happened to other lines: PLA Tough took PLA Basic's library entry, PLA Silk White took "Aero White", and PETG HF took plain PETG's. The match now also checks the product line the AMS reports, both among your existing filaments and in the Spoolman filament library, and each roll takes its own library entry and density. New filaments are named for the line ("PLA Matte"), including ones taken from the library, and the colour name ("Charcoal") is stored on the spool. Spoolman mode now shows the same subtype and colour name as the built-in inventory. When no filament or library entry fits the line, Bambuddy creates one from what the AMS reports instead of taking the first one with the same colour. Filaments that earlier versions named after a colour ("Black") are still reused, so existing installs don't get duplicates. A filament you renamed yourself in Spoolman is no longer reused for new rolls.
 - **LDAP group mapping finds groups on lldap and OpenLDAP (#3197, reported by @TOFM)** — Every login from an lldap directory got the Default Group, even for a user who was a member of a mapped group. Bambuddy read membership from the user's `memberOf` attribute but asked the server for "all attributes". lldap, and OpenLDAP's memberof overlay, leave `memberOf` out of "all attributes" and only return it when it is asked for by name. Bambuddy now asks for it by name wherever the directory's schema has it. It also asks the groups themselves which `groupOfNames` and `groupOfUniqueNames` entries list the user, searching from the directory root, because groups usually sit beside the users (`ou=groups` next to `ou=people`) rather than under the search base. That covers OpenLDAP without the memberof overlay, where there is no `memberOf` at all, and OpenLDAP with an overlay that tracks only one group class. Active Directory keeps `memberOf` complete and is not searched the second way. Checked against lldap 0.6.3, OpenLDAP 2.6 with and without the overlay, and Samba AD. Smaller things from the same report: **StartTLS now works with Active Directory and Samba AD**, which it never did: straight after StartTLS, and before logging in, the LDAP library read the server's schema, which those directories only allow once logged in, so every StartTLS connection failed with "operationsError" (LDAPS was unaffected); **Test Connection** against a server that doesn't offer StartTLS (lldap only does LDAPS) now says so and suggests LDAPS, instead of "Unsupported extended operation" and a number; support bundles no longer mask part of a longer dotted number such as that one as an IP address, which had made the message unreadable; and a user's first LDAP login logs the "no mapped groups" warning once instead of twice.
 - **In Spoolman mode, each spool's own size is used, not its filament's (#3194, reported by @worried-networking)** — Spoolman stores how much filament a full spool holds on the spool (**Initial Weight**) and uses the filament's **Weight** only when the spool has none, so one filament can have spools of different sizes. Bambuddy read only the filament, so a 250 g spool of a 1000 g filament showed as 1000 g with the wrong fill level, **Sync Weights from AMS** and AMS-percentage usage tracking charged it four times the real grams, and the AMS hover card's Spoolman fill level was off the same way. All of these, the weigh action and the SpoolBuddy scale now use the spool's own size. Creating a spool writes its **Label Weight** to the spool's Initial Weight, so a 250 g spool of a 1000 g catalogue filament is recorded as 250 g. Editing the Label Weight changes only that spool: it used to change the filament's weight while Spoolman kept the spool's old size, so the two disagreed from then on, and on a filament shared with other spools it linked this spool to a new duplicate filament instead. The spool form's **Cost per kg** is stored as Spoolman's spool **Price**, which Spoolman treats as the price of the whole spool; it is now converted at the spool's size both ways, and print cost divides a spool's own price by the spool's size. A catalogue price on the filament is still divided by the filament's weight. For 1000 g spools nothing changes. Saving a spool without changing its size or price no longer writes either, so a 250.7 g spool stays 250.7 g. Editing a spool that has no weight in Spoolman at all, on the spool or its filament, failed with an error; it now saves, and a Label Weight saved from the spool form becomes the spool's size.

+ 194 - 93
backend/app/api/routes/labels.py

@@ -5,32 +5,49 @@ 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, starting_position: int}`` and
-return a PDF stream.
+Both accept ``{spool_ids: [int], template: str, starting_position: int}`` plus
+the lines to print (``fields``) and the output ``format``: a PDF, or PNGs for
+label-printer software that takes images — one PNG as is, several in a ZIP.
+Each has a ``/preview`` sibling that renders one label as a PNG for the
+picker (#2981).
+
 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 asyncio
 import io
 import logging
-from typing import Literal
+import zipfile
+from datetime import date, datetime
+from typing import Annotated, Literal
 
 from fastapi import APIRouter, Depends, HTTPException, Request
-from fastapi.responses import StreamingResponse
+from fastapi.responses import Response, StreamingResponse
 from pydantic import BaseModel, Field, model_validator
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.api.routes._spoolman_helpers import parse_spoolman_multi_colors
+from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 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, get_sheet_capacity, render_labels
+from backend.app.services.label_renderer import (
+    ALL_LABEL_FIELDS,
+    DEFAULT_LABEL_FIELDS,
+    LabelData,
+    LabelField,
+    TemplateName,
+    get_sheet_capacity,
+    pdf_to_pngs,
+    render_label_preview_pdf,
+    render_labels,
+)
 from backend.app.services.spoolman import get_spoolman_client
 from backend.app.utils.http import build_content_disposition
 
@@ -38,34 +55,38 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(tags=["labels"])
 
-_VALID_TEMPLATES: tuple[TemplateName, ...] = (
-    "ams_holder_74x33",
-    "ams_holder_75x55",
-    "box_40x30",
-    "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
 
+# Preview resolution. High enough that the browser's downscale stays sharp on
+# a HiDPI screen; a label is a few centimetres, so the PNG stays small.
+PREVIEW_DPI = 300
+
+
+# Spoolman's mapping rejects an id below 1, so one never reaches it.
+SpoolId = Annotated[int, Field(gt=0)]
 
-class LabelRequest(BaseModel):
-    spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
-    template: Literal[
-        "ams_holder_74x33",
-        "ams_holder_75x55",
-        "box_40x30",
-        "box_62x29",
-        "avery_5160",
-        "avery_l7160",
-    ]
+
+class LabelOptions(BaseModel):
+    template: TemplateName
     # Black-and-white thermal printers: drop the colour swatch (prints as a
     # muddy grey block) and widen the text column instead (#1870).
     monochrome: bool = False
+    # The lines to print (#2981). Omitted: what labels always carried.
+    fields: list[LabelField] | None = Field(default=None, max_length=len(ALL_LABEL_FIELDS))
+
+    def field_set(self) -> frozenset[LabelField]:
+        return DEFAULT_LABEL_FIELDS if self.fields is None else frozenset(self.fields)
+
+
+class LabelRequest(LabelOptions):
+    spool_ids: list[SpoolId] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
     starting_position: int = Field(default=1, ge=1)
+    format: Literal["pdf", "png"] = "pdf"
+    # PNG only. 203 and 300 are the common thermal-printer resolutions; an
+    # image at the printer's own resolution is printed dot for dot.
+    dpi: Literal[203, 300, 600] = 300
 
     @model_validator(mode="after")
     def validate_starting_position(self) -> LabelRequest:
@@ -79,6 +100,10 @@ class LabelRequest(BaseModel):
         return self
 
 
+class LabelPreviewRequest(LabelOptions):
+    spool_id: SpoolId
+
+
 def _split_extra_colors(raw: str | None) -> list[str] | None:
     """Parse ``Spool.extra_colors`` (comma-separated hex tokens) into a list."""
     if not raw:
@@ -111,37 +136,54 @@ def _spool_to_label_data(spool: Spool, deeplink_base: str) -> LabelData:
         extra_colors=_split_extra_colors(spool.extra_colors),
         storage_location=getattr(spool, "storage_location", None),
         deeplink_url=f"{deeplink_base}/inventory?spool={spool.id}",
+        material_number=spool.material_number,
+        nozzle_temp_min=spool.nozzle_temp_min,
+        nozzle_temp_max=spool.nozzle_temp_max,
+        label_weight=spool.label_weight,
+        note=spool.note,
+        added=spool.created_at.date() if spool.created_at else None,
     )
 
 
+def _parse_date(value: str | None) -> date | None:
+    if not value:
+        return None
+    try:
+        return datetime.fromisoformat(value).date()
+    except ValueError:
+        return None
+
+
 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.
+    Goes through ``_map_spoolman_spool``, the mapping the inventory page shows,
+    so a Spoolman label carries what a built-in-inventory label does: the same
+    subtype, colour name, material number, temperature and weight.
     """
-    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
-
-    # Shared with `_map_spoolman_spool`, so the swatch printed on a label and
-    # the swatch drawn on an AMS slot card cannot read the same field two ways.
-    extra: list[str] | None = parse_spoolman_multi_colors(filament) or None
+    m = _map_spoolman_spool(s)
+    material = m["material"] or ""
+    # Same precedence as a built-in spool: a colour name the user set, then the
+    # filament (slicer) name. A synthesised colour name is only the subtype.
+    color_name = None if m["color_name_is_synthesized"] else m["color_name"]
+    name = color_name or m["slicer_filament_name"] or f"{m['brand'] or ''} {material}".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))}",
+        spool_id=m["id"],
+        name=name or material or "Spool",
+        material=material,
+        brand=m["brand"],
+        subtype=m["subtype"],
+        rgba=m["rgba"],
+        extra_colors=_split_extra_colors(m["extra_colors"]),
+        storage_location=m["storage_location"],
+        deeplink_url=f"{deeplink_base}/inventory?spool={m['id']}",
+        material_number=m["material_number"],
+        nozzle_temp_min=m["nozzle_temp_min"],
+        nozzle_temp_max=m["nozzle_temp_max"],
+        label_weight=m["label_weight"],
+        note=m["note"],
+        added=_parse_date(m["created_at"]),
     )
 
 
@@ -159,58 +201,78 @@ def _stream_pdf(pdf: bytes, filename: str) -> StreamingResponse:
     )
 
 
-@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}")
+async def _render_response(body: LabelRequest, data_list: list[LabelData], filename_stem: str) -> Response:
+    pdf = render_labels(
+        body.template,
+        data_list,
+        monochrome=body.monochrome,
+        starting_position=body.starting_position,
+        fields=body.field_set(),
+    )
+    if body.format == "pdf":
+        return _stream_pdf(pdf, f"{filename_stem}.pdf")
+
+    # Rasterising a few hundred pages is CPU work; keep it off the event loop.
+    pngs = await asyncio.to_thread(pdf_to_pngs, pdf, body.dpi)
+    if len(pngs) == 1:
+        return Response(
+            content=pngs[0],
+            media_type="image/png",
+            headers={
+                "Content-Disposition": build_content_disposition(f"{filename_stem}.png", disposition="attachment"),
+                "Cache-Control": "no-store",
+            },
+        )
+
+    # A roll template has one page per spool, so its files are named after
+    # the spool; a sheet's pages are numbered.
+    if get_sheet_capacity(body.template) is None:
+        names = [f"label-{d.spool_id}.png" for d in data_list]
+    else:
+        names = [f"sheet-{n}.png" for n in range(1, len(pngs) + 1)]
+    buf = io.BytesIO()
+    # PNG is already compressed; deflating it again only costs time.
+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
+        for name, png in zip(names, pngs, strict=True):
+            zf.writestr(name, png)
+    return Response(
+        content=buf.getvalue(),
+        media_type="application/zip",
+        headers={
+            "Content-Disposition": build_content_disposition(f"{filename_stem}.zip", disposition="attachment"),
+            "Cache-Control": "no-store",
+        },
+    )
+
+
+async def _render_preview(body: LabelPreviewRequest, data: LabelData) -> Response:
+    pdf = render_label_preview_pdf(body.template, data, monochrome=body.monochrome, fields=body.field_set())
+    pngs = await asyncio.to_thread(pdf_to_pngs, pdf, PREVIEW_DPI)
+    return Response(content=pngs[0], media_type="image/png", headers={"Cache-Control": "no-store"})
+
 
-    result = await db.execute(select(Spool).where(Spool.id.in_(body.spool_ids)))
+async def _local_label_data(spool_ids: list[int], request: Request, db: AsyncSession) -> list[LabelData]:
+    result = await db.execute(select(Spool).where(Spool.id.in_(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]
+    missing = [sid for sid in 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))
+    ordered = sorted(spools, key=lambda s: 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,
-        monochrome=body.monochrome,
-        starting_position=body.starting_position,
-    )
-    filename = f"bambuddy-labels-{body.template}.pdf"
-    return _stream_pdf(pdf, filename)
+    return [_spool_to_label_data(s, deeplink_base) for s in ordered]
 
 
-@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
+async def _spoolman_label_data(spool_ids: list[int], request: Request, db: AsyncSession) -> list[LabelData]:
+    """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")
@@ -226,18 +288,57 @@ async def render_spoolman_labels(
         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]
+    missing = [sid for sid in 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]
+    return [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in spool_ids]
 
-    pdf = render_labels(
-        body.template,
-        data_list,
-        monochrome=body.monochrome,
-        starting_position=body.starting_position,
-    )
-    filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
-    return _stream_pdf(pdf, filename)
+
+@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),
+) -> Response:
+    """Render labels for spools in the local inventory."""
+    data_list = await _local_label_data(body.spool_ids, request, db)
+    return await _render_response(body, data_list, f"bambuddy-labels-{body.template}")
+
+
+@router.post("/inventory/labels/preview")
+async def preview_local_inventory_label(
+    body: LabelPreviewRequest,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> Response:
+    """One label for a local spool, as a PNG, for the picker's preview."""
+    data_list = await _local_label_data([body.spool_id], request, db)
+    return await _render_preview(body, data_list[0])
+
+
+@router.post("/spoolman/labels")
+async def render_spoolman_labels(
+    body: LabelRequest,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> Response:
+    """Render labels for spools tracked in Spoolman."""
+    data_list = await _spoolman_label_data(body.spool_ids, request, db)
+    return await _render_response(body, data_list, f"bambuddy-labels-spoolman-{body.template}")
+
+
+@router.post("/spoolman/labels/preview")
+async def preview_spoolman_label(
+    body: LabelPreviewRequest,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> Response:
+    """One label for a Spoolman spool, as a PNG, for the picker's preview."""
+    data_list = await _spoolman_label_data([body.spool_id], request, db)
+    return await _render_preview(body, data_list[0])

+ 228 - 69
backend/app/services/label_renderer.py

@@ -27,12 +27,21 @@ 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>``.
+
+Which of those appear is the caller's choice (#2981): ``fields`` names the
+lines to print, and ``DEFAULT_LABEL_FIELDS`` is the set every label carried
+before the choice existed. The label text is data only (numbers, units,
+dates), never words, because the PDF is not translated.
+
+``render_labels`` returns a PDF; ``pdf_to_pngs`` rasterises it page by page
+for label-printer software that takes images.
 """
 
 from __future__ import annotations
 
 import io
 from dataclasses import dataclass
+from datetime import date
 from typing import Literal
 
 import qrcode
@@ -51,6 +60,44 @@ TemplateName = Literal[
 ]
 
 
+LabelField = Literal[
+    "brand",
+    "material",
+    "hex",
+    "name",
+    "location",
+    "material_number",
+    "temps",
+    "weight",
+    "note",
+    "added",
+    "qr",
+    "spool_id",
+]
+
+# Top-to-bottom order of the text lines, and the order the picker lists them.
+ALL_LABEL_FIELDS: tuple[LabelField, ...] = (
+    "brand",
+    "material",
+    "hex",
+    "name",
+    "location",
+    "material_number",
+    "temps",
+    "weight",
+    "note",
+    "added",
+    "qr",
+    "spool_id",
+)
+
+# What a label carried before the fields were selectable, so a request that
+# names none prints exactly what it always did.
+DEFAULT_LABEL_FIELDS: frozenset[LabelField] = frozenset(
+    {"brand", "material", "hex", "name", "location", "qr", "spool_id"}
+)
+
+
 @dataclass
 class LabelData:
     """Per-spool data needed to render a label.
@@ -68,6 +115,26 @@ class LabelData:
     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
+    material_number: str | None = None
+    nozzle_temp_min: int | None = None
+    nozzle_temp_max: int | None = None
+    label_weight: int | None = None  # advertised net weight, grams
+    note: str | None = None
+    added: date | None = None
+
+
+def _temps_text(data: LabelData) -> str:
+    """``220–240 °C``, or the one bound that is set."""
+    lo, hi = data.nozzle_temp_min, data.nozzle_temp_max
+    if lo and hi and lo != hi:
+        return f"{lo}–{hi} °C"
+    if lo or hi:
+        return f"{lo or hi} °C"
+    return ""
+
+
+def _weight_text(data: LabelData) -> str:
+    return f"{data.label_weight} g" if data.label_weight else ""
 
 
 # ── Colour helpers ───────────────────────────────────────────────────────────
@@ -213,7 +280,14 @@ def _truncate_to_width(c: rl_canvas.Canvas, text: str, font: str, size: float, m
 
 
 def _draw_label(
-    c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData, monochrome: bool = False
+    c: rl_canvas.Canvas,
+    x: float,
+    y: float,
+    w: float,
+    h: float,
+    data: LabelData,
+    monochrome: bool = False,
+    fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
 ) -> None:
     """Render one label inside the box (x, y, w, h). Origin is bottom-left.
 
@@ -244,9 +318,9 @@ def _draw_label(
     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, monochrome)
+        _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome, fields)
     else:
-        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
+        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome, fields)
 
 
 def _draw_label_tight(
@@ -262,8 +336,12 @@ def _draw_label_tight(
     pad: float,
     data: LabelData,
     monochrome: bool = False,
+    fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
 ) -> None:
-    """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR."""
+    """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR.
+
+    Only those four lines fit, so they are the only ``fields`` it honours.
+    """
     # Monochrome: drop the colour swatch (see _draw_label_roomy) and give the
     # width to the text column (#1870).
     if monochrome:
@@ -283,14 +361,14 @@ def _draw_label_tight(
     # Top: brand — bumped to bold + larger per the #809 follow-up so it's the
     # easiest thing to read on a small AMS holder at arm's length.
     brand_size = 6.5
-    if data.brand:
+    if data.brand and "brand" in fields:
         c.setFont("Helvetica-Bold", brand_size)
         brand = _truncate_to_width(c, data.brand, "Helvetica-Bold", brand_size, text_w)
         c.drawString(text_x, y + h - pad - brand_size, brand)
 
     # Second line: material + subtype, small
     sub_size = 5
-    sub_line = " ".join(filter(None, [data.material, data.subtype]))
+    sub_line = " ".join(filter(None, [data.material, data.subtype])) if "material" in fields else ""
     sub_y_baseline = y + h - pad - brand_size - 0.6 - sub_size
     if sub_line:
         c.setFont("Helvetica", sub_size)
@@ -299,7 +377,7 @@ def _draw_label_tight(
 
     # Third line (when there's room): hex code, tiny — useful when the user
     # has multiple near-identical colours in the same material family.
-    hex_code = _hex_code_label(data.rgba)
+    hex_code = _hex_code_label(data.rgba) if "hex" in fields else ""
     if hex_code:
         hex_size = 4.5
         hex_y = sub_y_baseline - 0.4 - hex_size
@@ -309,6 +387,8 @@ def _draw_label_tight(
             c.drawString(text_x, hex_y, hex_code)
 
     # Bottom: BIG spool ID — the killer field at-a-glance.
+    if "spool_id" not in fields:
+        return
     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)
@@ -328,6 +408,7 @@ def _draw_label_roomy(
     pad: float,
     data: LabelData,
     monochrome: bool = False,
+    fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
 ) -> None:
     """Box-label / Avery layout. Swatch left, QR right, text middle."""
     # Swatch: full inner height, ~18% of inner width but capped so we never
@@ -342,73 +423,69 @@ def _draw_label_roomy(
         swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
         _draw_swatch(c, inner_x, inner_y, swatch_w, inner_h, data)
 
-    qr_size = _roomy_qr_size(inner_w, inner_h)
-    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)
+    # Without the QR the text column runs to the right-hand padding.
+    if "qr" in fields:
+        qr_size = _roomy_qr_size(inner_w, inner_h)
+        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_right = qr_x - 1.5 * mm
+    else:
+        text_right = inner_x + inner_w
 
     text_x = inner_x + swatch_w + 1.5 * mm
-    text_w = qr_x - text_x - 1.5 * mm
+    text_w = text_right - text_x
     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]))
+    # Spool ID — anchored at the bottom of the text column, big and bold. The
+    # lines above stop short of it rather than print over it.
+    id_size = 16
+    show_id = "spool_id" in fields
+    floor_y = inner_y + 0.5 + id_size if show_id else inner_y
+
     name = data.name or ""
-    hex_code = _hex_code_label(data.rgba)
+    # The name line is dropped when it only repeats the brand, or the subtype
+    # already printed beside the material (a Spoolman filament named after
+    # its colour yields both).
+    repeats = {data.brand or ""}
+    if "material" in fields and data.subtype:
+        repeats.add(data.subtype)
+    # (field, text, font, size, gap below). Sizes and gaps of the first five
+    # are what the label has always used; the rest share the location's.
+    rows: list[tuple[LabelField, str, str, float, float]] = [
+        # Brand — bumped to bold + larger per the #809 follow-up.
+        ("brand", data.brand or "", "Helvetica-Bold", 8, 1.2),
+        ("material", " · ".join(filter(None, [data.material, data.subtype])), "Helvetica", 7, 1.5),
+        # Hex colour code — useful for telling near-identical material+colour
+        # spools apart when the swatch is small or the user is colour-blind.
+        ("hex", _hex_code_label(data.rgba), "Helvetica", 6.5, 1.2),
+        ("name", name if name not in repeats else "", "Helvetica-Bold", 9, 1.2),
+        ("location", data.storage_location or "", "Helvetica-Oblique", 6.5, 1.2),
+        ("material_number", data.material_number or "", "Helvetica", 6.5, 1.2),
+        ("temps", _temps_text(data), "Helvetica", 6.5, 1.2),
+        ("weight", _weight_text(data), "Helvetica", 6.5, 1.2),
+        ("note", data.note or "", "Helvetica-Oblique", 6.5, 1.2),
+        ("added", data.added.isoformat() if data.added else "", "Helvetica", 6.5, 1.2),
+    ]
 
-    # Layout from the top of the text column.
     cursor_y = y + h - pad
-
-    # Brand — bumped to bold + larger per the #809 follow-up.
-    if line1:
-        size = 8
-        c.setFont("Helvetica-Bold", size)
-        text = _truncate_to_width(c, line1, "Helvetica-Bold", 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
-
-    # Hex colour code — useful for telling near-identical material+colour
-    # spools apart when the swatch is small or the user is colour-blind.
-    if hex_code:
-        size = 6.5
-        c.setFont("Helvetica", size)
-        cursor_y -= size
-        c.drawString(text_x, cursor_y, hex_code)
-        cursor_y -= 1.2
-
-    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)
+    for field, text, font, size, gap in rows:
+        if field not in fields or not text:
+            continue
+        if cursor_y - size < floor_y:
+            break
+        c.setFont(font, size)
         cursor_y -= size
-        c.drawString(text_x, cursor_y, text)
+        c.drawString(text_x, cursor_y, _truncate_to_width(c, text, font, size, text_w))
+        cursor_y -= gap
 
-    # 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)
+    if show_id:
+        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 ────────────────────────────────────────────────────
@@ -430,8 +507,13 @@ _SHEET_TEMPLATES: dict[str, tuple] = {
 }
 
 
-def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
-    w_mm, h_mm = _SINGLE_LABEL_SIZES_MM[template]
+def _render_single_label_pdf(
+    template: TemplateName,
+    data_list: list[LabelData],
+    monochrome: bool = False,
+    fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
+) -> bytes:
+    w_mm, h_mm = label_size_mm(template)
     page_w, page_h = w_mm * mm, h_mm * mm
 
     buf = io.BytesIO()
@@ -439,13 +521,23 @@ def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData],
     c.setTitle(f"Bambuddy spool labels ({template})")
 
     for data in data_list:
-        _draw_label(c, 0, 0, page_w, page_h, data, monochrome)
+        _draw_label(c, 0, 0, page_w, page_h, data, monochrome, fields)
         c.showPage()
 
     c.save()
     return buf.getvalue()
 
 
+def label_size_mm(template: TemplateName) -> tuple[float, float]:
+    """(width, height) of one label of ``template`` — a sheet's cell size."""
+    if template in _SINGLE_LABEL_SIZES_MM:
+        return _SINGLE_LABEL_SIZES_MM[template]
+    if template in _SHEET_TEMPLATES:
+        layout = _SHEET_TEMPLATES[template]
+        return layout[1], layout[2]
+    raise ValueError(f"Unknown label template: {template!r}")
+
+
 def get_sheet_capacity(template: TemplateName) -> int | None:
     """Return the number of slots on a sheet template, or ``None`` for roll labels."""
     layout = _SHEET_TEMPLATES.get(template)
@@ -459,6 +551,7 @@ def _render_sheet_pdf(
     data_list: list[LabelData],
     monochrome: bool,
     starting_position: int,
+    fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
 ) -> 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
@@ -490,7 +583,7 @@ def _render_sheet_pdf(
             col = slot_index % 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, monochrome)
+            _draw_label(c, x, y, label_w, label_h, data, monochrome, fields)
         c.showPage()
         data_index += len(chunk)
         page_number += 1
@@ -505,6 +598,7 @@ def render_labels(
     *,
     monochrome: bool = False,
     starting_position: int = 1,
+    fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
 ) -> bytes:
     """Render ``data_list`` to a PDF using the named template. Returns bytes.
 
@@ -517,16 +611,81 @@ def render_labels(
 
     ``starting_position`` is one-based and applies only to the first page of a
     sheet template. Later pages always begin at the first slot.
+
+    ``fields`` names the lines to print (#2981); see ``ALL_LABEL_FIELDS``.
     """
     if template in _SINGLE_LABEL_SIZES_MM:
         if starting_position != 1:
             raise ValueError("Starting position is only supported for sheet label templates")
-        return _render_single_label_pdf(template, data_list, monochrome)
+        return _render_single_label_pdf(template, data_list, monochrome, fields)
     if template in _SHEET_TEMPLATES:
-        return _render_sheet_pdf(template, data_list, monochrome, starting_position)
+        return _render_sheet_pdf(template, data_list, monochrome, starting_position, fields)
     raise ValueError(f"Unknown label template: {template!r}")
 
 
-__all__ = ["LabelData", "TemplateName", "get_sheet_capacity", "render_labels"]
+def render_label_preview_pdf(
+    template: TemplateName,
+    data: LabelData,
+    *,
+    monochrome: bool = False,
+    fields: frozenset[LabelField] = DEFAULT_LABEL_FIELDS,
+) -> bytes:
+    """One label of ``template`` on a page of its own size.
+
+    For a roll template that is the label as printed; for a sheet it is one
+    cell, which is all the preview needs to show.
+    """
+    w_mm, h_mm = label_size_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))
+    _draw_label(c, 0, 0, page_w, page_h, data, monochrome, fields)
+    c.showPage()
+    c.save()
+    return buf.getvalue()
+
+
+def pdf_to_pngs(pdf: bytes, dpi: int) -> list[bytes]:
+    """Rasterise every page of ``pdf`` to a PNG at ``dpi``.
+
+    Images are drawn without smoothing: the QR is embedded as a bitmap, and
+    resampling it with interpolation greys the module edges, which is what
+    makes a small code unreadable on a 203 dpi thermal printer (#1870). The
+    PNG carries its dpi so label software prints it at the right size.
+    """
+    import pypdfium2 as pdfium
+
+    from backend.app.services.pdf_thumbnail import _PDFIUM_LOCK
+
+    pages: list[bytes] = []
+    with _PDFIUM_LOCK:
+        doc = pdfium.PdfDocument(pdf)
+        try:
+            for index in range(len(doc)):
+                page = doc[index]
+                try:
+                    image = page.render(scale=dpi / 72, no_smoothimage=True).to_pil()
+                finally:
+                    page.close()
+                out = io.BytesIO()
+                image.convert("RGB").save(out, "PNG", dpi=(dpi, dpi), optimize=True)
+                pages.append(out.getvalue())
+        finally:
+            doc.close()
+    return pages
+
+
+__all__ = [
+    "ALL_LABEL_FIELDS",
+    "DEFAULT_LABEL_FIELDS",
+    "LabelData",
+    "LabelField",
+    "TemplateName",
+    "get_sheet_capacity",
+    "label_size_mm",
+    "pdf_to_pngs",
+    "render_label_preview_pdf",
+    "render_labels",
+]
 # white re-exported for completeness; future templates may need a paper-tone variant.
 _ = white

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

@@ -293,3 +293,256 @@ class TestValidation:
             },
         )
         assert resp.status_code == 422
+
+
+# ── Fields, PNG and preview (#2981) ──────────────────────────────────────────
+
+
+def _spoolman_on(db_session: AsyncSession):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+
+
+def _spoolman_client(spools: list[dict]) -> MagicMock:
+    client = MagicMock()
+    client.is_connected = True
+    client.get_spools = AsyncMock(return_value=spools)
+    return client
+
+
+class TestLabelFieldsAndFormats:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_omitted_fields_print_the_default_set(self, async_client: AsyncClient, spool_factory):
+        from backend.app.api.routes import labels as labels_module
+        from backend.app.services.label_renderer import DEFAULT_LABEL_FIELDS
+
+        spool = await spool_factory()
+        captured = {}
+        original = labels_module.render_labels
+
+        def _capture(template, data_list, **kwargs):
+            captured["fields"] = kwargs["fields"]
+            return original(template, data_list, **kwargs)
+
+        with patch.object(labels_module, "render_labels", side_effect=_capture):
+            resp = await async_client.post(
+                "/api/v1/inventory/labels", json={"spool_ids": [spool.id], "template": "box_40x30"}
+            )
+        assert resp.status_code == 200
+        assert captured["fields"] == DEFAULT_LABEL_FIELDS
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chosen_fields_and_new_spool_data_reach_the_renderer(self, async_client: AsyncClient, spool_factory):
+        from backend.app.api.routes import labels as labels_module
+
+        spool = await spool_factory(material_number="MN-15", nozzle_temp_min=190, nozzle_temp_max=230, note="Dry first")
+        captured = {}
+        original = labels_module.render_labels
+
+        def _capture(template, data_list, **kwargs):
+            captured["fields"] = kwargs["fields"]
+            captured["data"] = data_list[0]
+            return original(template, data_list, **kwargs)
+
+        with patch.object(labels_module, "render_labels", side_effect=_capture):
+            resp = await async_client.post(
+                "/api/v1/inventory/labels",
+                json={
+                    "spool_ids": [spool.id],
+                    "template": "box_40x30",
+                    "fields": ["temps", "material_number", "temps"],
+                },
+            )
+        assert resp.status_code == 200
+        assert captured["fields"] == {"temps", "material_number"}
+        data = captured["data"]
+        assert (data.material_number, data.nozzle_temp_min, data.nozzle_temp_max) == ("MN-15", 190, 230)
+        assert (data.label_weight, data.note) == (1000, "Dry first")
+        assert data.added is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_field_rejected(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            json={"spool_ids": [spool.id], "template": "box_40x30", "fields": ["brand", "password"]},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("body", [{"format": "gif"}, {"format": "png", "dpi": 72}, {"spool_ids": [0]}])
+    async def test_invalid_output_options_rejected(self, async_client: AsyncClient, body: dict):
+        resp = await async_client.post(
+            "/api/v1/inventory/labels", json={"spool_ids": [1], "template": "box_40x30", **body}
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_one_label_as_png(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            json={"spool_ids": [spool.id], "template": "box_40x30", "format": "png", "dpi": 203},
+        )
+        assert resp.status_code == 200
+        assert resp.headers["content-type"] == "image/png"
+        assert "bambuddy-labels-box_40x30.png" in resp.headers["content-disposition"]
+        assert resp.content.startswith(b"\x89PNG")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_several_roll_labels_come_as_a_zip_named_by_spool(self, async_client: AsyncClient, spool_factory):
+        import io
+        import zipfile
+
+        s1 = await spool_factory()
+        s2 = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            json={"spool_ids": [s2.id, s1.id], "template": "box_62x29", "format": "png"},
+        )
+        assert resp.status_code == 200
+        assert resp.headers["content-type"] == "application/zip"
+        with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
+            assert zf.namelist() == [f"label-{s2.id}.png", f"label-{s1.id}.png"]
+            assert all(zf.read(n).startswith(b"\x89PNG") for n in zf.namelist())
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sheet_pngs_are_numbered_by_page(self, async_client: AsyncClient, spool_factory):
+        import io
+        import zipfile
+
+        s1 = await spool_factory()
+        s2 = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            # 21 per L7160 page; starting at 21 puts the second label on page two.
+            json={
+                "spool_ids": [s1.id, s2.id],
+                "template": "avery_l7160",
+                "format": "png",
+                "starting_position": 21,
+            },
+        )
+        assert resp.status_code == 200
+        with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
+            assert zf.namelist() == ["sheet-1.png", "sheet-2.png"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preview_is_one_png(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/labels/preview",
+            json={"spool_id": spool.id, "template": "avery_5160", "fields": ["brand", "qr"]},
+        )
+        assert resp.status_code == 200
+        assert resp.headers["content-type"] == "image/png"
+        assert resp.headers["cache-control"] == "no-store"
+        assert resp.content.startswith(b"\x89PNG")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preview_of_unknown_spool_is_404(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/inventory/labels/preview", json={"spool_id": 99999, "template": "box_40x30"}
+        )
+        assert resp.status_code == 404
+
+
+class TestSpoolmanLabelParity:
+    """A Spoolman spool's label is built from the same mapping the inventory
+    page shows, so it carries what a built-in spool's label does."""
+
+    SPOOL = {
+        "id": 7,
+        "filament": {
+            "name": "PLA Matte",
+            "material": "PLA",
+            "color_hex": "000000",
+            "vendor": {"name": "Bambu Lab"},
+            "article_number": "MN-15",
+            "settings_extruder_temp": 220,
+            "weight": 1000,
+        },
+        "extra": {"bambu_color_name": '"Charcoal"'},
+        "comment": "Dry first",
+        "registered": "2026-09-01T10:00:00Z",
+        "location": "Shelf 2",
+    }
+
+    async def _captured(self, async_client: AsyncClient, db_session: AsyncSession, spool: dict):
+        from backend.app.api.routes import labels as labels_module
+
+        _spoolman_on(db_session)
+        await db_session.commit()
+        captured = {}
+        original = labels_module.render_labels
+
+        def _capture(template, data_list, **kwargs):
+            captured["data"] = data_list[0]
+            return original(template, data_list, **kwargs)
+
+        with (
+            patch.object(labels_module, "render_labels", side_effect=_capture),
+            patch(
+                "backend.app.api.routes.labels.get_spoolman_client",
+                AsyncMock(return_value=_spoolman_client([spool])),
+            ),
+        ):
+            resp = await async_client.post(
+                "/api/v1/spoolman/labels", json={"spool_ids": [spool["id"]], "template": "box_40x30"}
+            )
+        assert resp.status_code == 200
+        return captured["data"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_carries_subtype_colour_name_and_the_new_fields(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        from datetime import date
+
+        data = await self._captured(async_client, db_session, self.SPOOL)
+        assert (data.material, data.subtype, data.name, data.brand) == ("PLA", "Matte", "Charcoal", "Bambu Lab")
+        assert (data.material_number, data.nozzle_temp_min, data.label_weight) == ("MN-15", 220, 1000)
+        assert (data.note, data.added, data.storage_location) == ("Dry first", date(2026, 9, 1), "Shelf 2")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_without_a_colour_name_the_filament_name_is_used(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """As before: the name line falls back to the filament name, not to the
+        subtype the mapping synthesises as a colour name."""
+        spool = {**self.SPOOL, "extra": {}}
+        data = await self._captured(async_client, db_session, spool)
+        assert data.name == "PLA Matte"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preview_uses_spoolman(self, async_client: AsyncClient, db_session: AsyncSession):
+        _spoolman_on(db_session)
+        await db_session.commit()
+        with patch(
+            "backend.app.api.routes.labels.get_spoolman_client",
+            AsyncMock(return_value=_spoolman_client([self.SPOOL])),
+        ):
+            resp = await async_client.post(
+                "/api/v1/spoolman/labels/preview", json={"spool_id": 7, "template": "box_40x30"}
+            )
+        assert resp.status_code == 200
+        assert resp.content.startswith(b"\x89PNG")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preview_refused_when_spoolman_is_off(self, async_client: AsyncClient):
+        resp = await async_client.post("/api/v1/spoolman/labels/preview", json={"spool_id": 7, "template": "box_40x30"})
+        assert resp.status_code == 400

+ 197 - 0
backend/tests/unit/services/test_label_renderer_fields.py

@@ -0,0 +1,197 @@
+"""Choosing what a spool label shows, and PNG output (#2981).
+
+Text is checked in an uncompressed PDF, where ReportLab writes each string as
+it was drawn.
+"""
+
+from __future__ import annotations
+
+import io
+from datetime import date
+from unittest.mock import patch
+
+import pytest
+from PIL import Image
+from reportlab.lib.units import mm
+from reportlab.pdfgen import canvas as rl_canvas
+
+from backend.app.services.label_renderer import (
+    ALL_LABEL_FIELDS,
+    DEFAULT_LABEL_FIELDS,
+    LabelData,
+    _draw_label,
+    label_size_mm,
+    pdf_to_pngs,
+    render_label_preview_pdf,
+    render_labels,
+)
+
+ALL = frozenset(ALL_LABEL_FIELDS)
+
+
+def _data(**overrides) -> LabelData:
+    values = {
+        "spool_id": 128,
+        "name": "Jade White",
+        "material": "PLA",
+        "brand": "Bambu Lab",
+        "subtype": "Matte",
+        "rgba": "E8F0E0FF",
+        "storage_location": "Shelf 2",
+        "deeplink_url": "https://example.test/inventory?spool=128",
+        "material_number": "MN-15",
+        "nozzle_temp_min": 190,
+        "nozzle_temp_max": 230,
+        "label_weight": 1000,
+        "note": "Dry first",
+        "added": date(2026, 9, 30),
+    }
+    values.update(overrides)
+    return LabelData(**values)
+
+
+def _text(template: str, data: LabelData, fields=DEFAULT_LABEL_FIELDS) -> bytes:
+    w_mm, h_mm = label_size_mm(template)
+    buf = io.BytesIO()
+    c = rl_canvas.Canvas(buf, pagesize=(w_mm * mm, h_mm * mm), pageCompression=0)
+    _draw_label(c, 0, 0, w_mm * mm, h_mm * mm, data, False, fields)
+    c.showPage()
+    c.save()
+    return buf.getvalue()
+
+
+def test_default_fields_are_what_labels_always_carried():
+    assert {"brand", "material", "hex", "name", "location", "qr", "spool_id"} == DEFAULT_LABEL_FIELDS
+
+
+def test_default_fields_leave_the_new_lines_off():
+    pdf = _text("ams_holder_75x55", _data())
+    for present in (b"Bambu Lab", b"Jade White", b"Shelf 2", b"#E8F0E0", b"#128"):
+        assert present in pdf
+    for absent in (b"MN-15", b"190", b"1000 g", b"Dry first", b"2026-09-30"):
+        assert absent not in pdf
+
+
+def test_every_field_prints_on_a_label_with_room():
+    pdf = _text("ams_holder_75x55", _data(), ALL)
+    for present in (b"MN-15", b"190", b"230", b"1000 g", b"Dry first", b"2026-09-30", b"#128"):
+        assert present in pdf
+
+
+def test_a_single_temperature_prints_alone():
+    pdf = _text("ams_holder_75x55", _data(nozzle_temp_max=None), frozenset({"temps"}))
+    assert b"190 " in pdf
+    assert b"230" not in pdf
+
+
+def test_unticked_lines_are_left_out():
+    pdf = _text("ams_holder_75x55", _data(), ALL - {"brand", "spool_id", "hex"})
+    assert b"Bambu Lab" not in pdf
+    assert b"#128" not in pdf
+    assert b"#E8F0E0" not in pdf
+    assert b"Jade White" in pdf
+
+
+def test_no_qr_without_the_qr_field():
+    with patch("backend.app.services.label_renderer._draw_qr") as draw_qr:
+        render_labels("box_40x30", [_data()], fields=ALL - {"qr"})
+    draw_qr.assert_not_called()
+    with patch("backend.app.services.label_renderer._draw_qr") as draw_qr:
+        render_labels("box_40x30", [_data()])
+    draw_qr.assert_called_once()
+
+
+def test_lines_that_do_not_fit_stop_above_the_spool_id():
+    """A 40 x 30 label has no room for all ten lines. The ones that don't fit
+    are dropped rather than drawn over the spool ID at the bottom."""
+    drawn: list[tuple[float, str]] = []
+    real = rl_canvas.Canvas.drawString
+
+    def record(self, x, y, text, *args, **kwargs):
+        drawn.append((y, text))
+        return real(self, x, y, text, *args, **kwargs)
+
+    with patch.object(rl_canvas.Canvas, "drawString", record):
+        _text("box_40x30", _data(), ALL)
+
+    id_y = next(y for y, text in drawn if text == "#128")
+    other = [y for y, text in drawn if text != "#128"]
+    assert other, "some lines must still print"
+    # The ID is 16 pt tall from its baseline; every other baseline sits above it.
+    assert min(other) >= id_y + 16
+    assert "2026-09-30" not in [text for _, text in drawn]
+
+
+def test_name_is_not_repeated_when_it_is_the_subtype():
+    """A Spoolman filament named after its colour gives subtype == name."""
+    pdf = _text("ams_holder_75x55", _data(name="Matte"))
+    assert pdf.count(b"Matte") == 1
+
+
+def test_name_prints_when_the_material_line_is_off():
+    pdf = _text("ams_holder_75x55", _data(name="Matte"), DEFAULT_LABEL_FIELDS - {"material"})
+    assert b"Matte" in pdf
+
+
+def test_tight_layout_honours_the_fields():
+    """The small layout only has brand, material, hex and ID; they obey the choice too."""
+    w, h = 40 * mm, 15 * mm
+    buf = io.BytesIO()
+    c = rl_canvas.Canvas(buf, pagesize=(w, h), pageCompression=0)
+    _draw_label(c, 0, 0, w, h, _data(), False, frozenset({"hex"}))
+    c.showPage()
+    c.save()
+    pdf = buf.getvalue()
+    assert b"#E8F0E0" in pdf
+    assert b"Bambu Lab" not in pdf
+    assert b"#128" not in pdf
+
+
+def _assert_size(image: Image.Image, w_mm: float, h_mm: float, dpi: int) -> None:
+    """PDFium rounds a fractional pixel up, so allow one either way."""
+    expected = (w_mm / 25.4 * dpi, h_mm / 25.4 * dpi)
+    assert all(abs(got - want) <= 1 for got, want in zip(image.size, expected, strict=True)), (image.size, expected)
+
+
+@pytest.mark.parametrize(
+    ("template", "size_mm"),
+    [("box_40x30", (40.0, 30.0)), ("avery_l7160", (63.5, 38.1)), ("avery_5160", (66.675, 25.4))],
+)
+def test_label_size_is_the_cell_for_a_sheet(template, size_mm):
+    assert label_size_mm(template) == size_mm
+
+
+def test_preview_is_one_label_of_the_templates_size():
+    """A sheet's preview is one cell, not a whole A4 page."""
+    pngs = pdf_to_pngs(render_label_preview_pdf("avery_l7160", _data(), fields=ALL), 300)
+    assert len(pngs) == 1
+    image = Image.open(io.BytesIO(pngs[0]))
+    _assert_size(image, 63.5, 38.1, 300)
+
+
+@pytest.mark.parametrize("dpi", [203, 300, 600])
+def test_png_is_printed_at_the_requested_resolution(dpi):
+    pngs = pdf_to_pngs(render_labels("box_40x30", [_data()]), dpi)
+    image = Image.open(io.BytesIO(pngs[0]))
+    assert image.format == "PNG"
+    _assert_size(image, 40, 30, dpi)
+    assert tuple(round(v) for v in image.info["dpi"]) == (dpi, dpi)
+
+
+def test_one_png_per_page():
+    roll = pdf_to_pngs(render_labels("box_40x30", [_data(spool_id=i) for i in range(1, 4)]), 203)
+    assert len(roll) == 3
+    sheets = pdf_to_pngs(render_labels("avery_l7160", [_data(spool_id=i) for i in range(1, 23)]), 72)
+    assert len(sheets) == 2
+
+
+def test_qr_stays_black_and_white_in_the_png():
+    """Rasterised without image smoothing, so the QR's modules keep hard edges
+    instead of grey fringes a 203 dpi head can't print (#1870)."""
+    png = pdf_to_pngs(render_label_preview_pdf("box_40x30", _data(), fields=frozenset({"qr"})), 203)[0]
+    image = Image.open(io.BytesIO(png)).convert("L")
+    # Only the QR is drawn besides the swatch and hairline border; crop to it.
+    w, h = image.size
+    qr_region = image.crop((w // 2, 0, w, h))
+    greys = sum(1 for v in qr_region.getdata() if 40 < v < 215)
+    assert greys / (qr_region.width * qr_region.height) < 0.02

+ 343 - 88
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -1,20 +1,49 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent, within } from '@testing-library/react';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
 import { render } from '../utils';
 import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
 import { LabelTemplatePickerModal } from '../../components/LabelTemplatePickerModal';
 import { api } from '../../api/client';
 
-vi.mock('../../api/client', () => ({
+vi.mock('../../api/client', async (importOriginal) => ({
+  // The field lists are real exports the modal renders from.
+  ...(await importOriginal<typeof import('../../api/client')>()),
   api: {
     printSpoolLabels: vi.fn(),
     printSpoolmanSpoolLabels: vi.fn(),
+    previewSpoolLabel: vi.fn(),
+    previewSpoolmanSpoolLabel: 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 PNG_BLOB = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' });
+const ZIP_BLOB = new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], { type: 'application/zip' });
+
+// What a label carried before the fields were selectable (#2981), in print order.
+const DEFAULT_FIELDS = ['brand', 'material', 'hex', 'name', 'location', 'qr', 'spool_id'];
+
+/** The request the print button sends, with the options' defaults filled in. */
+function printRequest(overrides: Record<string, unknown>) {
+  return {
+    monochrome: false,
+    starting_position: 1,
+    fields: DEFAULT_FIELDS,
+    format: 'pdf',
+    dpi: 300,
+    ...overrides,
+  };
+}
+
+function pickTemplate(template: string) {
+  fireEvent.change(screen.getByTestId('label-template-select'), { target: { value: template } });
+}
+
+function clickPrint() {
+  fireEvent.click(screen.getByTestId('label-print-button'));
+}
 
 const SPOOLS = [
   { id: 1, material: 'PLA', subtype: 'Basic', brand: 'Polymaker', color_name: 'Red', rgba: 'FF0000FF' },
@@ -25,6 +54,13 @@ const SPOOLS = [
 
 beforeEach(() => {
   vi.clearAllMocks();
+  // setup.ts stubs localStorage with bare mocks; give it a store so the
+  // remembered options survive a reopen, as they do in a browser.
+  const store = new Map<string, string>();
+  vi.mocked(localStorage.getItem).mockImplementation((key) => store.get(key) ?? null);
+  vi.mocked(localStorage.setItem).mockImplementation((key, value) => void store.set(key, String(value)));
+  vi.mocked(api.previewSpoolLabel).mockResolvedValue(PNG_BLOB);
+  vi.mocked(api.previewSpoolmanSpoolLabel).mockResolvedValue(PNG_BLOB);
   vi.mocked(api.getSettings).mockResolvedValue({} as never);
   vi.mocked(api.getAuthStatus).mockResolvedValue({ auth_enabled: false } as never);
   Object.defineProperty(window.URL, 'createObjectURL', {
@@ -224,7 +260,7 @@ describe('LabelTemplatePickerModal', () => {
     expect(screen.queryByText(/selected/i)).not.toBeInTheDocument();
   });
 
-  it('template buttons disabled when nothing is selected', () => {
+  it('disables the print button when nothing is selected', () => {
     render(
       <LabelTemplatePickerModal
         isOpen={true}
@@ -234,13 +270,7 @@ describe('LabelTemplatePickerModal', () => {
         spoolmanMode={false}
       />,
     );
-    // Two AMS holder variants exist (#1426). Both must be disabled when no
-    // spools are selected — the empty-selection guard is global, not per-template.
-    const amsButtons = screen.getAllByText(/AMS holder/i).map((el) => el.closest('button'));
-    expect(amsButtons).toHaveLength(2);
-    for (const btn of amsButtons) {
-      expect(btn).toBeDisabled();
-    }
+    expect(screen.getByTestId('label-print-button')).toBeDisabled();
   });
 
   it('sends only the currently checked IDs to the local endpoint', async () => {
@@ -257,17 +287,13 @@ describe('LabelTemplatePickerModal', () => {
     );
 
     fireEvent.click(screen.getByText(/Blue · Sunlu/));  // uncheck spool 2
-    // Two "Box label …" templates exist now (40×30 and 62×29) — pin the
-    // specific one we want to send so the assertion below stays meaningful.
-    fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
+    pickTemplate('box_62x29');
+    clickPrint();
 
     await waitFor(() => {
-      expect(api.printSpoolLabels).toHaveBeenCalledWith({
-        spool_ids: [1, 3],
-        template: 'box_62x29',
-        monochrome: false,
-        starting_position: 1,
-      });
+      expect(api.printSpoolLabels).toHaveBeenCalledWith(
+        printRequest({ spool_ids: [1, 3], template: 'box_62x29' }),
+      );
     });
     await waitFor(() => expect(onClose).toHaveBeenCalled());
   });
@@ -284,17 +310,13 @@ describe('LabelTemplatePickerModal', () => {
       />,
     );
 
-    // Pick the larger AMS holder variant explicitly (#1426: two AMS templates
-     // exist now — pin which one the test sends so the assertion stays meaningful).
-    fireEvent.click(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i));
+    pickTemplate('ams_holder_75x55');
+    clickPrint();
 
     await waitFor(() => {
-      expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
-        spool_ids: [1],
-        template: 'ams_holder_75x55',
-        monochrome: false,
-        starting_position: 1,
-      });
+      expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith(
+        printRequest({ spool_ids: [1], template: 'ams_holder_75x55' }),
+      );
     });
     expect(api.printSpoolLabels).not.toHaveBeenCalled();
   });
@@ -312,7 +334,8 @@ describe('LabelTemplatePickerModal', () => {
       />,
     );
 
-    fireEvent.click(screen.getByText(/Avery L7160/i));
+    pickTemplate('avery_l7160');
+    clickPrint();
 
     await waitFor(() => {
       expect(api.printSpoolLabels).toHaveBeenCalled();
@@ -347,15 +370,10 @@ describe('LabelTemplatePickerModal', () => {
     expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
   });
 
-  it('packs templates into a 2-column grid so they plus Cancel fit on short viewports (#1230)', () => {
-    // Regression for #1230: with templates stacked vertically (~310-390px) plus
-    // header/search/action bar/footer, the modal blew past max-h-[90vh] on
-    // Windows-11 + Brave-style viewports where browser chrome eats into 90vh.
-    // overflow-hidden on the modal then clipped the bottom templates and the
-    // Cancel footer with no scroll path. The fix uses sm:grid-cols-2 so the
-    // templates render as a 2-column grid, trimming ~150px of vertical and
-    // leaving room for the footer. The earlier min-h-0 on the spool list is
-    // kept so it still yields any remaining slack.
+  it('offers every template and keeps the footer reachable (#1230)', () => {
+    // #1230: the modal outgrew max-h-[90vh] and clipped its footer. The body
+    // between header and footer is the part that scrolls, the spool list can
+    // shrink (min-h-0), and the template choice is a single select.
     const { container } = render(
       <LabelTemplatePickerModal
         isOpen={true}
@@ -366,25 +384,18 @@ describe('LabelTemplatePickerModal', () => {
       />,
     );
 
-    // All six templates must be in the DOM (#1426 added two AMS variants).
-    // Use the dimension suffix to disambiguate same-family entries.
-    expect(screen.getByText(/AMS holder — small \(74 × 33 mm\)/i)).toBeInTheDocument();
-    expect(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i)).toBeInTheDocument();
-    expect(screen.getByText(/Box label \(40 × 30 mm\)/i)).toBeInTheDocument();
-    expect(screen.getByText(/Box label \(62 × 29 mm\)/i)).toBeInTheDocument();
-    expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
-    expect(screen.getByText(/Avery 5160/i)).toBeInTheDocument();
+    const options = [...(screen.getByTestId('label-template-select') as HTMLSelectElement).options];
+    expect(options.map((o) => o.value)).toEqual([
+      'ams_holder_74x33',
+      'ams_holder_75x55',
+      'box_40x30',
+      'box_62x29',
+      'avery_l7160',
+      'avery_5160',
+    ]);
     expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
+    expect(screen.getByTestId('label-template-picker-panel')).toHaveClass('max-h-[90vh]');
 
-    // Templates section must be a responsive grid (single column on mobile,
-    // two columns from sm: up) — a future refactor that drops the grid and
-    // reintroduces stacked rows fails CI.
-    const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
-    expect(templatesSection).not.toBeNull();
-    expect(templatesSection!.className).toContain('grid-cols-1');
-    expect(templatesSection!.querySelectorAll('button').length).toBe(6);
-
-    // Spool list still uses min-h-0 so it can yield further on very tight viewports.
     const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
     expect(spoolListScroller).not.toBeNull();
     expect(spoolListScroller!.className).toContain('min-h-0');
@@ -411,7 +422,8 @@ describe('LabelTemplatePickerModal', () => {
 
     // Default is ID-sorted; flip to colour.
     fireEvent.click(screen.getByRole('button', { name: 'By colour' }));
-    fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
+    pickTemplate('box_62x29');
+    clickPrint();
 
     await waitFor(() => {
       // Expected colour-sort order for the SPOOLS fixture:
@@ -420,12 +432,9 @@ describe('LabelTemplatePickerModal', () => {
       //   Blue   (2) — hue 240° — chromatic
       //   Black  (3) — saturation ≈0 → neutrals bucket, lightness 0 → last
       // Rainbow first, then neutrals (dark→light) per design choice for #1410.
-      expect(api.printSpoolLabels).toHaveBeenCalledWith({
-        spool_ids: [1, 4, 2, 3],
-        template: 'box_62x29',
-        monochrome: false,
-        starting_position: 1,
-      });
+      expect(api.printSpoolLabels).toHaveBeenCalledWith(
+        printRequest({ spool_ids: [1, 4, 2, 3], template: 'box_62x29' }),
+      );
     });
   });
 
@@ -443,15 +452,13 @@ describe('LabelTemplatePickerModal', () => {
       />,
     );
 
-    fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
+    pickTemplate('box_40x30');
+    clickPrint();
 
     await waitFor(() => {
-      expect(api.printSpoolLabels).toHaveBeenCalledWith({
-        spool_ids: [1, 2, 3, 4],
-        template: 'box_40x30',
-        monochrome: false,
-        starting_position: 1,
-      });
+      expect(api.printSpoolLabels).toHaveBeenCalledWith(
+        printRequest({ spool_ids: [1, 2, 3, 4], template: 'box_40x30' }),
+      );
     });
   });
 
@@ -468,15 +475,13 @@ describe('LabelTemplatePickerModal', () => {
     );
 
     fireEvent.click(screen.getByText(/black & white printer/i));
-    fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
+    pickTemplate('box_40x30');
+    clickPrint();
 
     await waitFor(() => {
-      expect(api.printSpoolLabels).toHaveBeenCalledWith({
-        spool_ids: [1],
-        template: 'box_40x30',
-        monochrome: true,
-        starting_position: 1,
-      });
+      expect(api.printSpoolLabels).toHaveBeenCalledWith(
+        printRequest({ spool_ids: [1], template: 'box_40x30', monochrome: true }),
+      );
     });
   });
 
@@ -492,17 +497,15 @@ describe('LabelTemplatePickerModal', () => {
       />,
     );
 
+    pickTemplate('avery_5160');
     fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '8' } });
     expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(/Positions 1 through 7/i);
-    fireEvent.click(screen.getByTestId('print-labels-avery_5160'));
+    clickPrint();
 
     await waitFor(() => {
-      expect(api.printSpoolLabels).toHaveBeenCalledWith({
-        spool_ids: [1],
-        template: 'avery_5160',
-        monochrome: false,
-        starting_position: 8,
-      });
+      expect(api.printSpoolLabels).toHaveBeenCalledWith(
+        printRequest({ spool_ids: [1], template: 'avery_5160', starting_position: 8 }),
+      );
     });
   });
 
@@ -517,13 +520,14 @@ describe('LabelTemplatePickerModal', () => {
       />,
     );
 
+    pickTemplate('avery_l7160');
     fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '2' } });
     expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(
       'Positions 1 through 1 will be left blank on the first sheet.',
     );
   });
 
-  it('explains each Avery template capacity when disabling it', () => {
+  it("checks the starting position against the chosen sheet's own capacity", () => {
     render(
       <LabelTemplatePickerModal
         isOpen={true}
@@ -534,10 +538,261 @@ describe('LabelTemplatePickerModal', () => {
       />,
     );
 
+    pickTemplate('avery_l7160');
     fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '25' } });
-    const l7160Button = screen.getByTestId('print-labels-avery_l7160');
-    expect(l7160Button).toBeDisabled();
-    expect(within(l7160Button).getByText(/between 1 and 21/)).toBeInTheDocument();
-    expect(screen.getByTestId('print-labels-avery_5160')).toBeEnabled();
+    expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(/from 1 to 21/);
+    expect(screen.getByTestId('label-print-button')).toBeDisabled();
+
+    // 25 is on the first sheet of an Avery 5160 (30 per page).
+    pickTemplate('avery_5160');
+    expect(screen.getByTestId('label-print-button')).toBeEnabled();
+  });
+
+  it('offers a starting position only for sheet templates', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+
+    pickTemplate('box_40x30');
+    expect(screen.queryByTestId('label-starting-position')).not.toBeInTheDocument();
+    pickTemplate('avery_5160');
+    expect(screen.getByTestId('label-starting-position')).toBeInTheDocument();
+  });
+});
+
+describe('choosing what the label shows (#2981)', () => {
+  const openModal = (props: Partial<Parameters<typeof LabelTemplatePickerModal>[0]> = {}) =>
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+        {...props}
+      />,
+    );
+
+  it('starts from the lines labels always carried', () => {
+    openModal();
+    for (const field of DEFAULT_FIELDS) {
+      expect(screen.getByTestId(`label-field-${field}`)).toBeChecked();
+    }
+    for (const field of ['material_number', 'temps', 'weight', 'note', 'added']) {
+      expect(screen.getByTestId(`label-field-${field}`)).not.toBeChecked();
+    }
+  });
+
+  it('sends the chosen lines in print order', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    openModal();
+    pickTemplate('box_40x30');
+
+    fireEvent.click(screen.getByTestId('label-field-temps'));
+    fireEvent.click(screen.getByTestId('label-field-qr'));
+    fireEvent.click(screen.getByTestId('label-field-brand'));
+    clickPrint();
+
+    await waitFor(() => {
+      expect(api.printSpoolLabels).toHaveBeenCalledWith(
+        printRequest({
+          spool_ids: [1],
+          template: 'box_40x30',
+          fields: ['material', 'hex', 'name', 'location', 'temps', 'spool_id'],
+        }),
+      );
+    });
+  });
+
+  it('puts the defaults back on reset', () => {
+    openModal();
+    fireEvent.click(screen.getByTestId('label-field-brand'));
+    fireEvent.click(screen.getByTestId('label-field-weight'));
+
+    fireEvent.click(screen.getByRole('button', { name: /Reset to default/i }));
+
+    expect(screen.getByTestId('label-field-brand')).toBeChecked();
+    expect(screen.getByTestId('label-field-weight')).not.toBeChecked();
+  });
+
+  it('remembers the lines per template, and the rest of the options, for next time', () => {
+    const { unmount } = openModal();
+    pickTemplate('box_40x30');
+    fireEvent.click(screen.getByTestId('label-field-weight'));
+    fireEvent.click(screen.getByText(/black & white printer/i));
+    fireEvent.click(screen.getByTestId('label-format-png'));
+    unmount();
+
+    openModal();
+    expect(screen.getByTestId('label-template-select')).toHaveValue('box_40x30');
+    expect(screen.getByTestId('label-field-weight')).toBeChecked();
+    expect(screen.getByRole('checkbox', { name: /black & white printer/i })).toBeChecked();
+    expect(screen.getByTestId('label-format-png')).toHaveAttribute('aria-pressed', 'true');
+
+    // A sheet holds a different amount, so its lines are chosen separately.
+    pickTemplate('avery_l7160');
+    expect(screen.getByTestId('label-field-weight')).not.toBeChecked();
+  });
+
+  it('ignores remembered options it does not recognise', () => {
+    localStorage.setItem(
+      'bambuddy-label-print-options',
+      JSON.stringify({ template: 'gone', fields: { box_40x30: ['brand', 'bogus'] }, format: 'tiff', dpi: 1 }),
+    );
+    openModal();
+
+    expect(screen.getByTestId('label-template-select')).toHaveValue('ams_holder_74x33');
+    expect(screen.getByTestId('label-format-pdf')).toHaveAttribute('aria-pressed', 'true');
+    pickTemplate('box_40x30');
+    expect(screen.getByTestId('label-field-brand')).toBeChecked();
+    expect(screen.getByTestId('label-field-material')).not.toBeChecked();
+  });
+});
+
+describe('the label preview (#2981)', () => {
+  it('renders the first spool that will print, with the chosen options', async () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[4, 2]}
+        spoolmanMode={false}
+      />,
+    );
+    pickTemplate('box_62x29');
+    fireEvent.click(screen.getByTestId('label-field-temps'));
+
+    await waitFor(() => {
+      expect(api.previewSpoolLabel).toHaveBeenLastCalledWith(
+        {
+          spool_id: 2,
+          template: 'box_62x29',
+          monochrome: false,
+          fields: [...DEFAULT_FIELDS.slice(0, 5), 'temps', 'qr', 'spool_id'],
+        },
+        expect.any(AbortSignal),
+      );
+    });
+    expect(await screen.findByAltText('Label preview')).toHaveAttribute('src', 'blob:mock');
+    expect(screen.getByText('Preview of #2')).toBeInTheDocument();
+  });
+
+  it('asks for a spool when none is selected', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[]}
+        spoolmanMode={false}
+      />,
+    );
+    expect(screen.getByText(/Select a spool to see its label/i)).toBeInTheDocument();
+    expect(api.previewSpoolLabel).not.toHaveBeenCalled();
+  });
+
+  it('uses the Spoolman preview in Spoolman mode', async () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={true}
+      />,
+    );
+    await waitFor(() => expect(api.previewSpoolmanSpoolLabel).toHaveBeenCalled());
+    expect(api.previewSpoolLabel).not.toHaveBeenCalled();
+  });
+
+  it('says why when the preview fails', async () => {
+    vi.mocked(api.previewSpoolLabel).mockRejectedValue(new Error('Spoolman not reachable'));
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+    expect(await screen.findByText(/Could not render the preview: Spoolman not reachable/)).toBeInTheDocument();
+  });
+});
+
+describe('PNG output (#2981)', () => {
+  function captureDownloads() {
+    const names: string[] = [];
+    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
+      names.push(this.download);
+    });
+    return names;
+  }
+
+  it('sends the format and resolution, and saves the image instead of opening a tab', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PNG_BLOB);
+    const downloads = captureDownloads();
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+    pickTemplate('box_40x30');
+    fireEvent.click(screen.getByTestId('label-format-png'));
+    fireEvent.change(screen.getByTestId('label-dpi-select'), { target: { value: '203' } });
+    clickPrint();
+
+    await waitFor(() => {
+      expect(api.printSpoolLabels).toHaveBeenCalledWith(
+        printRequest({ spool_ids: [1], template: 'box_40x30', format: 'png', dpi: 203 }),
+      );
+    });
+    await waitFor(() => expect(downloads).toEqual(['bambuddy-labels-box_40x30.png']));
+    expect(window.open).not.toHaveBeenCalled();
+  });
+
+  it('names several labels as a ZIP', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(ZIP_BLOB);
+    const downloads = captureDownloads();
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1, 2]}
+        spoolmanMode={false}
+      />,
+    );
+    pickTemplate('box_40x30');
+    fireEvent.click(screen.getByTestId('label-format-png'));
+    clickPrint();
+
+    await waitFor(() => expect(downloads).toEqual(['bambuddy-labels-box_40x30.zip']));
+  });
+
+  it('only offers a resolution for PNG', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+    expect(screen.queryByTestId('label-dpi-select')).not.toBeInTheDocument();
+    fireEvent.click(screen.getByTestId('label-format-png'));
+    expect(screen.getByTestId('label-dpi-select')).toHaveValue('300');
   });
 });

+ 182 - 0
frontend/src/__tests__/pages/InventoryPagePrintLabels.test.tsx

@@ -0,0 +1,182 @@
+/**
+ * The toolbar's "Print labels…" button (#2980): with spools ticked in the list
+ * it opens the picker on those; with none ticked, on everything shown.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent, within } from '@testing-library/react';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const baseSpool = {
+  subtype: null,
+  brand: 'eSun',
+  color_name: 'Blue',
+  rgba: '0000FFFF',
+  extra_colors: null,
+  effect_type: null,
+  label_weight: 1000,
+  core_weight: 250,
+  core_weight_catalog_id: null,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [] as never[],
+  cost_per_kg: null,
+  last_scale_weight: null,
+  last_weighed_at: null,
+  storage_location: null,
+  category: null,
+  low_stock_threshold_pct: null,
+  spoolman_id: null,
+  spoolman_filament_id: null,
+};
+
+const SPOOLS = [
+  { ...baseSpool, id: 5, material: 'PETG', color_name: 'Blue', weight_used: 400 },
+  { ...baseSpool, id: 6, material: 'PLA', color_name: 'Red', rgba: 'FF0000FF', weight_used: 0 },
+];
+
+const MOCK_SETTINGS = {
+  auto_archive: false,
+  save_thumbnails: false,
+  capture_finish_photo: false,
+  default_filament_cost: 25.0,
+  currency: 'USD',
+  energy_cost_per_kwh: 0.15,
+  energy_tracking_mode: 'total',
+  spoolman_enabled: false,
+  spoolman_url: '',
+  spoolman_sync_mode: 'auto',
+  spoolman_disable_weight_sync: false,
+  spoolman_report_partial_usage: true,
+  check_updates: false,
+  check_printer_firmware: false,
+  include_beta_updates: false,
+  language: 'en',
+  notification_language: 'en',
+  bed_cooled_threshold: 35,
+  ams_humidity_good: 40,
+  ams_humidity_fair: 60,
+  ams_temp_good: 28,
+  ams_temp_fair: 35,
+  ams_history_retention_days: 30,
+  per_printer_mapping_expanded: false,
+  date_format: 'system',
+  time_format: 'system',
+  default_printer_id: null,
+  virtual_printer_enabled: false,
+  virtual_printer_access_code: '',
+  virtual_printer_mode: 'archive',
+  dark_style: 'classic',
+  dark_background: 'neutral',
+  dark_accent: 'green',
+  light_style: 'classic',
+  light_background: 'neutral',
+  light_accent: 'green',
+  ftp_retry_enabled: true,
+  ftp_retry_count: 3,
+  ftp_retry_delay: 2,
+  ftp_timeout: 30,
+  mqtt_enabled: false,
+  mqtt_broker: '',
+  mqtt_port: 1883,
+  mqtt_username: '',
+  mqtt_password: '',
+  mqtt_topic_prefix: 'bambuddy',
+  mqtt_use_tls: false,
+  external_url: '',
+  ha_enabled: false,
+  ha_url: '',
+  ha_token: '',
+  ha_url_from_env: false,
+  ha_token_from_env: false,
+  ha_env_managed: false,
+  library_archive_mode: 'ask',
+  library_disk_warning_gb: 5.0,
+  camera_view_mode: 'window',
+  preferred_slicer: 'bambu_studio',
+  prometheus_enabled: false,
+  prometheus_token: '',
+  low_stock_threshold: 20.0,
+};
+
+function setupHandlers(spools: unknown[] = SPOOLS) {
+  server.use(
+    http.get('/api/v1/settings/', () => HttpResponse.json(MOCK_SETTINGS)),
+    http.get('/api/v1/settings/spoolman', () =>
+      HttpResponse.json({
+        spoolman_enabled: 'false',
+        spoolman_url: '',
+        spoolman_sync_mode: 'auto',
+        spoolman_disable_weight_sync: 'false',
+        spoolman_report_partial_usage: 'true',
+      })
+    ),
+    http.get('/api/v1/inventory/spools', () => HttpResponse.json(spools)),
+    http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
+    // SpoolFormModal kicks off these fetches the moment it opens. Without
+    // handlers MSW would passthrough to the real network and ECONNREFUSED;
+    // those promises then resolve after the test environment is torn down,
+    // surfacing as an unhandled rejection in the modal's setState finally.
+    http.get('/api/v1/cloud/status', () =>
+      HttpResponse.json({ is_authenticated: false })
+    ),
+    http.get('/api/v1/cloud/local-presets', () =>
+      HttpResponse.json({ filament: [], printer: [], process: [] })
+    ),
+    http.get('/api/v1/local-presets/', () =>
+      HttpResponse.json({ filament: [], printer: [], process: [] })
+    ),
+    http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/colors', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/', () => HttpResponse.json([])),
+  );
+}
+
+async function openPickerFromToolbar() {
+  fireEvent.click(screen.getByRole('button', { name: /Print labels…/ }));
+  return screen.findByTestId('label-template-picker-panel');
+}
+
+describe('InventoryPage — Print labels button (#2980)', () => {
+  beforeEach(() => {
+    setupHandlers();
+  });
+
+  it('starts from the ticked spools', async () => {
+    render(<InventoryPageRouter />);
+    await waitFor(() => expect(screen.getAllByText('PLA').length).toBeGreaterThan(0));
+
+    const rows = screen.getAllByRole('checkbox', { name: 'Select row' });
+    fireEvent.click(rows[1]);
+
+    const panel = await openPickerFromToolbar();
+    expect(within(panel).getByText(/1 selected/)).toBeInTheDocument();
+  });
+
+  it('starts from every shown spool when none is ticked', async () => {
+    render(<InventoryPageRouter />);
+    await waitFor(() => expect(screen.getAllByText('PLA').length).toBeGreaterThan(0));
+
+    const panel = await openPickerFromToolbar();
+    expect(within(panel).getByText(/2 selected/)).toBeInTheDocument();
+  });
+});

+ 64 - 30
frontend/src/api/client.ts

@@ -127,6 +127,23 @@ function buildSlicerUrlFilename(filename: string): string {
   return safe.toLowerCase().endsWith('.3mf') ? safe : `${safe}.3mf`;
 }
 
+/** POST JSON and return the response body as a Blob (label PDFs and images). */
+async function postForBlob(endpoint: string, data: unknown, signal?: AbortSignal): Promise<Blob> {
+  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
+  if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
+  const response = await fetch(`${API_BASE}${endpoint}`, {
+    method: 'POST',
+    headers,
+    body: JSON.stringify(data),
+    signal,
+  });
+  if (!response.ok) {
+    const error = await response.json().catch(() => ({}));
+    throw new Error(typeof error.detail === 'string' ? error.detail : `HTTP ${response.status}`);
+  }
+  return response.blob();
+}
+
 async function request<T>(
   endpoint: string,
   options: RequestInit = {}
@@ -3606,11 +3623,48 @@ export type SpoolLabelTemplate =
   | 'avery_5160'
   | 'avery_l7160';
 
+// Mirror of backend.app.services.label_renderer.LabelField, in print order (#2981).
+export const SPOOL_LABEL_FIELDS = [
+  'brand',
+  'material',
+  'hex',
+  'name',
+  'location',
+  'material_number',
+  'temps',
+  'weight',
+  'note',
+  'added',
+  'qr',
+  'spool_id',
+] as const;
+export type SpoolLabelField = (typeof SPOOL_LABEL_FIELDS)[number];
+// Mirror of DEFAULT_LABEL_FIELDS: what a label carried before fields were selectable.
+export const DEFAULT_SPOOL_LABEL_FIELDS: SpoolLabelField[] = [
+  'brand',
+  'material',
+  'hex',
+  'name',
+  'location',
+  'qr',
+  'spool_id',
+];
+
 export interface PrintSpoolLabelsRequest {
   spool_ids: number[];
   template: SpoolLabelTemplate;
   monochrome: boolean;
   starting_position: number;
+  fields?: SpoolLabelField[];
+  format?: 'pdf' | 'png';
+  dpi?: 203 | 300 | 600;
+}
+
+export interface PreviewSpoolLabelRequest {
+  spool_id: number;
+  template: SpoolLabelTemplate;
+  monochrome: boolean;
+  fields: SpoolLabelField[];
 }
 
 export interface InventorySpool {
@@ -6708,36 +6762,16 @@ export const api = {
   unassignSpool: (printerId: number, amsId: number, trayId: number) =>
     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: PrintSpoolLabelsRequest): 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: PrintSpoolLabelsRequest): 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();
-  },
+  // The print endpoints return a PDF, a PNG, or a ZIP of PNGs (#2981); the
+  // preview endpoints one PNG. Callers get the Blob and decide what to do.
+  printSpoolLabels: (data: PrintSpoolLabelsRequest): Promise<Blob> =>
+    postForBlob('/inventory/labels', data),
+  printSpoolmanSpoolLabels: (data: PrintSpoolLabelsRequest): Promise<Blob> =>
+    postForBlob('/spoolman/labels', data),
+  previewSpoolLabel: (data: PreviewSpoolLabelRequest, signal?: AbortSignal): Promise<Blob> =>
+    postForBlob('/inventory/labels/preview', data, signal),
+  previewSpoolmanSpoolLabel: (data: PreviewSpoolLabelRequest, signal?: AbortSignal): Promise<Blob> =>
+    postForBlob('/spoolman/labels/preview', data, signal),
   getSpoolCatalog: () =>
     request<SpoolCatalogEntry[]>('/inventory/catalog'),
   addCatalogEntry: (data: { name: string; weight: number }) =>

+ 577 - 267
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -1,7 +1,14 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, 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 { X, Loader2, Printer, CheckSquare, Square, Search, Download } from 'lucide-react';
+import {
+  api,
+  DEFAULT_SPOOL_LABEL_FIELDS,
+  SPOOL_LABEL_FIELDS,
+  type SpoolLabelField,
+  type SpoolLabelTemplate,
+  type InventorySpool,
+} from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
@@ -77,7 +84,88 @@ const SHEET_CAPACITIES: Partial<Record<SpoolLabelTemplate, number>> = {
   avery_5160: 30,
 };
 
-const MAX_SHEET_CAPACITY = Math.max(...Object.values(SHEET_CAPACITIES));
+type OutputFormat = 'pdf' | 'png';
+type PngDpi = 203 | 300 | 600;
+const PNG_DPIS: PngDpi[] = [203, 300, 600];
+
+// What the checkboxes read, keyed by SpoolLabelField (#2981).
+const FIELD_LABELS: Record<SpoolLabelField, { i18nKey: string; fallback: string }> = {
+  brand: { i18nKey: 'brand', fallback: 'Brand' },
+  material: { i18nKey: 'material', fallback: 'Material and subtype' },
+  hex: { i18nKey: 'hex', fallback: 'Colour code' },
+  name: { i18nKey: 'name', fallback: 'Colour or filament name' },
+  location: { i18nKey: 'location', fallback: 'Storage location' },
+  material_number: { i18nKey: 'materialNumber', fallback: 'Material number' },
+  temps: { i18nKey: 'temps', fallback: 'Nozzle temperature' },
+  weight: { i18nKey: 'weight', fallback: 'Net weight' },
+  note: { i18nKey: 'note', fallback: 'Note' },
+  added: { i18nKey: 'added', fallback: 'Date added' },
+  qr: { i18nKey: 'qr', fallback: 'QR code' },
+  spool_id: { i18nKey: 'spoolId', fallback: 'Spool ID' },
+};
+
+// Remembered per browser. Printing only needs inventory access, and a server
+// setting would need settings access to save.
+const PREFS_STORAGE_KEY = 'bambuddy-label-print-options';
+
+interface LabelPrefs {
+  template: SpoolLabelTemplate;
+  /** Chosen lines per template: a 40 × 30 roll and an A4 sheet fit different amounts. */
+  fields: Partial<Record<SpoolLabelTemplate, SpoolLabelField[]>>;
+  monochrome: boolean;
+  format: OutputFormat;
+  dpi: PngDpi;
+}
+
+const DEFAULT_PREFS: LabelPrefs = {
+  template: TEMPLATE_OPTIONS[0].value,
+  fields: {},
+  monochrome: false,
+  format: 'pdf',
+  dpi: 300,
+};
+
+function loadPrefs(): LabelPrefs {
+  let raw: unknown;
+  try {
+    raw = JSON.parse(localStorage.getItem(PREFS_STORAGE_KEY) ?? 'null');
+  } catch {
+    return DEFAULT_PREFS;
+  }
+  if (!raw || typeof raw !== 'object') return DEFAULT_PREFS;
+  const r = raw as Partial<Record<keyof LabelPrefs, unknown>>;
+  const templates = TEMPLATE_OPTIONS.map((o) => o.value);
+  const fields: LabelPrefs['fields'] = {};
+  if (r.fields && typeof r.fields === 'object') {
+    for (const [tpl, list] of Object.entries(r.fields as Record<string, unknown>)) {
+      if (!templates.includes(tpl as SpoolLabelTemplate) || !Array.isArray(list)) continue;
+      fields[tpl as SpoolLabelTemplate] = list.filter((f): f is SpoolLabelField =>
+        (SPOOL_LABEL_FIELDS as readonly string[]).includes(f),
+      );
+    }
+  }
+  return {
+    template: templates.includes(r.template as SpoolLabelTemplate)
+      ? (r.template as SpoolLabelTemplate)
+      : DEFAULT_PREFS.template,
+    fields,
+    monochrome: r.monochrome === true,
+    format: r.format === 'png' ? 'png' : 'pdf',
+    dpi: PNG_DPIS.includes(r.dpi as PngDpi) ? (r.dpi as PngDpi) : DEFAULT_PREFS.dpi,
+  };
+}
+
+function savePrefs(prefs: LabelPrefs): void {
+  try {
+    localStorage.setItem(PREFS_STORAGE_KEY, JSON.stringify(prefs));
+  } catch {
+    // Private mode or storage disabled: the options just aren't remembered.
+  }
+}
+
+function fieldsFor(prefs: LabelPrefs, template: SpoolLabelTemplate): SpoolLabelField[] {
+  return prefs.fields[template] ?? DEFAULT_SPOOL_LABEL_FIELDS;
+}
 
 function openBlobInNewTab(blob: Blob): void {
   const url = window.URL.createObjectURL(blob);
@@ -102,6 +190,18 @@ function openBlobInNewTab(blob: Blob): void {
   setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
 }
 
+/** Save a PNG, or a ZIP of PNGs when there were several pages. */
+function downloadBlob(blob: Blob, template: SpoolLabelTemplate): void {
+  const url = window.URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = `bambuddy-labels-${template}.${blob.type === 'application/zip' ? 'zip' : 'png'}`;
+  document.body.appendChild(a);
+  a.click();
+  document.body.removeChild(a);
+  setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
+}
+
 // Thin wrapper over `getSwatchStyle` from utils/colors so the modal's render
 // sites keep their existing call shape. Transparent (alpha=00) spools now
 // render as a checkerboard pattern instead of collapsing to solid black
@@ -187,17 +287,24 @@ export function LabelTemplatePickerModal({
   // memo has to recompute when the catalog finishes loading (#3090).
   const colorCatalogVersion = useColorCatalogVersion();
   const { showToast } = useToast();
-  const [pending, setPending] = useState<SpoolLabelTemplate | null>(null);
+  const [pending, setPending] = useState(false);
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
   const [search, setSearch] = useState('');
   const [materialFilter, setMaterialFilter] = useState<string>('');
   const [sortMode, setSortMode] = useState<SortMode>('id');
-  const [monochrome, setMonochrome] = useState(false);
+  const [prefs, setPrefs] = useState<LabelPrefs>(DEFAULT_PREFS);
   const [startingPositionInput, setStartingPositionInput] = useState('1');
+  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
+  const [previewLoading, setPreviewLoading] = useState(false);
+  const [previewError, setPreviewError] = useState<string | null>(null);
+  // The object URL on screen. Freed when its replacement arrives, not when the
+  // options change, so the old label stays visible while the new one renders.
+  const previewUrlRef = useRef<string | null>(null);
 
   // 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.
+  // a parent re-render to clobber their selection / filter / search. The
+  // label options come back as the user last left them.
   useEffect(() => {
     if (isOpen) {
       const allowed = new Set(availableSpools.map((s) => s.id));
@@ -205,9 +312,9 @@ export function LabelTemplatePickerModal({
       setSearch('');
       setMaterialFilter('');
       setSortMode('id');
-      setMonochrome(false);
+      setPrefs(loadPrefs());
       setStartingPositionInput('1');
-      setPending(null);
+      setPending(false);
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [isOpen]);
@@ -257,15 +364,93 @@ export function LabelTemplatePickerModal({
   const allVisibleChecked =
     visibleSpools.length > 0 && visibleSpools.every((s) => selectedIds.has(s.id));
 
+  const template = prefs.template;
+  const fields = fieldsFor(prefs, template);
+  const fieldsKey = fields.join(',');
+  // The preview shows the first spool that will print, in print order.
+  const previewSpoolId = sortedSpools.find((s) => selectedIds.has(s.id))?.id ?? null;
+
+  function showPreview(url: string | null) {
+    if (previewUrlRef.current) window.URL.revokeObjectURL(previewUrlRef.current);
+    previewUrlRef.current = url;
+    setPreviewUrl(url);
+  }
+
+  // Re-render the preview a moment after the last change, dropping any
+  // request still in flight so a slow answer can't overwrite a newer one.
+  useEffect(() => {
+    if (!isOpen || previewSpoolId === null) {
+      showPreview(null);
+      setPreviewError(null);
+      setPreviewLoading(false);
+      return;
+    }
+    const controller = new AbortController();
+    setPreviewLoading(true);
+    const timer = window.setTimeout(async () => {
+      const request = {
+        spool_id: previewSpoolId,
+        template,
+        monochrome: prefs.monochrome,
+        fields: fieldsKey ? (fieldsKey.split(',') as SpoolLabelField[]) : [],
+      };
+      try {
+        const blob = spoolmanMode
+          ? await api.previewSpoolmanSpoolLabel(request, controller.signal)
+          : await api.previewSpoolLabel(request, controller.signal);
+        if (controller.signal.aborted) return;
+        showPreview(window.URL.createObjectURL(blob));
+        setPreviewError(null);
+      } catch (err) {
+        if (controller.signal.aborted) return;
+        showPreview(null);
+        setPreviewError(err instanceof Error ? err.message : String(err));
+      } finally {
+        if (!controller.signal.aborted) setPreviewLoading(false);
+      }
+    }, 250);
+    return () => {
+      window.clearTimeout(timer);
+      controller.abort();
+    };
+  }, [isOpen, previewSpoolId, template, fieldsKey, prefs.monochrome, spoolmanMode]);
+
+  // Free the last preview when the modal unmounts.
+  useEffect(
+    () => () => {
+      if (previewUrlRef.current) window.URL.revokeObjectURL(previewUrlRef.current);
+    },
+    [],
+  );
+
   if (!isOpen) return null;
 
   const selectedCount = selectedIds.size;
   const noSelection = selectedCount === 0;
+  const sheetCapacity = SHEET_CAPACITIES[template];
   const startingPosition = Number(startingPositionInput);
   const startingPositionIsValid =
-    Number.isInteger(startingPosition) &&
-    startingPosition >= 1 &&
-    startingPosition <= MAX_SHEET_CAPACITY;
+    sheetCapacity === undefined ||
+    (Number.isInteger(startingPosition) && startingPosition >= 1 && startingPosition <= sheetCapacity);
+  const templateOption = TEMPLATE_OPTIONS.find((o) => o.value === template) ?? TEMPLATE_OPTIONS[0];
+
+  function updatePrefs(change: Partial<LabelPrefs>) {
+    setPrefs((prev) => {
+      const next = { ...prev, ...change };
+      savePrefs(next);
+      return next;
+    });
+  }
+
+  function setFields(next: SpoolLabelField[]) {
+    // Stored in print order, so the saved list reads like the label.
+    const ordered = SPOOL_LABEL_FIELDS.filter((f) => next.includes(f));
+    updatePrefs({ fields: { ...prefs.fields, [template]: ordered } });
+  }
+
+  function toggleField(field: SpoolLabelField) {
+    setFields(fields.includes(field) ? fields.filter((f) => f !== field) : [...fields, field]);
+  }
 
   function toggleOne(id: number) {
     setSelectedIds((prev) => {
@@ -296,43 +481,28 @@ export function LabelTemplatePickerModal({
     setSelectedIds(new Set());
   }
 
-  async function handlePick(template: SpoolLabelTemplate) {
-    if (noSelection || pending) return;
-    const sheetCapacity = SHEET_CAPACITIES[template];
-    if (
-      sheetCapacity !== undefined &&
-      (!startingPositionIsValid || startingPosition > sheetCapacity)
-    ) {
-      showToast(
-        t(
-          'inventory.labels.startingPositionRangeError',
-          'Starting position must be between 1 and {{capacity}} for this sheet.',
-          { capacity: sheetCapacity },
-        ),
-        'error',
-      );
-      return;
-    }
+  async function handlePrint() {
+    if (noSelection || pending || !startingPositionIsValid) return;
     // Order matters: the backend (labels.py) prints labels in the same order
     // we send IDs. Use the sorted list so a "by colour" sort flows through to
-    // the PDF instead of being clobbered by an ascending-ID re-sort.
+    // the output instead of being clobbered by an ascending-ID re-sort.
     const ids = sortedSpools.filter((s) => selectedIds.has(s.id)).map((s) => s.id);
-    setPending(template);
+    const request = {
+      spool_ids: ids,
+      template,
+      monochrome: prefs.monochrome,
+      starting_position: sheetCapacity === undefined ? 1 : startingPosition,
+      fields,
+      format: prefs.format,
+      dpi: prefs.dpi,
+    };
+    setPending(true);
     try {
       const blob = spoolmanMode
-        ? await api.printSpoolmanSpoolLabels({
-            spool_ids: ids,
-            template,
-            monochrome,
-            starting_position: sheetCapacity === undefined ? 1 : startingPosition,
-          })
-        : await api.printSpoolLabels({
-            spool_ids: ids,
-            template,
-            monochrome,
-            starting_position: sheetCapacity === undefined ? 1 : startingPosition,
-          });
-      openBlobInNewTab(blob);
+        ? await api.printSpoolmanSpoolLabels(request)
+        : await api.printSpoolLabels(request);
+      if (prefs.format === 'pdf') openBlobInNewTab(blob);
+      else downloadBlob(blob, template);
       onClose();
     } catch (err) {
       const msg = err instanceof Error ? err.message : String(err);
@@ -341,10 +511,17 @@ export function LabelTemplatePickerModal({
         'error',
       );
     } finally {
-      setPending(null);
+      setPending(false);
     }
   }
 
+  const chipClass = (active: boolean) =>
+    `px-2 py-0.5 text-xs rounded-full border transition ${
+      active
+        ? 'bg-bambu-green text-bambu-dark border-bambu-green'
+        : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
+    }`;
+
   return (
     <div className="fixed inset-0 z-50 flex items-start sm:items-center justify-center p-4 overflow-y-auto">
       <div
@@ -354,7 +531,7 @@ export function LabelTemplatePickerModal({
 
       <div
         data-testid="label-template-picker-panel"
-        className="relative w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-clip flex flex-col my-auto"
+        className="relative w-full max-w-5xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-clip flex flex-col my-auto"
       >
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
@@ -378,123 +555,240 @@ export function LabelTemplatePickerModal({
           </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) => (
+        {/* Two columns from md up: which spools on the left, what the label
+            looks like on the right. Stacked and scrolled as one on phones. */}
+        <div className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden grid grid-cols-1 md:grid-cols-2">
+          <div className="flex flex-col min-h-0 md:border-r border-bambu-dark-tertiary">
+            {/* 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 className="flex flex-wrap items-center gap-1.5">
+                <span className="text-xs text-bambu-gray mr-1">
+                  {t('inventory.labels.sortBy.label')}
+                </span>
+                <button
+                  type="button"
+                  onClick={() => setSortMode('id')}
+                  className={`px-2 py-0.5 text-xs rounded-full border transition ${
+                    sortMode === 'id'
+                      ? '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.sortBy.id')}
+                </button>
                 <button
-                  key={m}
                   type="button"
-                  onClick={() => setMaterialFilter(m)}
+                  onClick={() => setSortMode('color')}
                   className={`px-2 py-0.5 text-xs rounded-full border transition ${
-                    materialFilter === m
+                    sortMode === 'color'
                       ? 'bg-bambu-green text-bambu-dark border-bambu-green'
                       : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
                   }`}
                 >
-                  {m}
+                  {t('inventory.labels.sortBy.color')}
                 </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>
-          )}
-          <div className="flex flex-wrap items-center gap-1.5">
-            <span className="text-xs text-bambu-gray mr-1">
-              {t('inventory.labels.sortBy.label')}
-            </span>
-            <button
-              type="button"
-              onClick={() => setSortMode('id')}
-              className={`px-2 py-0.5 text-xs rounded-full border transition ${
-                sortMode === 'id'
-                  ? '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.sortBy.id')}
-            </button>
-            <button
-              type="button"
-              onClick={() => setSortMode('color')}
-              className={`px-2 py-0.5 text-xs rounded-full border transition ${
-                sortMode === 'color'
-                  ? '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.sortBy.color')}
-            </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,
+            {/* Spool list */}
+            <div className="flex-1 overflow-y-auto px-2 pb-2 min-h-0 max-h-72 md:max-h-none">
+              {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>
+                    );
                   })}
-            </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>
+                </ul>
+              )}
+            </div>
+
           </div>
-        </div>
 
-        {/* Spool list */}
-        <div className="flex-1 overflow-y-auto px-2 pb-2 min-h-0">
-          {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 className="md:overflow-y-auto p-4 space-y-4 border-t md:border-t-0 border-bambu-dark-tertiary">
+            {/* Label size */}
+            <div className="space-y-1">
+              <label htmlFor="label-template" className="text-sm font-medium text-white">
+                {t('inventory.labels.template', 'Label size')}
+              </label>
+              <select
+                id="label-template"
+                data-testid="label-template-select"
+                value={template}
+                onChange={(e) => updatePrefs({ template: e.target.value as SpoolLabelTemplate })}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+              >
+                {TEMPLATE_OPTIONS.map((opt) => (
+                  <option key={opt.value} value={opt.value}>
+                    {t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel)}
+                  </option>
+                ))}
+              </select>
+              <p className="text-xs text-bambu-gray">
+                {t(`inventory.labels.templates.${templateOption.i18nKey}.hint`, templateOption.fallbackHint)}
+              </p>
+            </div>
+
+            {/* Preview */}
+            <div className="space-y-1">
+              <div className="text-sm font-medium text-white">
+                {previewSpoolId === null
+                  ? t('inventory.labels.preview', 'Preview')
+                  : t('inventory.labels.previewOf', 'Preview of #{{id}}', { id: previewSpoolId })}
+              </div>
+              <div
+                data-testid="label-preview"
+                className="relative flex items-center justify-center min-h-40 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary"
+              >
+                {previewSpoolId === null ? (
+                  <span className="text-sm text-bambu-gray text-center">
+                    {t('inventory.labels.previewNoSelection', 'Select a spool to see its label.')}
+                  </span>
+                ) : previewError ? (
+                  <span className="text-sm text-red-400 text-center">
+                    {t('inventory.labels.previewError', 'Could not render the preview: {{msg}}', { msg: previewError })}
+                  </span>
+                ) : previewUrl ? (
+                  <img
+                    src={previewUrl}
+                    alt={t('inventory.labels.previewAlt', 'Label preview')}
+                    className="max-h-56 max-w-full object-contain bg-white shadow"
+                  />
+                ) : null}
+                {previewLoading && (
+                  <Loader2 className="absolute top-2 right-2 w-4 h-4 animate-spin text-bambu-green" />
+                )}
+              </div>
+              <p className="text-xs text-bambu-gray">
+                {t('inventory.labels.previewHint', 'Lines that don\'t fit on this label size are left out.')}
+              </p>
             </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">
+
+            {/* Label content */}
+            <fieldset className="space-y-2">
+              <div className="flex items-center justify-between gap-2">
+                <legend className="text-sm font-medium text-white">
+                  {t('inventory.labels.content', 'Print on the label')}
+                </legend>
+                <button
+                  type="button"
+                  onClick={() => setFields(DEFAULT_SPOOL_LABEL_FIELDS)}
+                  className="text-xs text-bambu-green hover:underline"
+                >
+                  {t('inventory.labels.resetFields', 'Reset to default')}
+                </button>
+              </div>
+              <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-1">
+                {SPOOL_LABEL_FIELDS.map((field) => {
+                  const checked = fields.includes(field);
+                  const { i18nKey, fallback } = FIELD_LABELS[field];
+                  return (
+                    <label
+                      key={field}
+                      className="flex items-center gap-2 py-0.5 cursor-pointer select-none"
+                    >
                       {checked ? (
                         <CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
                       ) : (
@@ -502,138 +796,154 @@ export function LabelTemplatePickerModal({
                       )}
                       <input
                         type="checkbox"
+                        data-testid={`label-field-${field}`}
                         checked={checked}
-                        onChange={() => toggleOne(s.id)}
+                        onChange={() => toggleField(field)}
                         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 className="text-sm text-white">
+                        {t(`inventory.labels.fields.${i18nKey}`, fallback)}
                       </span>
                     </label>
-                  </li>
-                );
-              })}
-            </ul>
-          )}
-        </div>
+                  );
+                })}
+              </div>
+            </fieldset>
+
+            {/* Print options */}
+            <div className="space-y-3">
+              <label className="inline-flex items-center gap-2 cursor-pointer select-none">
+                {prefs.monochrome ? (
+                  <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={prefs.monochrome}
+                  onChange={(e) => updatePrefs({ monochrome: e.target.checked })}
+                  className="sr-only"
+                />
+                <span className="text-sm text-white">
+                  {t('inventory.labels.monochrome', 'Monochrome (black & white printer)')}
+                </span>
+                <span className="text-xs text-bambu-gray">
+                  {t('inventory.labels.monochromeHint', 'Drops the colour swatch and widens the text')}
+                </span>
+              </label>
+
+              {sheetCapacity !== undefined && (
+                <div className="flex items-start gap-3">
+                  <label
+                    htmlFor="label-starting-position"
+                    className="text-sm text-white whitespace-nowrap pt-1.5"
+                  >
+                    {t('inventory.labels.startingPosition', 'Starting label position')}
+                  </label>
+                  <input
+                    id="label-starting-position"
+                    data-testid="label-starting-position"
+                    type="number"
+                    min={1}
+                    max={sheetCapacity}
+                    step={1}
+                    value={startingPositionInput}
+                    onChange={(event) => setStartingPositionInput(event.target.value)}
+                    aria-describedby="label-starting-position-help"
+                    className="w-20 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
+                  />
+                  <div
+                    id="label-starting-position-help"
+                    data-testid="label-starting-position-status"
+                    className={`text-xs pt-1.5 ${startingPositionIsValid ? 'text-bambu-gray' : 'text-red-400'}`}
+                  >
+                    {!startingPositionIsValid
+                      ? t(
+                          'inventory.labels.startingPositionInvalid',
+                          'Enter a whole number from 1 to {{capacity}}.',
+                          { capacity: sheetCapacity },
+                        )
+                      : startingPosition === 1
+                        ? t('inventory.labels.startingPositionFirst', 'Printing starts at position 1.')
+                        : t(
+                            'inventory.labels.startingPositionSkipped',
+                            'Positions 1 through {{lastPosition}} will be left blank on the first sheet.',
+                            { lastPosition: startingPosition - 1 },
+                          )}
+                  </div>
+                </div>
+              )}
 
-        {/* Print options */}
-        <div className="px-4 pt-2 pb-1 border-t border-bambu-dark-tertiary space-y-2">
-          <label className="inline-flex items-center gap-2 cursor-pointer select-none">
-            {monochrome ? (
-              <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={monochrome}
-              onChange={(e) => setMonochrome(e.target.checked)}
-              className="sr-only"
-            />
-            <span className="text-sm text-white">
-              {t('inventory.labels.monochrome', 'Monochrome (black & white printer)')}
-            </span>
-            <span className="text-xs text-bambu-gray">
-              {t('inventory.labels.monochromeHint', 'Drops the colour swatch and widens the text')}
-            </span>
-          </label>
-          <div className="flex items-start gap-3">
-            <label
-              htmlFor="label-starting-position"
-              className="text-sm text-white whitespace-nowrap pt-1.5"
-            >
-              {t('inventory.labels.startingPosition', 'Starting label position')}
-            </label>
-            <input
-              id="label-starting-position"
-              data-testid="label-starting-position"
-              type="number"
-              min={1}
-              max={MAX_SHEET_CAPACITY}
-              step={1}
-              value={startingPositionInput}
-              onChange={(event) => setStartingPositionInput(event.target.value)}
-              aria-describedby="label-starting-position-help"
-              className="w-20 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
-            />
-            <div id="label-starting-position-help" className="text-xs text-bambu-gray pt-1.5">
-              <div>
-                {t(
-                  'inventory.labels.startingPositionRange',
-                  'Sheet templates only: L7160 supports 1–21; 5160 supports 1–30.',
+              <div className="flex flex-wrap items-center gap-1.5">
+                <span className="text-sm text-white mr-1">
+                  {t('inventory.labels.format', 'Output:')}
+                </span>
+                <button
+                  type="button"
+                  data-testid="label-format-pdf"
+                  aria-pressed={prefs.format === 'pdf'}
+                  onClick={() => updatePrefs({ format: 'pdf' })}
+                  className={chipClass(prefs.format === 'pdf')}
+                >
+                  PDF
+                </button>
+                <button
+                  type="button"
+                  data-testid="label-format-png"
+                  aria-pressed={prefs.format === 'png'}
+                  onClick={() => updatePrefs({ format: 'png' })}
+                  className={chipClass(prefs.format === 'png')}
+                >
+                  PNG
+                </button>
+                {prefs.format === 'png' && (
+                  <select
+                    data-testid="label-dpi-select"
+                    aria-label={t('inventory.labels.dpi', 'Resolution')}
+                    value={prefs.dpi}
+                    onChange={(e) => updatePrefs({ dpi: Number(e.target.value) as PngDpi })}
+                    className="ml-2 px-2 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs focus:outline-none focus:border-bambu-green"
+                  >
+                    {PNG_DPIS.map((dpi) => (
+                      <option key={dpi} value={dpi}>
+                        {dpi} dpi
+                      </option>
+                    ))}
+                  </select>
                 )}
               </div>
-              <div
-                data-testid="label-starting-position-status"
-                className={startingPositionIsValid ? '' : 'text-red-400'}
-              >
-                {!startingPositionIsValid
-                  ? t(
-                      'inventory.labels.startingPositionInvalid',
-                      'Enter a whole number from 1 to {{capacity}}.',
-                      { capacity: MAX_SHEET_CAPACITY },
-                    )
-                  : startingPosition === 1
-                    ? t('inventory.labels.startingPositionFirst', 'Printing starts at position 1.')
-                    : t(
-                        'inventory.labels.startingPositionSkipped',
-                        'Positions 1 through {{lastPosition}} will be left blank on the first sheet.',
-                        { lastPosition: startingPosition - 1 },
-                      )}
-              </div>
+              {prefs.format === 'png' && (
+                <p className="text-xs text-bambu-gray">
+                  {t(
+                    'inventory.labels.pngHint',
+                    'For label printer software that takes images. Match your printer\'s resolution; several labels come as a ZIP.',
+                  )}
+                </p>
+              )}
             </div>
           </div>
         </div>
 
-        {/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit
-            inside max-h-[90vh] even when browser chrome eats into the viewport
-            (#1230). Stacked single column on mobile widths. */}
-        <div className="px-3 pt-1 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
-          {TEMPLATE_OPTIONS.map((opt) => {
-            const isPending = pending === opt.value;
-            const sheetCapacity = SHEET_CAPACITIES[opt.value];
-            const startingPositionExceedsSheet =
-              sheetCapacity !== undefined &&
-              (!startingPositionIsValid || startingPosition > sheetCapacity);
-            const label = t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel);
-            const hint = startingPositionExceedsSheet
-              ? t(
-                  'inventory.labels.startingPositionRangeError',
-                  'Starting position must be between 1 and {{capacity}} for this sheet.',
-                  { capacity: sheetCapacity },
-                )
-              : t(`inventory.labels.templates.${opt.i18nKey}.hint`, opt.fallbackHint);
-            return (
-              <button
-                key={opt.value}
-                data-testid={`print-labels-${opt.value}`}
-                disabled={noSelection || pending !== null || startingPositionExceedsSheet}
-                onClick={() => handlePick(opt.value)}
-                title={`${label} — ${hint}`}
-                className="w-full text-left p-2.5 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 text-sm truncate">{label}</div>
-                  <div className="text-xs text-bambu-gray mt-0.5 truncate">{hint}</div>
-                </div>
-                {isPending && <Loader2 className="w-4 h-4 animate-spin text-bambu-green shrink-0" />}
-              </button>
-            );
-          })}
-        </div>
-
-        <div className="flex justify-end gap-2 px-5 py-2 border-t border-bambu-dark-tertiary">
-          <Button variant="secondary" onClick={onClose} disabled={pending !== null}>
+        <div className="flex justify-end gap-2 px-5 py-3 border-t border-bambu-dark-tertiary">
+          <Button variant="secondary" onClick={onClose} disabled={pending}>
             {t('common.cancel', 'Cancel')}
           </Button>
+          <Button
+            data-testid="label-print-button"
+            onClick={handlePrint}
+            disabled={noSelection || pending || !startingPositionIsValid}
+          >
+            {pending ? (
+              <Loader2 className="w-4 h-4 animate-spin" />
+            ) : prefs.format === 'pdf' ? (
+              <Printer className="w-4 h-4" />
+            ) : (
+              <Download className="w-4 h-4" />
+            )}
+            {prefs.format === 'pdf'
+              ? t('inventory.labels.createPdf', 'Create PDF')
+              : t('inventory.labels.downloadPng', 'Download PNG')}
+          </Button>
         </div>
       </div>
     </div>

+ 29 - 2
frontend/src/i18n/locales/de.ts

@@ -4729,11 +4729,9 @@ export default {
       monochrome: 'Monochrom (Schwarz-Weiß-Drucker)',
       monochromeHint: 'Entfernt das Farbfeld und verbreitert den Text',
       startingPosition: 'Startposition des Etiketts',
-      startingPositionRange: 'Nur Bogenvorlagen: L7160 unterstützt 1–21; 5160 unterstützt 1–30.',
       startingPositionInvalid: 'Gib eine ganze Zahl zwischen 1 und {{capacity}} ein.',
       startingPositionFirst: 'Der Druck beginnt bei Position 1.',
       startingPositionSkipped: 'Die Positionen 1 bis {{lastPosition}} bleiben auf dem ersten Bogen leer.',
-      startingPositionRangeError: 'Die Startposition muss für diesen Bogen zwischen 1 und {{capacity}} liegen.',
       searchPlaceholder: 'Name, Marke oder #ID suchen',
       filterByMaterial: 'Material:',
       allMaterials: 'Alle',
@@ -4747,6 +4745,35 @@ export default {
       bulkTitle: 'Spulen aus den aktuell angezeigten {{count}} zum Etikettieren auswählen',
       noSpoolsTitle: 'Keine Spulen zum Etikettieren',
       error: 'Etiketten konnten nicht erstellt werden: {{msg}}',
+      template: 'Etikettengröße',
+      preview: 'Vorschau',
+      previewOf: 'Vorschau von #{{id}}',
+      previewNoSelection: 'Wähle eine Spule, um ihr Etikett zu sehen.',
+      previewError: 'Vorschau konnte nicht erstellt werden: {{msg}}',
+      previewAlt: 'Etikettenvorschau',
+      previewHint: 'Zeilen, die auf diese Etikettengröße nicht passen, werden weggelassen.',
+      content: 'Auf das Etikett drucken',
+      resetFields: 'Auf Standard zurücksetzen',
+      format: 'Ausgabe:',
+      dpi: 'Auflösung',
+      pngHint: 'Für Etikettendrucker-Software, die Bilder verarbeitet. Wähle die Auflösung deines Druckers; mehrere Etiketten kommen als ZIP.',
+      createPdf: 'PDF erstellen',
+      downloadPng: 'PNG herunterladen',
+      selectedTitle: 'Etiketten für die {{count}} ausgewählten Spulen drucken',
+      fields: {
+        brand: 'Marke',
+        material: 'Material und Untertyp',
+        hex: 'Farbcode',
+        name: 'Farb- oder Filamentname',
+        location: 'Lagerort',
+        materialNumber: 'Materialnummer',
+        temps: 'Düsentemperatur',
+        weight: 'Nettogewicht',
+        note: 'Notiz',
+        added: 'Hinzugefügt am',
+        qr: 'QR-Code',
+        spoolId: 'Spulen-ID',
+      },
       sortBy: {
         label: 'Sortieren:',
         id: 'Nach ID',

+ 29 - 2
frontend/src/i18n/locales/en.ts

@@ -4765,11 +4765,9 @@ export default {
       monochrome: 'Monochrome (black & white printer)',
       monochromeHint: 'Drops the colour swatch and widens the text',
       startingPosition: 'Starting label position',
-      startingPositionRange: 'Sheet templates only: L7160 supports 1–21; 5160 supports 1–30.',
       startingPositionInvalid: 'Enter a whole number from 1 to {{capacity}}.',
       startingPositionFirst: 'Printing starts at position 1.',
       startingPositionSkipped: 'Positions 1 through {{lastPosition}} will be left blank on the first sheet.',
-      startingPositionRangeError: 'Starting position must be between 1 and {{capacity}} for this sheet.',
       searchPlaceholder: 'Search name, brand, or #ID',
       filterByMaterial: 'Material:',
       allMaterials: 'All',
@@ -4783,6 +4781,35 @@ export default {
       bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
       noSpoolsTitle: 'No spools to label',
       error: 'Could not generate labels: {{msg}}',
+      template: 'Label size',
+      preview: 'Preview',
+      previewOf: 'Preview of #{{id}}',
+      previewNoSelection: 'Select a spool to see its label.',
+      previewError: 'Could not render the preview: {{msg}}',
+      previewAlt: 'Label preview',
+      previewHint: 'Lines that don\'t fit on this label size are left out.',
+      content: 'Print on the label',
+      resetFields: 'Reset to default',
+      format: 'Output:',
+      dpi: 'Resolution',
+      pngHint: 'For label printer software that takes images. Match your printer\'s resolution; several labels come as a ZIP.',
+      createPdf: 'Create PDF',
+      downloadPng: 'Download PNG',
+      selectedTitle: 'Print labels for the {{count}} selected spools',
+      fields: {
+        brand: 'Brand',
+        material: 'Material and subtype',
+        hex: 'Colour code',
+        name: 'Colour or filament name',
+        location: 'Storage location',
+        materialNumber: 'Material number',
+        temps: 'Nozzle temperature',
+        weight: 'Net weight',
+        note: 'Note',
+        added: 'Date added',
+        qr: 'QR code',
+        spoolId: 'Spool ID',
+      },
       sortBy: {
         label: 'Sort:',
         id: 'By ID',

+ 29 - 2
frontend/src/i18n/locales/es.ts

@@ -4731,11 +4731,9 @@ export default {
       monochrome: 'Monocromo (impresora en blanco y negro)',
       monochromeHint: 'Elimina la muestra de color y amplía el texto',
       startingPosition: 'Posición inicial de la etiqueta',
-      startingPositionRange: 'Solo plantillas de hoja: L7160 admite 1–21; 5160 admite 1–30.',
       startingPositionInvalid: 'Introduce un número entero del 1 al {{capacity}}.',
       startingPositionFirst: 'La impresión comienza en la posición 1.',
       startingPositionSkipped: 'Las posiciones 1 a {{lastPosition}} quedarán vacías en la primera hoja.',
-      startingPositionRangeError: 'La posición inicial debe estar entre 1 y {{capacity}} para esta hoja.',
       searchPlaceholder: 'Buscar nombre, marca o n.º de ID',
       filterByMaterial: 'Material:',
       allMaterials: 'Todos',
@@ -4749,6 +4747,35 @@ export default {
       bulkTitle: 'Elija las bobinas para las que imprimir etiquetas de las {{count}} mostradas actualmente',
       noSpoolsTitle: 'No hay bobinas que etiquetar',
       error: 'No se pudieron generar las etiquetas: {{msg}}',
+      template: 'Tamaño de etiqueta',
+      preview: 'Vista previa',
+      previewOf: 'Vista previa de #{{id}}',
+      previewNoSelection: 'Selecciona una bobina para ver su etiqueta.',
+      previewError: 'No se pudo generar la vista previa: {{msg}}',
+      previewAlt: 'Vista previa de la etiqueta',
+      previewHint: 'Las líneas que no caben en este tamaño de etiqueta se omiten.',
+      content: 'Imprimir en la etiqueta',
+      resetFields: 'Restablecer valores predeterminados',
+      format: 'Salida:',
+      dpi: 'Resolución',
+      pngHint: 'Para software de impresoras de etiquetas que usa imágenes. Elige la resolución de tu impresora; varias etiquetas se descargan como ZIP.',
+      createPdf: 'Crear PDF',
+      downloadPng: 'Descargar PNG',
+      selectedTitle: 'Imprimir etiquetas para las {{count}} bobinas seleccionadas',
+      fields: {
+        brand: 'Marca',
+        material: 'Material y subtipo',
+        hex: 'Código de color',
+        name: 'Nombre del color o filamento',
+        location: 'Ubicación de almacenamiento',
+        materialNumber: 'Número de material',
+        temps: 'Temperatura de la boquilla',
+        weight: 'Peso neto',
+        note: 'Nota',
+        added: 'Fecha de alta',
+        qr: 'Código QR',
+        spoolId: 'ID de bobina',
+      },
       sortBy: {
         label: 'Ordenar:',
         id: 'Por ID',

+ 29 - 2
frontend/src/i18n/locales/fr.ts

@@ -4717,11 +4717,9 @@ export default {
       monochrome: 'Monochrome (imprimante noir et blanc)',
       monochromeHint: 'Supprime la pastille de couleur et élargit le texte',
       startingPosition: 'Position de départ de l’étiquette',
-      startingPositionRange: 'Modèles en feuille uniquement : L7160 accepte 1–21 ; 5160 accepte 1–30.',
       startingPositionInvalid: 'Saisissez un nombre entier de 1 à {{capacity}}.',
       startingPositionFirst: 'L’impression commence à la position 1.',
       startingPositionSkipped: 'Les positions 1 à {{lastPosition}} resteront vides sur la première feuille.',
-      startingPositionRangeError: 'La position de départ doit être comprise entre 1 et {{capacity}} pour cette feuille.',
       searchPlaceholder: 'Rechercher par nom, marque ou #ID',
       filterByMaterial: 'Matériau :',
       allMaterials: 'Tout',
@@ -4735,6 +4733,35 @@ export default {
       bulkTitle: 'Choisissez les bobines à étiqueter parmi les {{count}} affichées',
       noSpoolsTitle: 'Aucune bobine à étiqueter',
       error: 'Impossible de générer les étiquettes : {{msg}}',
+      template: 'Taille d\'étiquette',
+      preview: 'Aperçu',
+      previewOf: 'Aperçu de #{{id}}',
+      previewNoSelection: 'Sélectionnez une bobine pour voir son étiquette.',
+      previewError: 'Impossible de générer l\'aperçu : {{msg}}',
+      previewAlt: 'Aperçu de l\'étiquette',
+      previewHint: 'Les lignes qui ne tiennent pas sur cette taille d\'étiquette sont omises.',
+      content: 'Imprimer sur l\'étiquette',
+      resetFields: 'Rétablir les valeurs par défaut',
+      format: 'Sortie :',
+      dpi: 'Résolution',
+      pngHint: 'Pour les logiciels d\'imprimante d\'étiquettes qui utilisent des images. Choisissez la résolution de votre imprimante ; plusieurs étiquettes sont fournies dans un ZIP.',
+      createPdf: 'Créer le PDF',
+      downloadPng: 'Télécharger le PNG',
+      selectedTitle: 'Imprimer les étiquettes des {{count}} bobines sélectionnées',
+      fields: {
+        brand: 'Marque',
+        material: 'Matériau et sous-type',
+        hex: 'Code couleur',
+        name: 'Nom de la couleur ou du filament',
+        location: 'Emplacement de stockage',
+        materialNumber: 'Numéro de matériau',
+        temps: 'Température de la buse',
+        weight: 'Poids net',
+        note: 'Remarque',
+        added: 'Date d\'ajout',
+        qr: 'Code QR',
+        spoolId: 'ID de la bobine',
+      },
       sortBy: {
         label: 'Trier :',
         id: 'Par ID',

+ 29 - 2
frontend/src/i18n/locales/it.ts

@@ -4716,11 +4716,9 @@ export default {
       monochrome: 'Monocromatico (stampante in bianco e nero)',
       monochromeHint: 'Rimuove il campione di colore e allarga il testo',
       startingPosition: 'Posizione iniziale dell’etichetta',
-      startingPositionRange: 'Solo modelli a foglio: L7160 supporta 1–21; 5160 supporta 1–30.',
       startingPositionInvalid: 'Inserisci un numero intero da 1 a {{capacity}}.',
       startingPositionFirst: 'La stampa inizia dalla posizione 1.',
       startingPositionSkipped: 'Le posizioni da 1 a {{lastPosition}} resteranno vuote sul primo foglio.',
-      startingPositionRangeError: 'La posizione iniziale deve essere compresa tra 1 e {{capacity}} per questo foglio.',
       searchPlaceholder: 'Cerca per nome, marca o #ID',
       filterByMaterial: 'Materiale:',
       allMaterials: 'Tutto',
@@ -4734,6 +4732,35 @@ export default {
       bulkTitle: 'Scegli le bobine da etichettare tra le {{count}} mostrate',
       noSpoolsTitle: 'Nessuna bobina da etichettare',
       error: 'Impossibile generare etichette: {{msg}}',
+      template: 'Dimensione etichetta',
+      preview: 'Anteprima',
+      previewOf: 'Anteprima di #{{id}}',
+      previewNoSelection: 'Seleziona una bobina per vederne l\'etichetta.',
+      previewError: 'Impossibile generare l\'anteprima: {{msg}}',
+      previewAlt: 'Anteprima etichetta',
+      previewHint: 'Le righe che non entrano in questa dimensione di etichetta vengono omesse.',
+      content: 'Stampa sull\'etichetta',
+      resetFields: 'Ripristina predefiniti',
+      format: 'Uscita:',
+      dpi: 'Risoluzione',
+      pngHint: 'Per i software delle stampanti di etichette che usano immagini. Scegli la risoluzione della tua stampante; più etichette vengono fornite in uno ZIP.',
+      createPdf: 'Crea PDF',
+      downloadPng: 'Scarica PNG',
+      selectedTitle: 'Stampa etichette per le {{count}} bobine selezionate',
+      fields: {
+        brand: 'Marca',
+        material: 'Materiale e sottotipo',
+        hex: 'Codice colore',
+        name: 'Nome del colore o del filamento',
+        location: 'Posizione di stoccaggio',
+        materialNumber: 'Numero materiale',
+        temps: 'Temperatura ugello',
+        weight: 'Peso netto',
+        note: 'Nota',
+        added: 'Data di aggiunta',
+        qr: 'Codice QR',
+        spoolId: 'ID bobina',
+      },
       sortBy: {
         label: 'Ordina:',
         id: 'Per ID',

+ 29 - 2
frontend/src/i18n/locales/ja.ts

@@ -4729,11 +4729,9 @@ export default {
       monochrome: 'モノクロ(白黒プリンター)',
       monochromeHint: 'カラースウォッチを削除してテキストを広げます',
       startingPosition: 'ラベルの開始位置',
-      startingPositionRange: 'シートテンプレートのみ:L7160 は1~21、5160 は1~30に対応します。',
       startingPositionInvalid: '1~{{capacity}}の整数を入力してください。',
       startingPositionFirst: '位置1から印刷を開始します。',
       startingPositionSkipped: '最初のシートでは位置1~{{lastPosition}}を空白にします。',
-      startingPositionRangeError: 'このシートの開始位置は1~{{capacity}}で指定してください。',
       searchPlaceholder: '名前、ブランド、#IDで検索',
       filterByMaterial: '素材:',
       allMaterials: 'すべて',
@@ -4747,6 +4745,35 @@ export default {
       bulkTitle: '現在表示中の{{count}}件からラベルを印刷するスプールを選択',
       noSpoolsTitle: 'ラベル付けするスプールなし',
       error: 'ラベルを生成できませんでした: {{msg}}',
+      template: 'ラベルサイズ',
+      preview: 'プレビュー',
+      previewOf: '#{{id}} のプレビュー',
+      previewNoSelection: 'スプールを選択するとラベルが表示されます。',
+      previewError: 'プレビューを作成できませんでした: {{msg}}',
+      previewAlt: 'ラベルのプレビュー',
+      previewHint: 'このラベルサイズに収まらない行は省略されます。',
+      content: 'ラベルに印刷する項目',
+      resetFields: 'デフォルトに戻す',
+      format: '出力:',
+      dpi: '解像度',
+      pngHint: '画像を使うラベルプリンターソフト向けです。プリンターの解像度に合わせてください。複数のラベルは ZIP でダウンロードされます。',
+      createPdf: 'PDF を作成',
+      downloadPng: 'PNG をダウンロード',
+      selectedTitle: '選択した {{count}} 個のスプールのラベルを印刷',
+      fields: {
+        brand: 'ブランド',
+        material: '素材とサブタイプ',
+        hex: 'カラーコード',
+        name: '色名またはフィラメント名',
+        location: '保管場所',
+        materialNumber: '材料番号',
+        temps: 'ノズル温度',
+        weight: '正味重量',
+        note: 'メモ',
+        added: '追加日',
+        qr: 'QR コード',
+        spoolId: 'スプール ID',
+      },
       sortBy: {
         label: '並べ替え:',
         id: 'ID順',

+ 29 - 2
frontend/src/i18n/locales/ko.ts

@@ -4515,11 +4515,9 @@ export default {
       monochrome: '흑백 (흑백 프린터)',
       monochromeHint: '색상 견본을 제거하고 텍스트를 넓힙니다',
       startingPosition: '라벨 시작 위치',
-      startingPositionRange: '시트 템플릿만 해당: L7160은 1–21, 5160은 1–30을 지원합니다.',
       startingPositionInvalid: '1에서 {{capacity}} 사이의 정수를 입력하세요.',
       startingPositionFirst: '위치 1부터 인쇄를 시작합니다.',
       startingPositionSkipped: '첫 번째 시트의 위치 1부터 {{lastPosition}}까지는 비워 둡니다.',
-      startingPositionRangeError: '이 시트의 시작 위치는 1에서 {{capacity}} 사이여야 합니다.',
       searchPlaceholder: '이름, 브랜드 또는 #ID 검색',
       filterByMaterial: '재료:',
       allMaterials: '전체',
@@ -4559,6 +4557,35 @@ export default {
           hint: '페이지당 단일 레이블; AMS 필라멘트 레이블 홀더의 카드스톡 삽입 변형에 맞습니다. 스와치, 브랜드, 재질, ID, QR 코드에 충분한 공간이 있습니다.'
         }
       },
+      template: '라벨 크기',
+      preview: '미리보기',
+      previewOf: '#{{id}} 미리보기',
+      previewNoSelection: '스풀을 선택하면 라벨이 표시됩니다.',
+      previewError: '미리보기를 만들 수 없습니다: {{msg}}',
+      previewAlt: '라벨 미리보기',
+      previewHint: '이 라벨 크기에 맞지 않는 줄은 생략됩니다.',
+      content: '라벨에 인쇄할 항목',
+      resetFields: '기본값으로 재설정',
+      format: '출력:',
+      dpi: '해상도',
+      pngHint: '이미지를 사용하는 라벨 프린터 소프트웨어용입니다. 프린터 해상도에 맞추세요. 여러 라벨은 ZIP으로 제공됩니다.',
+      createPdf: 'PDF 만들기',
+      downloadPng: 'PNG 다운로드',
+      selectedTitle: '선택한 스풀 {{count}}개의 라벨 인쇄',
+      fields: {
+        brand: '브랜드',
+        material: '재료 및 하위 유형',
+        hex: '색상 코드',
+        name: '색상 또는 필라멘트 이름',
+        location: '보관 위치',
+        materialNumber: '재료 번호',
+        temps: '노즐 온도',
+        weight: '순중량',
+        note: '메모',
+        added: '추가 날짜',
+        qr: 'QR 코드',
+        spoolId: '스풀 ID',
+      },
       sortBy: {
         label: '정렬:',
         id: 'ID 순',

+ 29 - 2
frontend/src/i18n/locales/nl.ts

@@ -4765,11 +4765,9 @@ export default {
       monochrome: 'Monochroom (zwart-witprinter)',
       monochromeHint: 'Verwijdert het kleurvlak en maakt de tekst breder',
       startingPosition: 'Startpositie van etiket',
-      startingPositionRange: 'Alleen vellensjablonen: L7160 ondersteunt 1–21; 5160 ondersteunt 1–30.',
       startingPositionInvalid: 'Voer een heel getal van 1 tot {{capacity}} in.',
       startingPositionFirst: 'Het afdrukken begint op positie 1.',
       startingPositionSkipped: 'Posities 1 tot en met {{lastPosition}} blijven leeg op het eerste vel.',
-      startingPositionRangeError: 'De startpositie moet voor dit vel tussen 1 en {{capacity}} liggen.',
       searchPlaceholder: 'Zoek op naam, merk of #ID',
       filterByMaterial: 'Materiaal:',
       allMaterials: 'Alle',
@@ -4783,6 +4781,35 @@ export default {
       bulkTitle: 'Kies spoelen voor labels uit de {{count}} die momenteel worden weergegeven',
       noSpoolsTitle: 'Geen spoelen om te labelen',
       error: 'Labels genereren mislukt: {{msg}}',
+      template: 'Labelformaat',
+      preview: 'Voorbeeld',
+      previewOf: 'Voorbeeld van #{{id}}',
+      previewNoSelection: 'Selecteer een spoel om het label te zien.',
+      previewError: 'Kon het voorbeeld niet maken: {{msg}}',
+      previewAlt: 'Labelvoorbeeld',
+      previewHint: 'Regels die niet op dit labelformaat passen, worden weggelaten.',
+      content: 'Op het label afdrukken',
+      resetFields: 'Standaard herstellen',
+      format: 'Uitvoer:',
+      dpi: 'Resolutie',
+      pngHint: 'Voor labelprintersoftware die afbeeldingen gebruikt. Kies de resolutie van je printer; meerdere labels komen als ZIP.',
+      createPdf: 'PDF maken',
+      downloadPng: 'PNG downloaden',
+      selectedTitle: 'Labels afdrukken voor de {{count}} geselecteerde spoelen',
+      fields: {
+        brand: 'Merk',
+        material: 'Materiaal en subtype',
+        hex: 'Kleurcode',
+        name: 'Kleur- of filamentnaam',
+        location: 'Opslaglocatie',
+        materialNumber: 'Materiaalnummer',
+        temps: 'Nozzletemperatuur',
+        weight: 'Nettogewicht',
+        note: 'Notitie',
+        added: 'Datum toegevoegd',
+        qr: 'QR-code',
+        spoolId: 'Spoel-ID',
+      },
       sortBy: {
         label: 'Sorteren:',
         id: 'Op ID',

+ 29 - 2
frontend/src/i18n/locales/pt-BR.ts

@@ -4716,11 +4716,9 @@ export default {
       monochrome: 'Monocromático (impressora preto e branco)',
       monochromeHint: 'Remove a amostra de cor e amplia o texto',
       startingPosition: 'Posição inicial da etiqueta',
-      startingPositionRange: 'Somente modelos em folha: L7160 aceita 1–21; 5160 aceita 1–30.',
       startingPositionInvalid: 'Digite um número inteiro de 1 a {{capacity}}.',
       startingPositionFirst: 'A impressão começa na posição 1.',
       startingPositionSkipped: 'As posições de 1 a {{lastPosition}} ficarão vazias na primeira folha.',
-      startingPositionRangeError: 'A posição inicial deve estar entre 1 e {{capacity}} para esta folha.',
       searchPlaceholder: 'Buscar por nome, marca ou #ID',
       filterByMaterial: 'Material:',
       allMaterials: 'Tudo',
@@ -4734,6 +4732,35 @@ export default {
       bulkTitle: 'Escolha bobinas para etiquetar entre as {{count}} exibidas',
       noSpoolsTitle: 'Nenhuma bobina para rotular',
       error: 'Não foi possível gerar etiquetas: {{msg}}',
+      template: 'Tamanho da etiqueta',
+      preview: 'Pré-visualização',
+      previewOf: 'Pré-visualização de #{{id}}',
+      previewNoSelection: 'Selecione um carretel para ver a etiqueta.',
+      previewError: 'Não foi possível gerar a pré-visualização: {{msg}}',
+      previewAlt: 'Pré-visualização da etiqueta',
+      previewHint: 'Linhas que não cabem neste tamanho de etiqueta são omitidas.',
+      content: 'Imprimir na etiqueta',
+      resetFields: 'Restaurar padrão',
+      format: 'Saída:',
+      dpi: 'Resolução',
+      pngHint: 'Para softwares de impressoras de etiquetas que usam imagens. Use a resolução da sua impressora; várias etiquetas vêm em um ZIP.',
+      createPdf: 'Criar PDF',
+      downloadPng: 'Baixar PNG',
+      selectedTitle: 'Imprimir etiquetas dos {{count}} carretéis selecionados',
+      fields: {
+        brand: 'Marca',
+        material: 'Material e subtipo',
+        hex: 'Código de cor',
+        name: 'Nome da cor ou do filamento',
+        location: 'Local de armazenamento',
+        materialNumber: 'Número do material',
+        temps: 'Temperatura do bico',
+        weight: 'Peso líquido',
+        note: 'Observação',
+        added: 'Data de inclusão',
+        qr: 'Código QR',
+        spoolId: 'ID do carretel',
+      },
       sortBy: {
         label: 'Ordenar:',
         id: 'Por ID',

+ 29 - 2
frontend/src/i18n/locales/ru.ts

@@ -4505,11 +4505,9 @@ export default {
       monochrome: "Монохромный режим (чёрно-белый принтер)",
       monochromeHint: "Убирает образец цвета и расширяет область текста",
       startingPosition: "Начальная позиция этикетки",
-      startingPositionRange: "Только для листовых шаблонов: L7160 — 1–21; 5160 — 1–30.",
       startingPositionInvalid: "Введите целое число от 1 до {{capacity}}.",
       startingPositionFirst: "Печать начинается с позиции 1.",
       startingPositionSkipped: "Позиции с 1 по {{lastPosition}} останутся пустыми на первом листе.",
-      startingPositionRangeError: "Начальная позиция для этого листа должна быть от 1 до {{capacity}}.",
       searchPlaceholder: "Поиск по названию, бренду или #ID",
       filterByMaterial: "Материал:",
       allMaterials: "Все",
@@ -4523,6 +4521,35 @@ export default {
       bulkTitle: "Выберите катушки для печати этикеток из {{count}} отображаемых",
       noSpoolsTitle: "Нет катушек для печати этикеток",
       error: "Не удалось создать этикетки: {{msg}}",
+      template: "Размер этикетки",
+      preview: "Предпросмотр",
+      previewOf: "Предпросмотр #{{id}}",
+      previewNoSelection: "Выберите катушку, чтобы увидеть её этикетку.",
+      previewError: "Не удалось создать предпросмотр: {{msg}}",
+      previewAlt: "Предпросмотр этикетки",
+      previewHint: "Строки, которые не помещаются на этикетку этого размера, не печатаются.",
+      content: "Печатать на этикетке",
+      resetFields: "Сбросить по умолчанию",
+      format: "Вывод:",
+      dpi: "Разрешение",
+      pngHint: "Для программ принтеров этикеток, работающих с изображениями. Выберите разрешение вашего принтера; несколько этикеток скачиваются в ZIP.",
+      createPdf: "Создать PDF",
+      downloadPng: "Скачать PNG",
+      selectedTitle: "Напечатать этикетки для выбранных катушек ({{count}})",
+      fields: {
+        brand: "Бренд",
+        material: "Материал и подтип",
+        hex: "Код цвета",
+        name: "Название цвета или филамента",
+        location: "Место хранения",
+        materialNumber: "Номер материала",
+        temps: "Температура сопла",
+        weight: "Вес нетто",
+        note: "Заметка",
+        added: "Дата добавления",
+        qr: "QR-код",
+        spoolId: "ID катушки",
+      },
       sortBy: {
         label: "Сортировка:",
         id: "По ID",

+ 29 - 2
frontend/src/i18n/locales/sv.ts

@@ -4764,11 +4764,9 @@ errors: {
       monochrome: 'Monokrom (svartvit skrivare)',
       monochromeHint: 'Tar bort färgprovet och breddar texten',
       startingPosition: 'Startposition för etikett',
-      startingPositionRange: 'Endast arkmallar: L7160 stöder 1–21; 5160 stöder 1–30.',
       startingPositionInvalid: 'Ange ett heltal från 1 till {{capacity}}.',
       startingPositionFirst: 'Utskriften börjar vid position 1.',
       startingPositionSkipped: 'Positionerna 1 till {{lastPosition}} lämnas tomma på det första arket.',
-      startingPositionRangeError: 'Startpositionen måste vara mellan 1 och {{capacity}} för detta ark.',
       searchPlaceholder: 'Sök namn, märke eller #ID',
       filterByMaterial: 'Material:',
       allMaterials: 'Alla',
@@ -4782,6 +4780,35 @@ errors: {
       bulkTitle: 'Välj rullar för att skriva ut etiketter från de {{count}} som visas',
       noSpoolsTitle: 'Inga rullar att göra etikett för',
       error: 'Kunde inte generera etiketter: {{msg}}',
+      template: 'Etikettstorlek',
+      preview: 'Förhandsvisning',
+      previewOf: 'Förhandsvisning av #{{id}}',
+      previewNoSelection: 'Välj en spole för att se dess etikett.',
+      previewError: 'Kunde inte skapa förhandsvisningen: {{msg}}',
+      previewAlt: 'Förhandsvisning av etikett',
+      previewHint: 'Rader som inte får plats på den här etikettstorleken utelämnas.',
+      content: 'Skriv ut på etiketten',
+      resetFields: 'Återställ standard',
+      format: 'Utdata:',
+      dpi: 'Upplösning',
+      pngHint: 'För etikettskrivarprogram som använder bilder. Välj skrivarens upplösning; flera etiketter levereras som ZIP.',
+      createPdf: 'Skapa PDF',
+      downloadPng: 'Ladda ner PNG',
+      selectedTitle: 'Skriv ut etiketter för de {{count}} valda spolarna',
+      fields: {
+        brand: 'Märke',
+        material: 'Material och undertyp',
+        hex: 'Färgkod',
+        name: 'Färg- eller filamentnamn',
+        location: 'Förvaringsplats',
+        materialNumber: 'Materialnummer',
+        temps: 'Munstyckstemperatur',
+        weight: 'Nettovikt',
+        note: 'Anteckning',
+        added: 'Datum tillagd',
+        qr: 'QR-kod',
+        spoolId: 'Spol-ID',
+      },
       sortBy: {
         label: 'Sortera:',
         id: 'Efter ID',

+ 29 - 2
frontend/src/i18n/locales/tr.ts

@@ -4717,11 +4717,9 @@ export default {
       monochrome: 'Tek renk (siyah beyaz yazıcı)',
       monochromeHint: 'Renk örneğini kaldırır ve metni genişletir',
       startingPosition: 'Başlangıç etiket konumu',
-      startingPositionRange: 'Yalnızca sayfa şablonları: L7160 için 1–21; 5160 için 1–30.',
       startingPositionInvalid: '1 ile {{capacity}} arasında bir tam sayı girin.',
       startingPositionFirst: 'Yazdırma 1. konumdan başlar.',
       startingPositionSkipped: 'İlk sayfada 1 ile {{lastPosition}} arasındaki konumlar boş bırakılır.',
-      startingPositionRangeError: 'Bu sayfanın başlangıç konumu 1 ile {{capacity}} arasında olmalıdır.',
       searchPlaceholder: 'Ad, marka veya #ID ara',
       filterByMaterial: 'Malzeme:',
       allMaterials: 'Tümü',
@@ -4735,6 +4733,35 @@ export default {
       bulkTitle: 'Şu anda gösterilen {{count}} makaradan etiket yazdırılacak olanları seçin',
       noSpoolsTitle: 'Etiketlenecek makara yok',
       error: 'Etiketler oluşturulamadı: {{msg}}',
+      template: 'Etiket boyutu',
+      preview: 'Önizleme',
+      previewOf: '#{{id}} önizlemesi',
+      previewNoSelection: 'Etiketini görmek için bir makara seçin.',
+      previewError: 'Önizleme oluşturulamadı: {{msg}}',
+      previewAlt: 'Etiket önizlemesi',
+      previewHint: 'Bu etiket boyutuna sığmayan satırlar atlanır.',
+      content: 'Etikete yazdır',
+      resetFields: 'Varsayılana sıfırla',
+      format: 'Çıktı:',
+      dpi: 'Çözünürlük',
+      pngHint: 'Görüntü kullanan etiket yazıcısı yazılımları için. Yazıcınızın çözünürlüğünü seçin; birden fazla etiket ZIP olarak gelir.',
+      createPdf: 'PDF oluştur',
+      downloadPng: 'PNG indir',
+      selectedTitle: 'Seçili {{count}} makara için etiket yazdır',
+      fields: {
+        brand: 'Marka',
+        material: 'Malzeme ve alt tür',
+        hex: 'Renk kodu',
+        name: 'Renk veya filament adı',
+        location: 'Depolama konumu',
+        materialNumber: 'Malzeme numarası',
+        temps: 'Nozül sıcaklığı',
+        weight: 'Net ağırlık',
+        note: 'Not',
+        added: 'Eklenme tarihi',
+        qr: 'QR kodu',
+        spoolId: 'Makara kimliği',
+      },
       sortBy: {
         label: 'Sırala:',
         id: 'ID\'ye göre',

+ 29 - 2
frontend/src/i18n/locales/uk.ts

@@ -4761,11 +4761,9 @@ export default {
       monochrome: "Монохромний (чорно-білий принтер)",
       monochromeHint: "Прибирає зразок кольору та розширює область тексту",
       startingPosition: "Початкова позиція етикетки",
-      startingPositionRange: "Лише для аркушів: L7160 підтримує 1–21; 5160 підтримує 1–30.",
       startingPositionInvalid: "Введіть ціле число від 1 до {{capacity}}.",
       startingPositionFirst: "Друк починається з позиції 1.",
       startingPositionSkipped: "Позиції від 1 до {{lastPosition}} залишаться порожніми на першому аркуші.",
-      startingPositionRangeError: "Початкова позиція для цього аркуша має бути від 1 до {{capacity}}.",
       searchPlaceholder: "Шукайте назву, бренд або #ID",
       filterByMaterial: "матеріал:",
       allMaterials: "все",
@@ -4779,6 +4777,35 @@ export default {
       bulkTitle: "Виберіть котушки для друку етикеток серед показаних ({{count}})",
       noSpoolsTitle: "Немає котушок для маркування",
       error: "Не вдалося створити мітки: {{msg}}",
+      template: "Розмір етикетки",
+      preview: "Попередній перегляд",
+      previewOf: "Попередній перегляд #{{id}}",
+      previewNoSelection: "Виберіть котушку, щоб побачити її етикетку.",
+      previewError: "Не вдалося створити попередній перегляд: {{msg}}",
+      previewAlt: "Попередній перегляд етикетки",
+      previewHint: "Рядки, які не вміщуються на етикетку цього розміру, не друкуються.",
+      content: "Друкувати на етикетці",
+      resetFields: "Скинути до типових",
+      format: "Вивід:",
+      dpi: "Роздільна здатність",
+      pngHint: "Для програм принтерів етикеток, що працюють із зображеннями. Виберіть роздільну здатність вашого принтера; кілька етикеток завантажуються в ZIP.",
+      createPdf: "Створити PDF",
+      downloadPng: "Завантажити PNG",
+      selectedTitle: "Надрукувати етикетки для вибраних котушок ({{count}})",
+      fields: {
+        brand: "Бренд",
+        material: "Матеріал і підтип",
+        hex: "Код кольору",
+        name: "Назва кольору або філаменту",
+        location: "Місце зберігання",
+        materialNumber: "Номер матеріалу",
+        temps: "Температура сопла",
+        weight: "Вага нетто",
+        note: "Нотатка",
+        added: "Дата додавання",
+        qr: "QR-код",
+        spoolId: "ID котушки",
+      },
       sortBy: {
         label: "Сортувати:",
         id: "За ідентифікатором",

+ 29 - 2
frontend/src/i18n/locales/zh-CN.ts

@@ -4716,11 +4716,9 @@ export default {
       monochrome: '单色(黑白打印机)',
       monochromeHint: '移除颜色色块并加宽文本',
       startingPosition: '标签起始位置',
-      startingPositionRange: '仅适用于标签纸模板:L7160 支持 1–21;5160 支持 1–30。',
       startingPositionInvalid: '请输入 1 到 {{capacity}} 之间的整数。',
       startingPositionFirst: '从位置 1 开始打印。',
       startingPositionSkipped: '第一张标签纸的位置 1 到 {{lastPosition}} 将留空。',
-      startingPositionRangeError: '此标签纸的起始位置必须在 1 到 {{capacity}} 之间。',
       searchPlaceholder: '按名称、品牌或 #ID 搜索',
       filterByMaterial: '材料:',
       allMaterials: '全部',
@@ -4734,6 +4732,35 @@ export default {
       bulkTitle: '从当前显示的 {{count}} 个线材中选择要打印标签的',
       noSpoolsTitle: '没有要打标签的线材',
       error: '无法生成标签:{{msg}}',
+      template: '标签尺寸',
+      preview: '预览',
+      previewOf: '#{{id}} 的预览',
+      previewNoSelection: '选择一个料盘以查看其标签。',
+      previewError: '无法生成预览:{{msg}}',
+      previewAlt: '标签预览',
+      previewHint: '放不下此标签尺寸的行会被省略。',
+      content: '标签打印内容',
+      resetFields: '恢复默认',
+      format: '输出:',
+      dpi: '分辨率',
+      pngHint: '适用于使用图片的标签打印机软件。请选择与打印机一致的分辨率;多个标签将以 ZIP 形式提供。',
+      createPdf: '生成 PDF',
+      downloadPng: '下载 PNG',
+      selectedTitle: '为选中的 {{count}} 个料盘打印标签',
+      fields: {
+        brand: '品牌',
+        material: '材料和子类型',
+        hex: '颜色代码',
+        name: '颜色或耗材名称',
+        location: '存放位置',
+        materialNumber: '物料编号',
+        temps: '喷嘴温度',
+        weight: '净重',
+        note: '备注',
+        added: '添加日期',
+        qr: '二维码',
+        spoolId: '料盘 ID',
+      },
       sortBy: {
         label: '排序:',
         id: '按 ID',

+ 29 - 2
frontend/src/i18n/locales/zh-TW.ts

@@ -4716,11 +4716,9 @@ export default {
       monochrome: '單色(黑白印表機)',
       monochromeHint: '移除顏色色塊並加寬文字',
       startingPosition: '標籤起始位置',
-      startingPositionRange: '僅適用於標籤紙範本:L7160 支援 1–21;5160 支援 1–30。',
       startingPositionInvalid: '請輸入 1 到 {{capacity}} 之間的整數。',
       startingPositionFirst: '從位置 1 開始列印。',
       startingPositionSkipped: '第一張標籤紙的位置 1 到 {{lastPosition}} 將留空。',
-      startingPositionRangeError: '此標籤紙的起始位置必須在 1 到 {{capacity}} 之間。',
       searchPlaceholder: '按名稱、品牌或 #ID 搜尋',
       filterByMaterial: '材料:',
       allMaterials: '全部',
@@ -4734,6 +4732,35 @@ export default {
       bulkTitle: '從目前顯示的 {{count}} 個線材中選擇要列印標籤的',
       noSpoolsTitle: '沒有要貼標籤的線材',
       error: '無法產生標籤:{{msg}}',
+      template: '標籤尺寸',
+      preview: '預覽',
+      previewOf: '#{{id}} 的預覽',
+      previewNoSelection: '選擇一個料盤以查看其標籤。',
+      previewError: '無法產生預覽:{{msg}}',
+      previewAlt: '標籤預覽',
+      previewHint: '放不下此標籤尺寸的行會被省略。',
+      content: '標籤列印內容',
+      resetFields: '恢復預設',
+      format: '輸出:',
+      dpi: '解析度',
+      pngHint: '適用於使用圖片的標籤印表機軟體。請選擇與印表機一致的解析度;多個標籤將以 ZIP 形式提供。',
+      createPdf: '產生 PDF',
+      downloadPng: '下載 PNG',
+      selectedTitle: '為選取的 {{count}} 個料盤列印標籤',
+      fields: {
+        brand: '品牌',
+        material: '材料與子類型',
+        hex: '顏色代碼',
+        name: '顏色或線材名稱',
+        location: '存放位置',
+        materialNumber: '物料編號',
+        temps: '噴嘴溫度',
+        weight: '淨重',
+        note: '備註',
+        added: '新增日期',
+        qr: 'QR 碼',
+        spoolId: '料盤 ID',
+      },
       sortBy: {
         label: '排序:',
         id: '按 ID',

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

@@ -1623,14 +1623,19 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
           <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))}
+            // Pre-select the spools ticked in the list; with none ticked,
+            // every visible spool, so the user refines downward in the modal
+            // (#2980). Per-card icon pre-selects only that spool — all flows
+            // share the same picker.
+            onClick={() =>
+              setLabelPickerSpoolIds(selectedIds.size > 0 ? [...selectedIds] : 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 })
+                : selectedIds.size > 0
+                  ? t('inventory.labels.selectedTitle', 'Print labels for the {{count}} selected spools', { count: selectedIds.size })
+                  : t('inventory.labels.bulkTitle', 'Pick spools to print labels for from the {{count}} currently shown', { count: filteredSpools.length })
             }
           >
             <Printer className="w-4 h-4" />

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ImagePreviewModal-CHxXVMhZ.js


File diff ditekan karena terlalu besar
+ 0 - 1
static/assets/PdfPreviewModal-BE-QK9vM.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpreadsheetPreviewModal-EdHQ73sd.js


File diff ditekan karena terlalu besar
+ 0 - 1
static/assets/index-2v_Z1nnP.css


File diff ditekan karena terlalu besar
+ 1 - 0
static/assets/index-BdFVHW40.css


File diff ditekan karena terlalu besar
+ 0 - 1
static/assets/index-CcfSTIKL.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/pdf-BVNSFz5v.js


+ 2 - 2
static/index.html

@@ -26,9 +26,9 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Besr7Tb4.js"></script>
+    <script type="module" crossorigin src="/assets/index-CcfSTIKL.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-2v_Z1nnP.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-BdFVHW40.css">
   </head>
   <body>
     <div id="root"></div>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini