소스 검색

Add custom label sheets and saved label templates (#3385)

Kyrylo Koshil 11 시간 전
부모
커밋
9a92f7d7a1
34개의 변경된 파일과 3005개의 추가작업 그리고 64개의 파일을 삭제
  1. 129 0
      backend/app/api/routes/label_templates.py
  2. 47 6
      backend/app/api/routes/labels.py
  3. 2 0
      backend/app/main.py
  4. 2 0
      backend/app/models/__init__.py
  5. 31 0
      backend/app/models/label_template.py
  6. 99 0
      backend/app/schemas/label_template.py
  7. 138 30
      backend/app/services/label_renderer.py
  8. 216 0
      backend/tests/integration/test_label_templates_api.py
  9. 145 0
      backend/tests/integration/test_labels.py
  10. 165 1
      backend/tests/unit/services/test_label_renderer.py
  11. 1 0
      backend/tests/unit/test_not_null_update_schemas.py
  12. 11 0
      frontend/scripts/check-i18n-parity.mjs
  13. 38 0
      frontend/src/__tests__/components/LabelTemplatePickerKeys.test.ts
  14. 407 3
      frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx
  15. 37 2
      frontend/src/api/client.ts
  16. 167 0
      frontend/src/components/LabelSheetLayoutEditor.tsx
  17. 434 22
      frontend/src/components/LabelTemplatePickerModal.tsx
  18. 49 0
      frontend/src/i18n/locales/de.ts
  19. 49 0
      frontend/src/i18n/locales/en.ts
  20. 49 0
      frontend/src/i18n/locales/es.ts
  21. 49 0
      frontend/src/i18n/locales/fr.ts
  22. 49 0
      frontend/src/i18n/locales/it.ts
  23. 49 0
      frontend/src/i18n/locales/ja.ts
  24. 49 0
      frontend/src/i18n/locales/ko.ts
  25. 49 0
      frontend/src/i18n/locales/nl.ts
  26. 49 0
      frontend/src/i18n/locales/pl.ts
  27. 49 0
      frontend/src/i18n/locales/pt-BR.ts
  28. 49 0
      frontend/src/i18n/locales/ru.ts
  29. 49 0
      frontend/src/i18n/locales/sv.ts
  30. 49 0
      frontend/src/i18n/locales/tr.ts
  31. 49 0
      frontend/src/i18n/locales/uk.ts
  32. 49 0
      frontend/src/i18n/locales/zh-CN.ts
  33. 49 0
      frontend/src/i18n/locales/zh-TW.ts
  34. 152 0
      frontend/src/utils/labelSheetLayout.ts

+ 129 - 0
backend/app/api/routes/label_templates.py

@@ -0,0 +1,129 @@
+"""Saved custom label sheets (#3296).
+
+A saved template is a named sheet layout, listed in the print dialog for
+everyone on the install. Printing does not read this table: the dialog sends
+the layout itself with the print request, so anyone who can print can use a
+saved template or a one-off custom sheet, and only saving, editing and
+deleting need ``inventory:update`` (the same as storage locations).
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, HTTPException, Response
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+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.label_template import LabelTemplate
+from backend.app.models.user import User
+from backend.app.schemas.label_template import (
+    LabelTemplateIn,
+    LabelTemplateResponse,
+    SheetLayoutResponse,
+)
+from backend.app.utils.natural_sort import natural_sort_key
+
+router = APIRouter(prefix="/label-templates", tags=["labels"])
+
+DUPLICATE_NAME = "A label template with this name already exists"
+
+_LAYOUT_COLUMNS = tuple(SheetLayoutResponse.model_fields)
+
+
+def _to_response(template: LabelTemplate) -> LabelTemplateResponse:
+    return LabelTemplateResponse(
+        id=template.id,
+        name=template.name,
+        layout=SheetLayoutResponse(**{col: getattr(template, col) for col in _LAYOUT_COLUMNS}),
+        created_at=template.created_at,
+        updated_at=template.updated_at,
+    )
+
+
+def _apply(template: LabelTemplate, data: LabelTemplateIn) -> None:
+    template.name = data.name
+    template.name_key = data.name_key
+    for col, value in data.layout.model_dump().items():
+        setattr(template, col, value)
+
+
+async def _name_taken(db: AsyncSession, name_key: str, exclude_id: int | None = None) -> bool:
+    query = select(LabelTemplate.id).where(LabelTemplate.name_key == name_key)
+    if exclude_id is not None:
+        query = query.where(LabelTemplate.id != exclude_id)
+    return (await db.execute(query)).first() is not None
+
+
+async def _commit(db: AsyncSession, template: LabelTemplate) -> None:
+    try:
+        await db.commit()
+    except IntegrityError as exc:
+        # Two saves under the same name at once: the unique index catches the
+        # one the lookup above could not.
+        await db.rollback()
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
+    await db.refresh(template)
+
+
+@router.get("", response_model=list[LabelTemplateResponse])
+async def list_label_templates(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """All saved label templates, in natural name order."""
+    result = await db.execute(select(LabelTemplate))
+    templates = sorted(result.scalars().all(), key=lambda t: natural_sort_key(t.name))
+    return [_to_response(t) for t in templates]
+
+
+@router.post("", response_model=LabelTemplateResponse, status_code=201)
+async def create_label_template(
+    data: LabelTemplateIn,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Save a custom sheet layout under a name."""
+    if await _name_taken(db, data.name_key):
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+    template = LabelTemplate()
+    _apply(template, data)
+    db.add(template)
+    await _commit(db, template)
+    return _to_response(template)
+
+
+@router.put("/{template_id}", response_model=LabelTemplateResponse)
+async def replace_label_template(
+    template_id: int,
+    data: LabelTemplateIn,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Rename a saved template or change its layout. The body is the whole
+    template, as the dialog's form always holds all of it."""
+    template = await db.get(LabelTemplate, template_id)
+    if template is None:
+        raise HTTPException(status_code=404, detail="Label template not found")
+    if await _name_taken(db, data.name_key, exclude_id=template_id):
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+    _apply(template, data)
+    await _commit(db, template)
+    return _to_response(template)
+
+
+@router.delete("/{template_id}", status_code=204)
+async def delete_label_template(
+    template_id: int,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Delete a saved template. Labels already printed with it are unaffected."""
+    template = await db.get(LabelTemplate, template_id)
+    if template is None:
+        raise HTTPException(status_code=404, detail="Label template not found")
+    await db.delete(template)
+    await db.commit()
+    return Response(status_code=204)

+ 47 - 6
backend/app/api/routes/labels.py

@@ -6,7 +6,8 @@ Two endpoints, one per inventory backend:
 - ``POST /spoolman/labels``   — Spoolman-backed spools
 
 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
+the lines to print (``fields``) and the output ``format``. ``template`` is a
+built-in name, or ``"custom"`` with the sheet itself in ``layout`` (#3296): 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).
@@ -37,16 +38,19 @@ 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.schemas.label_template import SheetLayoutSpec
 from backend.app.services.label_renderer import (
     ALL_LABEL_FIELDS,
     DEFAULT_LABEL_FIELDS,
     LabelData,
     LabelField,
+    Template,
     TemplateName,
     get_sheet_capacity,
     pdf_to_pngs,
     render_label_preview_pdf,
     render_labels,
+    sheet_page_count,
 )
 from backend.app.services.spoolman import get_spoolman_client
 from backend.app.utils.http import build_content_disposition
@@ -59,6 +63,14 @@ router = APIRouter(tags=["labels"])
 # largest realistic batch (an Avery sheet at 30/page × ~10 pages).
 MAX_LABELS_PER_REQUEST = 500
 
+# PNG export of sheets: pages × dpi. A sheet page is a whole A4 or Letter
+# page, rasterised under the process-wide PDFium lock that PDF thumbnails also
+# wait on, so a custom one-label-per-page sheet of 500 spools at 600 dpi would
+# hold it for minutes. 15 000 is 25 pages at 600 dpi, 50 at 300 and 73 at 203;
+# the built-in sheets need at most 24 pages (500 spools on an L7160), so they
+# always fit. Mirrored in the dialog (labelSheetLayout.ts).
+MAX_PNG_SHEET_PAGES_X_DPI = 15_000
+
 # 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
@@ -69,16 +81,34 @@ SpoolId = Annotated[int, Field(gt=0)]
 
 
 class LabelOptions(BaseModel):
-    template: TemplateName
+    template: TemplateName | Literal["custom"]
+    # The sheet, when ``template`` is "custom" (#3296). A saved template is
+    # sent this way too: the dialog holds its layout, so printing never reads
+    # the saved-template table.
+    layout: SheetLayoutSpec | None = None
     # 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))
 
+    @model_validator(mode="after")
+    def layout_only_for_custom(self) -> LabelOptions:
+        if (self.template == "custom") != (self.layout is not None):
+            raise ValueError('layout is required for template "custom" and not allowed otherwise')
+        return self
+
     def field_set(self) -> frozenset[LabelField]:
         return DEFAULT_LABEL_FIELDS if self.fields is None else frozenset(self.fields)
 
+    def resolved_template(self) -> Template:
+        if self.layout is not None:
+            return self.layout.to_layout()
+        if self.template == "custom":
+            # layout_only_for_custom refuses this, so a validated request never gets here.
+            raise ValueError('layout is required for template "custom"')
+        return self.template
+
 
 class LabelRequest(LabelOptions):
     spool_ids: list[SpoolId] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
@@ -90,13 +120,23 @@ class LabelRequest(LabelOptions):
 
     @model_validator(mode="after")
     def validate_starting_position(self) -> LabelRequest:
-        capacity = get_sheet_capacity(self.template)
+        if (self.template == "custom") != (self.layout is not None):
+            return self  # layout_only_for_custom reports this one
+        capacity = get_sheet_capacity(self.resolved_template())
         if capacity is None:
             if self.starting_position != 1:
                 raise ValueError("starting_position is only supported for sheet label templates")
             return self
         if self.starting_position > capacity:
             raise ValueError(f"starting_position must be between 1 and {capacity} for template {self.template}")
+        if self.format == "png":
+            pages = sheet_page_count(self.resolved_template(), len(self.spool_ids), self.starting_position) or 0
+            if pages * self.dpi > MAX_PNG_SHEET_PAGES_X_DPI:
+                max_pages = MAX_PNG_SHEET_PAGES_X_DPI // self.dpi
+                raise ValueError(
+                    f"PNG export is limited to {max_pages} sheet pages at {self.dpi} dpi, and this is {pages}. "
+                    "Use PDF, a lower resolution or fewer spools."
+                )
         return self
 
 
@@ -202,8 +242,9 @@ def _stream_pdf(pdf: bytes, filename: str) -> StreamingResponse:
 
 
 async def _render_response(body: LabelRequest, data_list: list[LabelData], filename_stem: str) -> Response:
+    template = body.resolved_template()
     pdf = render_labels(
-        body.template,
+        template,
         data_list,
         monochrome=body.monochrome,
         starting_position=body.starting_position,
@@ -226,7 +267,7 @@ async def _render_response(body: LabelRequest, data_list: list[LabelData], filen
 
     # 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:
+    if get_sheet_capacity(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)]
@@ -246,7 +287,7 @@ async def _render_response(body: LabelRequest, data_list: list[LabelData], filen
 
 
 async def _render_preview(body: LabelPreviewRequest, data: LabelData) -> Response:
-    pdf = render_label_preview_pdf(body.template, data, monochrome=body.monochrome, fields=body.field_set())
+    pdf = render_label_preview_pdf(body.resolved_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"})
 

+ 2 - 0
backend/app/main.py

@@ -48,6 +48,7 @@ from backend.app.api.routes import (
     ha_sensors,
     inventory,
     kprofiles,
+    label_templates,
     labels,
     library,
     library_tags,
@@ -12946,6 +12947,7 @@ app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(finance.router, prefix=app_settings.api_prefix)
 app.include_router(inventory.router, prefix=app_settings.api_prefix)
 app.include_router(labels.router, prefix=app_settings.api_prefix)
+app.include_router(label_templates.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(overlay_branding.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)

+ 2 - 0
backend/app/models/__init__.py

@@ -16,6 +16,7 @@ from backend.app.models.filament_profile_default import (
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.group import Group, group_locations, group_printers, user_groups
 from backend.app.models.kprofile_note import KProfileNote
+from backend.app.models.label_template import LabelTemplate
 from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.location import Location
@@ -85,6 +86,7 @@ __all__ = [
     "LibraryFolder",
     "LibraryFile",
     "FileVariantGroup",
+    "LabelTemplate",
     "Location",
     "User",
     "Group",

+ 31 - 0
backend/app/models/label_template.py

@@ -0,0 +1,31 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class LabelTemplate(Base):
+    """A custom label sheet saved under a name (#3296). Shared by everyone on
+    the install, like storage locations. Lengths are in millimetres."""
+
+    __tablename__ = "label_templates"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100), nullable=False)
+    # Uniqueness ignoring case and Unicode composition: "Avery 3490" and
+    # "avery 3490" are one template. Casefolding can lengthen a name up to
+    # three times, hence the wider column (schemas.label_template).
+    name_key: Mapped[str] = mapped_column(String(300), nullable=False, unique=True, index=True)
+    page: Mapped[str] = mapped_column(String(10), nullable=False)
+    label_w_mm: Mapped[float] = mapped_column(Float, nullable=False)
+    label_h_mm: Mapped[float] = mapped_column(Float, nullable=False)
+    cols: Mapped[int] = mapped_column(Integer, nullable=False)
+    rows: Mapped[int] = mapped_column(Integer, nullable=False)
+    top_mm: Mapped[float] = mapped_column(Float, nullable=False)
+    left_mm: Mapped[float] = mapped_column(Float, nullable=False)
+    col_gap_mm: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
+    row_gap_mm: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 99 - 0
backend/app/schemas/label_template.py

@@ -0,0 +1,99 @@
+"""Sheet layouts for custom label sheets, and the saved templates that hold
+them (#3296)."""
+
+import unicodedata
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+from backend.app.services.label_renderer import (
+    MAX_COLS,
+    MAX_LENGTH_MM,
+    MAX_ROWS,
+    MIN_LABEL_H_MM,
+    MIN_LABEL_W_MM,
+    PageSize,
+    SheetLayout,
+)
+
+# Each character can casefold to at most three ("ffi" -> "ffi"), so a 100-character
+# name always has a key that fits the 300-character column.
+NAME_MAX_LENGTH = 100
+NAME_KEY_MAX_LENGTH = 300
+
+
+def label_template_name_key(name: str) -> str:
+    """What makes two names the same template: case and Unicode composition
+    ignored, so "Café" typed with a combining accent matches the composed one."""
+    return unicodedata.normalize("NFC", name).casefold()
+
+
+class SheetLayoutSpec(BaseModel):
+    """A sheet layout as a request carries it. Lengths are in millimetres."""
+
+    model_config = ConfigDict(allow_inf_nan=False)
+
+    page: PageSize
+    label_w_mm: float = Field(ge=MIN_LABEL_W_MM, le=MAX_LENGTH_MM)
+    label_h_mm: float = Field(ge=MIN_LABEL_H_MM, le=MAX_LENGTH_MM)
+    cols: int = Field(ge=1, le=MAX_COLS)
+    rows: int = Field(ge=1, le=MAX_ROWS)
+    top_mm: float = Field(ge=0, le=MAX_LENGTH_MM)
+    left_mm: float = Field(ge=0, le=MAX_LENGTH_MM)
+    col_gap_mm: float = Field(default=0.0, ge=0, le=MAX_LENGTH_MM)
+    row_gap_mm: float = Field(default=0.0, ge=0, le=MAX_LENGTH_MM)
+
+    @model_validator(mode="after")
+    def fits_on_page(self) -> "SheetLayoutSpec":
+        problem = self.to_layout().problem()
+        if problem:
+            raise ValueError(problem)
+        return self
+
+    def to_layout(self) -> SheetLayout:
+        return SheetLayout(**self.model_dump())
+
+
+class SheetLayoutResponse(BaseModel):
+    """A stored layout, returned as saved. Not re-validated, so a template saved
+    under looser limits still lists rather than failing the whole response."""
+
+    page: str
+    label_w_mm: float
+    label_h_mm: float
+    cols: int
+    rows: int
+    top_mm: float
+    left_mm: float
+    col_gap_mm: float
+    row_gap_mm: float
+
+
+class LabelTemplateIn(BaseModel):
+    """Create or replace a saved template: a name and the whole layout."""
+
+    name: str = Field(..., min_length=1, max_length=NAME_MAX_LENGTH)
+    layout: SheetLayoutSpec
+
+    @field_validator("name")
+    @classmethod
+    def normalize_name(cls, v: str) -> str:
+        v = unicodedata.normalize("NFC", v.strip())
+        if not v:
+            raise ValueError("name must not be empty")
+        # NFC can (rarely) lengthen a name, so both columns are checked again.
+        if len(v) > NAME_MAX_LENGTH or len(label_template_name_key(v)) > NAME_KEY_MAX_LENGTH:
+            raise ValueError("name is too long")
+        return v
+
+    @property
+    def name_key(self) -> str:
+        return label_template_name_key(self.name)
+
+
+class LabelTemplateResponse(BaseModel):
+    id: int
+    name: str
+    layout: SheetLayoutResponse
+    created_at: datetime
+    updated_at: datetime

+ 138 - 30
backend/app/services/label_renderer.py

@@ -15,6 +15,10 @@ Six fixed templates:
 - ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
 - ``avery_l7160`` — A4 sheet, 38.1×63.5 mm × 21 per sheet.
 
+Beyond these, a sheet of any grid can be described at run time as a
+``SheetLayout`` (#3296): page size, label size, columns and rows, margins and
+gaps. The built-in sheets are ``SheetLayout`` values too.
+
 The legacy ``ams_30x15`` preset (#809) was incorrect — the original 30×15 mm
 dimension didn't fit any documented variant of model 752566. Replaced by the
 two ``ams_holder_*`` presets above (#1426).
@@ -485,6 +489,82 @@ def _draw_label_roomy(
 
 # ── Template entry points ────────────────────────────────────────────────────
 
+PageSize = Literal["a4", "letter"]
+
+_PAGE_SIZES: dict[str, tuple[float, float]] = {"a4": A4, "letter": letter}
+# The same pages in millimetres, for the fit check. Written out rather than
+# derived from the points above so the dialog (labelSheetLayout.ts) can use the
+# very same numbers: letter[0] / mm is 215.8999…, not 215.9.
+PAGE_SIZES_MM: dict[str, tuple[float, float]] = {"a4": (210.0, 297.0), "letter": (215.9, 279.4)}
+
+# Bounds on a sheet layout (#3296). The minimum label is what the roomy layout
+# can fill: from 20 mm tall a label leaves the tight layout (which drops the QR
+# and most lines), and from 40 mm wide the text column beside the colour swatch
+# and the QR holds a four-digit spool ID at full size, at any height — the same
+# width as the smallest built-in label. The rest stops a typo asking for a
+# 10 000-label page.
+MIN_LABEL_W_MM = 40.0
+MIN_LABEL_H_MM = 20.0
+MAX_LENGTH_MM = 300.0
+MAX_COLS = 20
+MAX_ROWS = 40
+# Rounding slack when checking that the grid fits the page: a sheet measured in
+# inches converts to millimetres with a few hundredths to spare either way.
+_FIT_TOLERANCE_MM = 0.01
+
+
+@dataclass(frozen=True)
+class SheetLayout:
+    """A sheet of identical labels in a grid: the built-in Avery templates and
+    any custom sheet a user describes (#3296). Lengths are in millimetres."""
+
+    page: PageSize
+    label_w_mm: float
+    label_h_mm: float
+    cols: int
+    rows: int
+    top_mm: float
+    left_mm: float
+    col_gap_mm: float = 0.0
+    row_gap_mm: float = 0.0
+
+    @property
+    def capacity(self) -> int:
+        return self.cols * self.rows
+
+    @property
+    def page_size(self) -> tuple[float, float]:
+        """Page size in points, as ReportLab takes it."""
+        return _PAGE_SIZES[self.page]
+
+    def problem(self) -> str | None:
+        """Why this layout can't be printed, or ``None`` when it can."""
+        if self.page not in PAGE_SIZES_MM:
+            return f"Unknown page size: {self.page!r}"
+        if self.label_w_mm < MIN_LABEL_W_MM or self.label_h_mm < MIN_LABEL_H_MM:
+            return f"Labels must be at least {MIN_LABEL_W_MM:g} mm wide and {MIN_LABEL_H_MM:g} mm tall"
+        lengths = (self.label_w_mm, self.label_h_mm, self.top_mm, self.left_mm, self.col_gap_mm, self.row_gap_mm)
+        if max(lengths) > MAX_LENGTH_MM:
+            return f"Sizes, margins and gaps can be at most {MAX_LENGTH_MM:g} mm"
+        if not 1 <= self.cols <= MAX_COLS:
+            return f"Columns must be between 1 and {MAX_COLS}"
+        if not 1 <= self.rows <= MAX_ROWS:
+            return f"Rows must be between 1 and {MAX_ROWS}"
+        if min(self.top_mm, self.left_mm, self.col_gap_mm, self.row_gap_mm) < 0:
+            return "Margins and gaps must not be negative"
+        page_w_mm, page_h_mm = PAGE_SIZES_MM[self.page]
+        grid_w = self.left_mm + self.cols * self.label_w_mm + (self.cols - 1) * self.col_gap_mm
+        grid_h = self.top_mm + self.rows * self.label_h_mm + (self.rows - 1) * self.row_gap_mm
+        if grid_w > page_w_mm + _FIT_TOLERANCE_MM:
+            return f"The labels are {grid_w:.2f} mm wide with the left margin, but the page is {page_w_mm:.1f} mm"
+        if grid_h > page_h_mm + _FIT_TOLERANCE_MM:
+            return f"The labels are {grid_h:.2f} mm tall with the top margin, but the page is {page_h_mm:.1f} mm"
+        return None
+
+
+# A built-in template name, or a sheet layout supplied at run time.
+Template = TemplateName | SheetLayout
+
 # (label_w_mm, label_h_mm) for single-label-per-page templates.
 _SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
     "ams_holder_74x33": (74.0, 33.0),
@@ -493,15 +573,22 @@ _SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
     "box_62x29": (62.0, 29.0),
 }
 
-# Sheet template parameters: (page_size, label_w_mm, label_h_mm,
-#                              cols, rows, top_margin_mm, left_margin_mm,
-#                              col_gap_mm, row_gap_mm)
-_SHEET_TEMPLATES: dict[str, tuple] = {
-    "avery_5160": (letter, 66.675, 25.4, 3, 10, 12.7, 4.76, 3.175, 0.0),
-    "avery_l7160": (A4, 63.5, 38.1, 3, 7, 15.15, 7.0, 2.5, 0.0),
+_SHEET_TEMPLATES: dict[str, SheetLayout] = {
+    "avery_5160": SheetLayout("letter", 66.675, 25.4, 3, 10, top_mm=12.7, left_mm=4.76, col_gap_mm=3.175),
+    "avery_l7160": SheetLayout("a4", 63.5, 38.1, 3, 7, top_mm=15.15, left_mm=7.0, col_gap_mm=2.5),
 }
 
 
+def _sheet_layout(template: Template) -> SheetLayout | None:
+    if isinstance(template, SheetLayout):
+        return template
+    return _SHEET_TEMPLATES.get(template)
+
+
+def _template_title(template: Template) -> str:
+    return "custom sheet" if isinstance(template, SheetLayout) else template
+
+
 def _render_single_label_pdf(
     template: TemplateName,
     data_list: list[LabelData],
@@ -523,48 +610,62 @@ def _render_single_label_pdf(
     return buf.getvalue()
 
 
-def label_size_mm(template: TemplateName) -> tuple[float, float]:
+def label_size_mm(template: Template) -> 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 isinstance(template, SheetLayout):
+        return template.label_w_mm, template.label_h_mm
     if template in _SHEET_TEMPLATES:
         layout = _SHEET_TEMPLATES[template]
-        return layout[1], layout[2]
+        return layout.label_w_mm, layout.label_h_mm
+    if template in _SINGLE_LABEL_SIZES_MM:
+        return _SINGLE_LABEL_SIZES_MM[template]
     raise ValueError(f"Unknown label template: {template!r}")
 
 
-def get_sheet_capacity(template: TemplateName) -> int | None:
+def get_sheet_capacity(template: Template) -> int | None:
     """Return the number of slots on a sheet template, or ``None`` for roll labels."""
-    layout = _SHEET_TEMPLATES.get(template)
-    if layout is None:
+    layout = _sheet_layout(template)
+    return None if layout is None else layout.capacity
+
+
+def sheet_page_count(template: Template, label_count: int, starting_position: int = 1) -> int | None:
+    """Pages a sheet template prints ``label_count`` labels on, or ``None`` for
+    roll labels (one label-sized page each)."""
+    capacity = get_sheet_capacity(template)
+    if capacity is None:
         return None
-    return layout[3] * layout[4]
+    return -(-(starting_position - 1 + label_count) // capacity)
 
 
 def _render_sheet_pdf(
-    template: TemplateName,
+    layout: SheetLayout,
+    title: str,
     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]
+    problem = layout.problem()
+    if problem:
+        raise ValueError(problem)
+    page_size = layout.page_size
     page_w, page_h = page_size
+    cols = layout.cols
 
-    label_w = w_mm * mm
-    label_h = h_mm * mm
-    top_margin = top_mm * mm
-    left_margin = left_mm * mm
-    col_gap = col_gap_mm * mm
-    row_gap = row_gap_mm * mm
+    label_w = layout.label_w_mm * mm
+    label_h = layout.label_h_mm * mm
+    top_margin = layout.top_mm * mm
+    left_margin = layout.left_mm * mm
+    col_gap = layout.col_gap_mm * mm
+    row_gap = layout.row_gap_mm * mm
 
     buf = io.BytesIO()
     c = rl_canvas.Canvas(buf, pagesize=page_size)
-    c.setTitle(f"Bambuddy spool labels ({template})")
+    c.setTitle(f"Bambuddy spool labels ({title})")
 
-    per_page = cols * rows
+    per_page = layout.capacity
     if starting_position < 1 or starting_position > per_page:
-        raise ValueError(f"Starting position must be between 1 and {per_page} for {template}")
+        raise ValueError(f"Starting position must be between 1 and {per_page} for {title}")
 
     data_index = 0
     page_number = 0
@@ -588,14 +689,15 @@ def _render_sheet_pdf(
 
 
 def render_labels(
-    template: TemplateName,
+    template: Template,
     data_list: list[LabelData],
     *,
     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.
+    """Render ``data_list`` to a PDF using ``template``: a built-in template
+    name, or a ``SheetLayout`` for a custom sheet (#3296). Returns bytes.
 
     Empty ``data_list`` still produces a valid (empty) PDF — callers should
     short-circuit beforehand if that's not desired.
@@ -609,17 +711,19 @@ def render_labels(
 
     ``fields`` names the lines to print (#2981); see ``ALL_LABEL_FIELDS``.
     """
+    if isinstance(template, SheetLayout):
+        return _render_sheet_pdf(template, _template_title(template), data_list, monochrome, starting_position, fields)
+    if template in _SHEET_TEMPLATES:
+        return _render_sheet_pdf(_SHEET_TEMPLATES[template], template, data_list, monochrome, starting_position, 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, fields)
-    if template in _SHEET_TEMPLATES:
-        return _render_sheet_pdf(template, data_list, monochrome, starting_position, fields)
     raise ValueError(f"Unknown label template: {template!r}")
 
 
 def render_label_preview_pdf(
-    template: TemplateName,
+    template: Template,
     data: LabelData,
     *,
     monochrome: bool = False,
@@ -675,6 +779,10 @@ __all__ = [
     "DEFAULT_LABEL_FIELDS",
     "LabelData",
     "LabelField",
+    "PageSize",
+    "sheet_page_count",
+    "SheetLayout",
+    "Template",
     "TemplateName",
     "get_sheet_capacity",
     "label_size_mm",

+ 216 - 0
backend/tests/integration/test_label_templates_api.py

@@ -0,0 +1,216 @@
+"""Integration tests for saved label templates (#3296).
+
+``/label-templates`` lists, saves, replaces and deletes named sheet layouts.
+Listing needs ``inventory:read``; the writes need ``inventory:update``, like
+storage locations.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+# A 3 × 8 sheet of 70 × 36 mm labels on A4, edge to edge (Avery 3490).
+LAYOUT_3490 = {
+    "page": "a4",
+    "label_w_mm": 70.0,
+    "label_h_mm": 36.0,
+    "cols": 3,
+    "rows": 8,
+    "top_mm": 4.5,
+    "left_mm": 0.0,
+}
+
+
+def _template(name: str = "Avery 3490", **layout) -> dict:
+    return {"name": name, "layout": {**LAYOUT_3490, **layout}}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_crud_round_trip(async_client: AsyncClient):
+    created = await async_client.post("/api/v1/label-templates", json=_template())
+    assert created.status_code == 201, created.text
+    body = created.json()
+    assert body["name"] == "Avery 3490"
+    # Gaps omitted from the request are stored as zero and returned as such.
+    assert body["layout"] == {**LAYOUT_3490, "col_gap_mm": 0.0, "row_gap_mm": 0.0}
+
+    listed = await async_client.get("/api/v1/label-templates")
+    assert [t["id"] for t in listed.json()] == [body["id"]]
+
+    replaced = await async_client.put(
+        f"/api/v1/label-templates/{body['id']}",
+        json=_template("Avery 3490 (shop)", rows=7, row_gap_mm=2.0),
+    )
+    assert replaced.status_code == 200, replaced.text
+    assert replaced.json()["name"] == "Avery 3490 (shop)"
+    assert replaced.json()["layout"]["rows"] == 7
+    assert replaced.json()["layout"]["row_gap_mm"] == 2.0
+
+    deleted = await async_client.delete(f"/api/v1/label-templates/{body['id']}")
+    assert deleted.status_code == 204
+    assert (await async_client.get("/api/v1/label-templates")).json() == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_list_sorts_naturally(async_client: AsyncClient):
+    for name in ("Sheet 10", "sheet 2", "Sheet 1"):
+        assert (await async_client.post("/api/v1/label-templates", json=_template(name))).status_code == 201
+    names = [t["name"] for t in (await async_client.get("/api/v1/label-templates")).json()]
+    assert names == ["Sheet 1", "sheet 2", "Sheet 10"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_names_are_unique_ignoring_case_and_spaces(async_client: AsyncClient):
+    assert (await async_client.post("/api/v1/label-templates", json=_template("Avery 3490"))).status_code == 201
+    dup = await async_client.post("/api/v1/label-templates", json=_template("  avery 3490 "))
+    assert dup.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_rename_onto_another_template_is_refused(async_client: AsyncClient):
+    await async_client.post("/api/v1/label-templates", json=_template("First"))
+    second = (await async_client.post("/api/v1/label-templates", json=_template("Second"))).json()
+    resp = await async_client.put(f"/api/v1/label-templates/{second['id']}", json=_template("FIRST"))
+    assert resp.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_changing_only_the_case_of_its_own_name_is_allowed(async_client: AsyncClient):
+    created = (await async_client.post("/api/v1/label-templates", json=_template("avery 3490"))).json()
+    resp = await async_client.put(f"/api/v1/label-templates/{created['id']}", json=_template("Avery 3490"))
+    assert resp.status_code == 200
+    assert resp.json()["name"] == "Avery 3490"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize(
+    "layout",
+    [
+        {"cols": 4},  # 4 × 70 mm is wider than A4
+        {"rows": 9},  # 4.5 + 9 × 36 mm is taller than A4
+        {"left_mm": 1.0},  # pushes the third column 1 mm off the page
+        {"label_w_mm": 39.0},  # below the 40 mm a label needs for its text
+        {"label_h_mm": 19.0},  # below the 20 mm of the full layout
+        {"cols": 0},
+        {"top_mm": -1.0},
+        {"page": "a3"},
+    ],
+)
+async def test_a_layout_that_cannot_print_is_refused(async_client: AsyncClient, layout):
+    resp = await async_client.post("/api/v1/label-templates", json=_template(**layout))
+    assert resp.status_code == 422, resp.text
+    assert (await async_client.get("/api/v1/label-templates")).json() == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_names_match_whatever_the_unicode_composition(async_client: AsyncClient):
+    composed = "Caf\u00e9 sheet"
+    combining = "Cafe\u0301 sheet"
+    assert (await async_client.post("/api/v1/label-templates", json=_template(composed))).status_code == 201
+    assert (await async_client.post("/api/v1/label-templates", json=_template(combining))).status_code == 409
+    assert (await async_client.post("/api/v1/label-templates", json=_template("CAFÉ SHEET"))).status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_name_that_casefolds_longer_still_fits(async_client: AsyncClient):
+    # "İ" lowercases to two characters; 100 of them make a 200-character key.
+    resp = await async_client.post("/api/v1/label-templates", json=_template("\u0130" * 100))
+    assert resp.status_code == 201, resp.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_blank_name_is_refused(async_client: AsyncClient):
+    resp = await async_client.post("/api/v1/label-templates", json=_template("   "))
+    assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_unknown_id_is_404(async_client: AsyncClient):
+    assert (await async_client.put("/api/v1/label-templates/99999", json=_template())).status_code == 404
+    assert (await async_client.delete("/api/v1/label-templates/99999")).status_code == 404
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_routes_require_auth_when_enabled(async_client: AsyncClient):
+    """Every route fails closed without credentials once auth is on."""
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "labeltpl3296admin", "admin_password": "AdminPass1!"},
+    )
+    assert (await async_client.get("/api/v1/label-templates")).status_code == 401
+    assert (await async_client.post("/api/v1/label-templates", json=_template())).status_code == 401
+    # The auth gate runs before the not-found check, so a synthetic id is enough.
+    assert (await async_client.put("/api/v1/label-templates/99999", json=_template())).status_code == 401
+    assert (await async_client.delete("/api/v1/label-templates/99999")).status_code == 401
+
+
+class TestPermissions:
+    """Operators can manage saved templates; Viewers can only list them."""
+
+    @pytest.fixture
+    async def tokens(self, db_session):
+        from sqlalchemy import select
+
+        from backend.app.core.auth import create_access_token, get_password_hash
+        from backend.app.models.group import Group
+        from backend.app.models.settings import Settings
+        from backend.app.models.user import User
+
+        db_session.add(Settings(key="auth_enabled", value="true"))
+        await db_session.commit()
+
+        tokens = {}
+        for group_name in ("Operators", "Viewers"):
+            group = (await db_session.execute(select(Group).where(Group.name == group_name))).scalar_one()
+            user = User(
+                username=f"labeltpl_{group_name.lower()}",
+                password_hash=get_password_hash("password"),
+                is_active=True,
+            )
+            user.groups.append(group)
+            db_session.add(user)
+            await db_session.commit()
+            tokens[group_name] = {"Authorization": f"Bearer {create_access_token(data={'sub': user.username})}"}
+        return tokens
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_manage_templates(self, async_client: AsyncClient, tokens):
+        headers = tokens["Operators"]
+        created = await async_client.post("/api/v1/label-templates", json=_template(), headers=headers)
+        assert created.status_code == 201, created.text
+        template_id = created.json()["id"]
+        put = await async_client.put(f"/api/v1/label-templates/{template_id}", json=_template("B"), headers=headers)
+        assert put.status_code == 200
+        delete = await async_client.delete(f"/api/v1/label-templates/{template_id}", headers=headers)
+        assert delete.status_code == 204
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_viewer_can_list_but_not_change_templates(self, async_client: AsyncClient, tokens):
+        created = await async_client.post("/api/v1/label-templates", json=_template(), headers=tokens["Operators"])
+        template_id = created.json()["id"]
+
+        viewer = tokens["Viewers"]
+        listed = await async_client.get("/api/v1/label-templates", headers=viewer)
+        assert listed.status_code == 200
+        assert [t["id"] for t in listed.json()] == [template_id]
+
+        assert (
+            await async_client.post("/api/v1/label-templates", json=_template("C"), headers=viewer)
+        ).status_code == 403
+        put = await async_client.put(f"/api/v1/label-templates/{template_id}", json=_template("C"), headers=viewer)
+        assert put.status_code == 403
+        assert (await async_client.delete(f"/api/v1/label-templates/{template_id}", headers=viewer)).status_code == 403

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

@@ -574,3 +574,148 @@ class TestSpoolmanLabelParity:
     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
+
+
+# ── Custom sheets (#3296) ────────────────────────────────────────────────────
+
+# A 3 × 8 sheet of 70 × 36 mm labels on A4, edge to edge (Avery 3490).
+CUSTOM_LAYOUT = {
+    "page": "a4",
+    "label_w_mm": 70.0,
+    "label_h_mm": 36.0,
+    "cols": 3,
+    "rows": 8,
+    "top_mm": 4.5,
+    "left_mm": 0.0,
+}
+
+
+class TestCustomSheet:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_layout_reaches_the_renderer(self, async_client: AsyncClient, spool_factory):
+        from backend.app.services.label_renderer import SheetLayout
+
+        spool = await spool_factory()
+        captured: dict = {}
+
+        def _capture(template, data_list, **kwargs):
+            captured["template"] = template
+            captured.update(kwargs)
+            return b"%PDF-1.4\n%%EOF\n"
+
+        with patch("backend.app.api.routes.labels.render_labels", side_effect=_capture):
+            resp = await async_client.post(
+                "/api/v1/inventory/labels",
+                json={"spool_ids": [spool.id], "template": "custom", "layout": CUSTOM_LAYOUT, "starting_position": 24},
+            )
+        assert resp.status_code == 200, resp.text
+        assert captured["template"] == SheetLayout(**CUSTOM_LAYOUT)
+        assert captured["starting_position"] == 24
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_renders_a_pdf_and_page_pngs(self, async_client: AsyncClient, spool_factory):
+        import io
+        import zipfile
+
+        s1 = await spool_factory()
+        s2 = await spool_factory()
+        body = {"spool_ids": [s1.id, s2.id], "template": "custom", "layout": CUSTOM_LAYOUT}
+
+        pdf = await async_client.post("/api/v1/inventory/labels", json=body)
+        assert pdf.status_code == 200, pdf.text
+        assert pdf.content.startswith(b"%PDF")
+        assert "bambuddy-labels-custom.pdf" in pdf.headers["content-disposition"]
+
+        # 24 per page; starting at 24 puts the second label on page two.
+        pngs = await async_client.post(
+            "/api/v1/inventory/labels", json={**body, "format": "png", "starting_position": 24}
+        )
+        assert pngs.status_code == 200
+        with zipfile.ZipFile(io.BytesIO(pngs.content)) as zf:
+            assert zf.namelist() == ["sheet-1.png", "sheet-2.png"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preview_is_one_cell(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": "custom", "layout": CUSTOM_LAYOUT},
+        )
+        assert resp.status_code == 200, resp.text
+        assert resp.content.startswith(b"\x89PNG")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolman_spools_print_on_a_custom_sheet(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([TestSpoolmanLabelParity.SPOOL])),
+        ):
+            resp = await async_client.post(
+                "/api/v1/spoolman/labels",
+                json={"spool_ids": [7], "template": "custom", "layout": CUSTOM_LAYOUT},
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.content.startswith(b"%PDF")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "body",
+        [
+            {"template": "custom"},  # no layout
+            {"template": "avery_l7160", "layout": CUSTOM_LAYOUT},  # layout with a built-in
+            {"template": "custom", "layout": {**CUSTOM_LAYOUT, "left_mm": 1.0}},  # off the page
+            {"template": "custom", "layout": CUSTOM_LAYOUT, "starting_position": 25},  # past 3 × 8
+            {"template": "custom", "layout": {**CUSTOM_LAYOUT, "label_w_mm": 39.0}},  # too small for text
+        ],
+    )
+    async def test_invalid_custom_requests_are_refused(self, async_client: AsyncClient, spool_factory, body):
+        spool = await spool_factory()
+        resp = await async_client.post("/api/v1/inventory/labels", json={"spool_ids": [spool.id], **body})
+        assert resp.status_code == 422, resp.text
+
+
+class TestPngSheetCap:
+    """PNG export of sheets is capped at pages × dpi, so a one-label-per-page
+    custom sheet can't hold the PDFium lock for minutes."""
+
+    ONE_PER_PAGE = {**CUSTOM_LAYOUT, "label_w_mm": 70.0, "label_h_mm": 36.0, "cols": 1, "rows": 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_too_many_pages_at_the_resolution_is_refused(self, async_client: AsyncClient):
+        # 26 pages at 600 dpi is over the 25 allowed; no spool is looked up first.
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            json={
+                "spool_ids": list(range(1, 27)),
+                "template": "custom",
+                "layout": self.ONE_PER_PAGE,
+                "format": "png",
+                "dpi": 600,
+            },
+        )
+        assert resp.status_code == 422
+        assert "25 sheet pages at 600 dpi" in resp.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_same_pages_as_pdf_or_at_a_lower_resolution_are_allowed(self, async_client: AsyncClient):
+        from backend.app.api.routes.labels import LabelRequest
+
+        ids = list(range(1, 27))
+        LabelRequest(spool_ids=ids, template="custom", layout=self.ONE_PER_PAGE, format="pdf")
+        LabelRequest(spool_ids=ids, template="custom", layout=self.ONE_PER_PAGE, format="png", dpi=300)
+
+    def test_the_built_in_sheets_always_fit(self):
+        from backend.app.api.routes.labels import MAX_LABELS_PER_REQUEST, LabelRequest
+
+        ids = list(range(1, MAX_LABELS_PER_REQUEST + 1))
+        for template in ("avery_5160", "avery_l7160"):
+            LabelRequest(spool_ids=ids, template=template, format="png", dpi=600)

+ 165 - 1
backend/tests/unit/services/test_label_renderer.py

@@ -8,7 +8,13 @@ import pytest
 from reportlab.lib.pagesizes import letter
 from reportlab.lib.units import mm
 
-from backend.app.services.label_renderer import LabelData, render_labels
+from backend.app.services.label_renderer import (
+    LabelData,
+    SheetLayout,
+    get_sheet_capacity,
+    label_size_mm,
+    render_labels,
+)
 
 ALL_TEMPLATES = (
     "ams_holder_74x33",
@@ -417,3 +423,161 @@ def test_roomy_qr_size_has_floor_for_narrow_labels():
     inner_h_big = 55 * mm - 2 * pad
     size_big = _roomy_qr_size(inner_w_big, inner_h_big)
     assert 12 * mm <= size_big <= 18 * mm
+
+
+# ── Custom sheet layouts (#3296) ──
+
+
+def _layout(**overrides) -> SheetLayout:
+    # A 3 × 8 sheet of 70 × 36 mm labels on A4, edge to edge (Avery 3490).
+    fields = {
+        "page": "a4",
+        "label_w_mm": 70.0,
+        "label_h_mm": 36.0,
+        "cols": 3,
+        "rows": 8,
+        "top_mm": 4.5,
+        "left_mm": 0.0,
+    }
+    fields.update(overrides)
+    return SheetLayout(**fields)
+
+
+def test_a_layout_that_fills_the_page_exactly_is_printable():
+    assert _layout().problem() is None
+    assert _layout().capacity == 24
+
+
+def test_inch_based_sheet_within_rounding_still_fits():
+    # Avery 5160 measured in inches: 3 × 2.625" + 2 × 0.125" gaps + 0.1875" margin
+    # comes to 215.9 mm, Letter's full width, give or take float rounding.
+    layout = SheetLayout(
+        "letter", 2.625 * 25.4, 25.4, 3, 10, top_mm=12.7, left_mm=0.1875 * 25.4, col_gap_mm=0.125 * 25.4
+    )
+    assert layout.problem() is None
+
+
+@pytest.mark.parametrize(
+    ("overrides", "message"),
+    [
+        ({"left_mm": 1.0}, "wide"),
+        ({"rows": 9}, "tall"),
+        ({"row_gap_mm": 2.0}, "tall"),
+        ({"label_h_mm": 19.9}, "at least"),
+        ({"label_w_mm": 39.9, "cols": 1}, "at least"),
+        ({"col_gap_mm": 300.5, "cols": 1}, "at most"),
+        ({"cols": 0}, "Columns"),
+        ({"rows": 41, "label_h_mm": 20.0, "top_mm": 0.0}, "Rows"),
+        ({"col_gap_mm": -0.5, "cols": 2}, "negative"),
+        ({"page": "a3"}, "page size"),
+    ],
+)
+def test_layout_problems_are_named(overrides, message):
+    assert message in _layout(**overrides).problem()
+
+
+def test_letter_width_matches_the_dialog_to_the_hundredth():
+    # 215.9 mm is Letter's width on both sides of the API; 215.91 is not.
+    fits = SheetLayout("letter", 107.95, 25.4, 2, 1, top_mm=0.0, left_mm=0.0)
+    over = SheetLayout("letter", 107.95, 25.4, 2, 1, top_mm=0.0, left_mm=0.0, col_gap_mm=0.02)
+    assert fits.problem() is None
+    assert "wide" in over.problem()
+
+
+@pytest.mark.parametrize("label_h_mm", [20.0, 22.0, 25.0, 30.0, 40.0, 60.0, 100.0, 280.0])
+def test_the_smallest_label_has_room_for_a_full_size_spool_id(label_h_mm):
+    """At the minimum width, a colour label with a QR keeps a text column wide
+    enough for a four-digit spool ID at its full 16 pt — at any height, since
+    the QR grows with the label. At the minimum height the roomy layout is used."""
+    from reportlab.pdfbase.pdfmetrics import stringWidth
+
+    from backend.app.services.label_renderer import MIN_LABEL_H_MM, MIN_LABEL_W_MM, _roomy_qr_size
+
+    pad = 1.2 * mm
+    inner_w = MIN_LABEL_W_MM * mm - 2 * pad
+    inner_h = label_h_mm * mm - 2 * pad
+    swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
+    qr = _roomy_qr_size(inner_w, inner_h)
+    text_w = inner_w - swatch_w - qr - 2 * 1.5 * mm
+    assert text_w >= stringWidth("#9999", "Helvetica-Bold", 16)
+    assert MIN_LABEL_H_MM >= 20.0  # the tight layout starts below 20 mm
+
+
+def test_the_smallest_label_prints_the_spool_id():
+    from backend.app.services.label_renderer import MIN_LABEL_H_MM, MIN_LABEL_W_MM
+
+    layout = _layout(label_w_mm=MIN_LABEL_W_MM, label_h_mm=MIN_LABEL_H_MM, cols=1, rows=1)
+    pdf = _render_uncompressed_layout(layout, [_sample(9999)])
+    assert b"(#9999)" in pdf
+
+
+def _render_uncompressed_layout(layout, data):
+    import io as _io
+
+    from reportlab.pdfgen import canvas as _rl_canvas
+
+    from backend.app.services.label_renderer import _draw_label  # noqa: PLC0415
+
+    buf = _io.BytesIO()
+    w, h = layout.label_w_mm * mm, layout.label_h_mm * mm
+    c = _rl_canvas.Canvas(buf, pagesize=(w, h), pageCompression=0)
+    for d in data:
+        _draw_label(c, 0, 0, w, h, d)
+        c.showPage()
+    c.save()
+    return buf.getvalue()
+
+
+@pytest.mark.parametrize(
+    ("label_count", "starting_position", "pages"),
+    [(24, 1, 1), (25, 1, 2), (1, 24, 1), (2, 24, 2), (48, 1, 2)],
+)
+def test_sheet_page_count(label_count, starting_position, pages):
+    from backend.app.services.label_renderer import sheet_page_count
+
+    assert sheet_page_count(_layout(), label_count, starting_position) == pages
+
+
+def test_roll_labels_have_no_sheet_page_count():
+    from backend.app.services.label_renderer import sheet_page_count
+
+    assert sheet_page_count("box_40x30", 10) is None
+
+
+def test_render_refuses_a_layout_that_does_not_fit():
+    with pytest.raises(ValueError, match="wide"):
+        render_labels(_layout(left_mm=1.0), [_sample()])
+
+
+def test_custom_layout_places_labels_with_gaps():
+    layout = _layout(
+        page="letter",
+        label_w_mm=60.0,
+        label_h_mm=30.0,
+        cols=2,
+        rows=3,
+        top_mm=10.0,
+        left_mm=5.0,
+        col_gap_mm=4.0,
+        row_gap_mm=2.0,
+    )
+    data = [_sample(i) for i in range(1, 8)]
+    with patch("backend.app.services.label_renderer._draw_label") as draw_label:
+        render_labels(layout, data, starting_position=2)
+
+    calls = [call.args for call in draw_label.call_args_list]
+    # Slot 2 is the top-right cell of page one.
+    assert calls[0][1] == pytest.approx(5.0 * mm + 64.0 * mm)
+    assert calls[0][2] == pytest.approx(letter[1] - 10.0 * mm - 30.0 * mm)
+    assert calls[0][3:5] == (pytest.approx(60.0 * mm), pytest.approx(30.0 * mm))
+    # Slot 6 is the bottom-right cell: two row gaps above it.
+    assert calls[4][2] == pytest.approx(letter[1] - 10.0 * mm - 3 * 30.0 * mm - 2 * 2.0 * mm)
+    # The sixth label starts page two at the top-left.
+    assert calls[5][1:3] == (pytest.approx(5.0 * mm), pytest.approx(letter[1] - 10.0 * mm - 30.0 * mm))
+
+
+def test_custom_layout_size_and_capacity():
+    layout = _layout()
+    assert label_size_mm(layout) == (70.0, 36.0)
+    assert get_sheet_capacity(layout) == 24
+    assert render_labels(layout, [_sample()]).startswith(b"%PDF")

+ 1 - 0
backend/tests/unit/test_not_null_update_schemas.py

@@ -20,6 +20,7 @@ NOT_A_ROW_UPDATE = {
     "ManyfoldConfigUpdate": "stored as settings",
     "CostCenterBudgetUpdateRequest": "replaces the budget rows as a whole",
     "AmsLabelBody": "upserts a label, every field required",
+    "LabelTemplateIn": "replaces a saved label template as a whole, every field required",
     "LinkTagRequest": "links a tag through its own rules",
     "ShoppingListItemStatusUpdate": "one required field",
     "ExternalLinkReorder": "a list of ids",

+ 11 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -141,6 +141,7 @@ function isAlwaysAllowedIdentical(value) {
 // German loanwords / cognates from English are extensive. Most short technical
 // UI labels are identical in DE. List below curates the legitimate ones.
 const DE_COGNATES = [
+  'A4 (210 × 297 mm)',  // #3296 page size name in the custom label sheet form — ISO name and metric size, written the same
   'Apps',  // notifications badge for messages from connected apps — same word
   '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the DE term too
   'Auto',  // calibrationMode_auto — German UI uses the loanword (matches BambuStudio DE)
@@ -181,6 +182,7 @@ const DE_COGNATES = [
 
 // French cognates — many UI labels overlap with English exactly.
 const FR_COGNATES = [
+  'A4 (210 × 297 mm)',  // #3296 page size name in the custom label sheet form — ISO name and metric size, written the same
   'Apps',  // notifications badge for messages from connected apps — same word
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
@@ -232,6 +234,7 @@ const FR_COGNATES = [
 
 // Italian cognates.
 const IT_COGNATES = [
+  'A4 (210 × 297 mm)',  // #3296 page size name in the custom label sheet form — ISO name and metric size, written the same
   '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the IT term too
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
@@ -281,6 +284,7 @@ const JA_COGNATES = [
 
 // Portuguese (BR) cognates.
 const PT_BR_COGNATES = [
+  'A4 (210 × 297 mm)',  // #3296 page size name in the custom label sheet form — ISO name and metric size, written the same
   'Apps',  // notifications badge for messages from connected apps — same word
   '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the PT-BR term too
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
@@ -338,6 +342,7 @@ const ZH_TW_COGNATES = [
 // Korean: script difference means almost nothing is identical.
 // Allow loanwords/acronyms, format strings, and proper nouns that stay verbatim.
 const KO_COGNATES = [
+  'A4 (210 × 297 mm)',  // #3296 page size name in the custom label sheet form — ISO name and metric size, written the same
   'OK', 'Bambu', 'N/A',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   '({{count}}/8)', '(25%, 50%, 75%)',
@@ -357,6 +362,7 @@ const KO_COGNATES = [
 
 // Spanish cognates — words/phrases that are genuinely identical in Spanish.
 const ES_COGNATES = [
+  'A4 (210 × 297 mm)',  // #3296 page size name in the custom label sheet form — ISO name and metric size, written the same
   'Apps',  // notifications badge for messages from connected apps — same word
   '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the ES term too
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
@@ -382,6 +388,7 @@ const ES_COGNATES = [
 // Swedish cognates — technical UI labels that Swedish speakers use verbatim
 // from English (loanwords + acronyms + format strings). Curated, not a shortcut.
 const SV_COGNATES = [
+  'A4 (210 × 297 mm)',  // #3296 page size name in the custom label sheet form — ISO name and metric size, written the same
   'MakerWorld: {{designer}}',
   'email',
   '{{printer}}: {{error}}',
@@ -402,6 +409,7 @@ const SV_COGNATES = [
 // Turkish cognates — technical UI labels that Turkish speakers use verbatim
 // from English (loanwords + acronyms + format strings). Curated, not a shortcut.
 const TR_COGNATES = [
+  'A4 (210 × 297 mm)',  // #3296 page size name in the custom label sheet form — ISO name and metric size, written the same
   '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the TR term too
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
@@ -449,6 +457,8 @@ const UK_COGNATES = [
 // are used untranslated by Dutch slicer users. Each entry below was
 // checked individually against the Dutch translation in #2891.
 const NL_COGNATES = [
+  '{{page}} · {{width}} × {{height}} mm · {{capacity}} labels ({{cols}} × {{rows}})',  // #3296 custom sheet summary — "labels" is the Dutch word too
+  'A4 (210 × 297 mm)',  // #3296 page size name in the custom label sheet form — ISO name and metric size, written the same
   'Project:',  // archive page label
   'Apps',  // notifications badge for messages from connected apps — same word
   '1 printer', '{{n}} printers',
@@ -515,6 +525,7 @@ const PL_COGNATES = [
   'CallMeBot/WhatsApp', 'ntfy', 'Pushover', 'Telegram', 'Discord', 'Webhook', 'Bark', 'Gotify', 'Start',
   'Test', 'Hex: #{{hex}}', 'BamBuddy', '-', 'Tag', 'Host', 'Manyfold', 'http://192.168.1.10:3214',
   'Skirt / brim', 'Folder', 'SKU', 'Trend', 'ROP', 'SS', '{{count}} alert', 'CSV',
+  'A4 (210 × 297 mm)', 'Letter',  // paper formats, as Polish names them
 ];
 
 const IDENTICAL_TO_EN_ALLOWED = {

+ 38 - 0
frontend/src/__tests__/components/LabelTemplatePickerKeys.test.ts

@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import en from '../../i18n/locales/en';
+
+// The dialog's tests render without i18next, so a key in the wrong place in
+// the locale files only shows as the English fallback in every language and no
+// test sees it (#3296 review). Every key the label picker names literally has
+// to resolve in en.ts; the parity check carries it to the other locales.
+const SOURCES = [
+  'components/LabelTemplatePickerModal.tsx',
+  'components/LabelSheetLayoutEditor.tsx',
+  'utils/labelSheetLayout.ts',
+];
+
+function resolveKey(path: string): unknown {
+  return path.split('.').reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], en);
+}
+
+describe('label picker translation keys', () => {
+  const keys = new Set<string>();
+  for (const file of SOURCES) {
+    const source = readFileSync(resolve(__dirname, '../..', file), 'utf8');
+    for (const match of source.matchAll(/\bt\(\s*'(inventory\.labels\.[\w.]+)'/g)) keys.add(match[1]);
+  }
+  // The layout problems are looked up as inventory.labels.custom.problems.<key>.
+  const utils = readFileSync(resolve(__dirname, '../../utils/labelSheetLayout.ts'), 'utf8');
+  for (const match of utils.matchAll(/key: '(\w+)'/g)) keys.add(`inventory.labels.custom.problems.${match[1]}`);
+
+  it('finds the keys it checks', () => {
+    expect(keys).toContain('inventory.labels.custom.nameTaken');
+    expect(keys).toContain('inventory.labels.custom.problems.tooSmall');
+  });
+
+  it.each([...keys])('%s is a string in en.ts', (key) => {
+    expect(typeof resolveKey(key)).toBe('string');
+  });
+});

+ 407 - 3
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -1,9 +1,10 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor, fireEvent, act } from '@testing-library/react';
+import { focusManager } from '@tanstack/react-query';
 import { render } from '../utils';
 import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
 import { LabelTemplatePickerModal } from '../../components/LabelTemplatePickerModal';
-import { api } from '../../api/client';
+import { api, ApiError, setAuthToken } from '../../api/client';
 
 vi.mock('../../api/client', async (importOriginal) => ({
   // The field lists are real exports the modal renders from.
@@ -15,6 +16,12 @@ vi.mock('../../api/client', async (importOriginal) => ({
     previewSpoolmanSpoolLabel: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    getCurrentUser: vi.fn(),
+    getUiSettings: vi.fn().mockResolvedValue({}),
+    getLabelTemplates: vi.fn(),
+    createLabelTemplate: vi.fn(),
+    updateLabelTemplate: vi.fn(),
+    deleteLabelTemplate: vi.fn(),
   },
 }));
 
@@ -63,6 +70,7 @@ beforeEach(() => {
   vi.mocked(api.previewSpoolmanSpoolLabel).mockResolvedValue(PNG_BLOB);
   vi.mocked(api.getSettings).mockResolvedValue({} as never);
   vi.mocked(api.getAuthStatus).mockResolvedValue({ auth_enabled: false } as never);
+  vi.mocked(api.getLabelTemplates).mockResolvedValue([]);
   Object.defineProperty(window.URL, 'createObjectURL', {
     value: vi.fn(() => 'blob:mock'),
     configurable: true,
@@ -392,6 +400,7 @@ describe('LabelTemplatePickerModal', () => {
       'box_62x29',
       'avery_l7160',
       'avery_5160',
+      'custom',
     ]);
     expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
     expect(screen.getByTestId('label-template-picker-panel')).toHaveClass('max-h-[90vh]');
@@ -796,3 +805,398 @@ describe('PNG output (#2981)', () => {
     expect(screen.getByTestId('label-dpi-select')).toHaveValue('300');
   });
 });
+
+describe('custom sheets and saved templates (#3296)', () => {
+  // A 3 × 8 sheet of 70 × 36 mm labels on A4, edge to edge (Avery 3490).
+  const LAYOUT_3490 = {
+    page: 'a4',
+    label_w_mm: 70,
+    label_h_mm: 36,
+    cols: 3,
+    rows: 8,
+    top_mm: 4.5,
+    left_mm: 0,
+    col_gap_mm: 0,
+    row_gap_mm: 0,
+  } as const;
+  const SAVED = {
+    id: 5,
+    name: 'Avery 3490',
+    layout: LAYOUT_3490,
+    created_at: '2026-10-25T00:00:00Z',
+    updated_at: '2026-10-25T00:00:00Z',
+  };
+
+  const openModal = (spoolmanMode = false) =>
+    render(
+      <LabelTemplatePickerModal
+        isOpen
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={spoolmanMode}
+      />,
+    );
+
+  function typeLayout(layout: Record<string, string | number>) {
+    for (const [field, value] of Object.entries(layout)) {
+      if (field === 'page') {
+        fireEvent.change(screen.getByTestId('label-sheet-page'), { target: { value } });
+      } else {
+        fireEvent.change(screen.getByTestId(`label-sheet-${field}`), { target: { value: String(value) } });
+      }
+    }
+  }
+
+  it('prints a one-off custom sheet with its layout', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    openModal();
+    pickTemplate('custom');
+    typeLayout(LAYOUT_3490);
+    fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '24' } });
+    clickPrint();
+
+    await waitFor(() =>
+      expect(api.printSpoolLabels).toHaveBeenCalledWith(
+        printRequest({ spool_ids: [1], template: 'custom', layout: LAYOUT_3490, starting_position: 24 }),
+      ),
+    );
+  });
+
+  it('bounds the starting position by the custom grid', () => {
+    openModal();
+    pickTemplate('custom');
+    typeLayout(LAYOUT_3490);
+    fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '25' } });
+
+    expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent('1 to 24');
+    expect(screen.getByTestId('label-print-button')).toBeDisabled();
+  });
+
+  it('explains a sheet that runs off the page and will not print it', async () => {
+    openModal();
+    pickTemplate('custom');
+    typeLayout({ ...LAYOUT_3490, left_mm: 1 });
+
+    expect(screen.getByTestId('label-sheet-problem')).toHaveTextContent('211 mm wide');
+    expect(screen.getAllByTestId('label-sheet-cell-off-page')).toHaveLength(8);
+    expect(screen.getByTestId('label-print-button')).toBeDisabled();
+    expect(screen.getByTestId('label-preview')).toHaveTextContent(/sheet layout is complete/);
+  });
+
+  it('asks for every field before drawing or printing', () => {
+    openModal();
+    pickTemplate('custom');
+    typeLayout({ rows: '' });
+
+    expect(screen.getByTestId('label-sheet-problem')).toHaveTextContent(/every field/);
+    expect(screen.queryByTestId('label-sheet-diagram')).not.toBeInTheDocument();
+    expect(screen.getByTestId('label-print-button')).toBeDisabled();
+  });
+
+  it('previews a custom sheet with its layout', async () => {
+    openModal();
+    pickTemplate('custom');
+    typeLayout(LAYOUT_3490);
+
+    await waitFor(() =>
+      expect(api.previewSpoolLabel).toHaveBeenLastCalledWith(
+        expect.objectContaining({ template: 'custom', layout: LAYOUT_3490 }),
+        expect.anything(),
+      ),
+    );
+  });
+
+  it('remembers the custom sheet for next time', () => {
+    const first = openModal();
+    pickTemplate('custom');
+    typeLayout({ label_w_mm: '70', rows: '8' });
+    first.unmount();
+
+    openModal();
+    expect(screen.getByTestId('label-template-select')).toHaveValue('custom');
+    expect(screen.getByTestId('label-sheet-label_w_mm')).toHaveValue('70');
+    expect(screen.getByTestId('label-sheet-rows')).toHaveValue('8');
+  });
+
+  it('lists saved templates and prints with the saved layout', async () => {
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([SAVED]);
+    vi.mocked(api.printSpoolmanSpoolLabels).mockResolvedValue(PDF_BLOB);
+    openModal(true);
+
+    await screen.findByRole('option', { name: 'Avery 3490' });
+    pickTemplate('saved:5');
+    expect(screen.getByTestId('label-template-hint')).toHaveTextContent('24 labels (3 × 8)');
+    expect(screen.getByTestId('label-sheet-diagram')).toBeInTheDocument();
+    clickPrint();
+
+    await waitFor(() =>
+      expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith(
+        printRequest({ spool_ids: [1], template: 'custom', layout: LAYOUT_3490 }),
+      ),
+    );
+  });
+
+  it('saves a custom sheet under a name and switches to it', async () => {
+    vi.mocked(api.createLabelTemplate).mockResolvedValue(SAVED);
+    openModal();
+    pickTemplate('custom');
+    typeLayout(LAYOUT_3490);
+    fireEvent.click(screen.getByTestId('label-field-note'));
+    fireEvent.click(screen.getByTestId('label-template-save-as'));
+    fireEvent.change(screen.getByTestId('label-template-name'), { target: { value: '  Avery 3490 ' } });
+    // The list the dialog reloads after saving.
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([SAVED]);
+    fireEvent.click(screen.getByTestId('label-template-save'));
+
+    await waitFor(() =>
+      expect(api.createLabelTemplate).toHaveBeenCalledWith({ name: 'Avery 3490', layout: LAYOUT_3490 }),
+    );
+    await waitFor(() => expect(screen.getByTestId('label-template-select')).toHaveValue('saved:5'));
+    // The lines chosen for the custom sheet came along.
+    expect(screen.getByTestId('label-field-note')).toBeChecked();
+  });
+
+  it('edits a saved template in place or as a new one', async () => {
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([SAVED]);
+    const updated = { ...SAVED, layout: { ...LAYOUT_3490, rows: 7 } };
+    vi.mocked(api.updateLabelTemplate).mockResolvedValue(updated);
+    openModal();
+    await screen.findByRole('option', { name: 'Avery 3490' });
+    pickTemplate('saved:5');
+
+    fireEvent.click(screen.getByTestId('label-template-edit'));
+    typeLayout({ rows: '7' });
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([updated]);
+    fireEvent.click(screen.getByTestId('label-template-save-changes'));
+    await waitFor(() =>
+      expect(api.updateLabelTemplate).toHaveBeenCalledWith(5, {
+        name: 'Avery 3490',
+        layout: { ...LAYOUT_3490, rows: 7 },
+      }),
+    );
+
+    const copy = { ...updated, id: 6, name: 'Avery 3490 short' };
+    vi.mocked(api.createLabelTemplate).mockResolvedValue(copy);
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([updated, copy]);
+    await waitFor(() => expect(screen.getByTestId('label-template-edit')).toBeInTheDocument());
+    fireEvent.click(screen.getByTestId('label-template-edit'));
+    fireEvent.change(screen.getByTestId('label-template-name'), { target: { value: 'Avery 3490 short' } });
+    fireEvent.click(screen.getByTestId('label-template-save-as-new'));
+    await waitFor(() =>
+      expect(api.createLabelTemplate).toHaveBeenCalledWith({
+        name: 'Avery 3490 short',
+        layout: { ...LAYOUT_3490, rows: 7 },
+      }),
+    );
+    await waitFor(() => expect(screen.getByTestId('label-template-select')).toHaveValue('saved:6'));
+  });
+
+  it('deletes a saved template after confirming and goes back to the default', async () => {
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([SAVED]);
+    vi.mocked(api.deleteLabelTemplate).mockResolvedValue(undefined);
+    openModal();
+    await screen.findByRole('option', { name: 'Avery 3490' });
+    pickTemplate('saved:5');
+
+    fireEvent.click(screen.getByTestId('label-template-delete'));
+    expect(api.deleteLabelTemplate).not.toHaveBeenCalled();
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([]);
+    // The confirm dialog's button, not the link that opened it.
+    fireEvent.click(screen.getAllByRole('button', { name: 'Delete' }).at(-1)!);
+
+    await waitFor(() => expect(api.deleteLabelTemplate).toHaveBeenCalledWith(5));
+    await waitFor(() => expect(screen.getByTestId('label-template-select')).toHaveValue('ams_holder_74x33'));
+  });
+
+  it('falls back to the default when a remembered template is gone', async () => {
+    const store = new Map([['bambuddy-label-print-options', JSON.stringify({ template: 'saved:9' })]]);
+    vi.mocked(localStorage.getItem).mockImplementation((key) => store.get(key) ?? null);
+    openModal();
+
+    await waitFor(() => expect(api.getLabelTemplates).toHaveBeenCalled());
+    await waitFor(() => expect(screen.getByTestId('label-template-select')).toHaveValue('ams_holder_74x33'));
+  });
+
+  it('refuses a label too small to carry its text', () => {
+    openModal();
+    pickTemplate('custom');
+    typeLayout({ ...LAYOUT_3490, label_w_mm: '39', cols: '1' });
+
+    expect(screen.getByTestId('label-sheet-problem')).toHaveTextContent('at least 40 mm wide and 20 mm tall');
+    expect(screen.getByTestId('label-print-button')).toBeDisabled();
+  });
+
+  it('measures US Letter exactly as the server does', () => {
+    openModal();
+    pickTemplate('custom');
+    // Two 107.95 mm labels fill Letter's 215.9 mm; a 0.02 mm gap does not fit.
+    typeLayout({ ...LAYOUT_3490, page: 'letter', label_w_mm: '107.95', cols: '2', rows: '1', top_mm: '0' });
+    expect(screen.queryByTestId('label-sheet-problem')).not.toBeInTheDocument();
+
+    typeLayout({ col_gap_mm: '0.02' });
+    expect(screen.getByTestId('label-sheet-problem')).toHaveTextContent('215.92 mm wide');
+  });
+
+  it('caps every length as the server does', () => {
+    openModal();
+    pickTemplate('custom');
+    typeLayout({ ...LAYOUT_3490, cols: '1', rows: '1', col_gap_mm: '301' });
+
+    expect(screen.getByTestId('label-sheet-problem')).toHaveTextContent('at most 300 mm');
+  });
+
+  it('links each field to the explanation of what is wrong', () => {
+    openModal();
+    pickTemplate('custom');
+    typeLayout({ ...LAYOUT_3490, left_mm: '1' });
+
+    const problem = screen.getByTestId('label-sheet-problem');
+    expect(screen.getByTestId('label-sheet-left_mm').getAttribute('aria-describedby')).toContain(problem.id);
+    expect(screen.getByTestId('label-sheet-left_mm')).toHaveAttribute('aria-invalid', 'true');
+  });
+
+  it('says in the reader\'s language when a name is already taken', async () => {
+    vi.mocked(api.createLabelTemplate).mockRejectedValue(
+      new ApiError('A label template with this name already exists', 409),
+    );
+    openModal();
+    pickTemplate('custom');
+    typeLayout(LAYOUT_3490);
+    fireEvent.click(screen.getByTestId('label-template-save-as'));
+    fireEvent.change(screen.getByTestId('label-template-name'), { target: { value: 'Avery 3490' } });
+    fireEvent.click(screen.getByTestId('label-template-save'));
+
+    expect(await screen.findByText('A label template with this name already exists.')).toBeInTheDocument();
+    expect(screen.queryByText(/Could not save the template/)).not.toBeInTheDocument();
+  });
+
+  it('keeps the lines ticked while the save was on its way', async () => {
+    let finishSave: (value: typeof SAVED) => void = () => {};
+    vi.mocked(api.createLabelTemplate).mockReturnValue(new Promise((resolve) => (finishSave = resolve)));
+    openModal();
+    pickTemplate('custom');
+    typeLayout(LAYOUT_3490);
+    fireEvent.click(screen.getByTestId('label-template-save-as'));
+    fireEvent.change(screen.getByTestId('label-template-name'), { target: { value: 'Avery 3490' } });
+    fireEvent.click(screen.getByTestId('label-template-save'));
+    // Ticked after clicking Save, before the server answered.
+    fireEvent.click(screen.getByTestId('label-field-weight'));
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([SAVED]);
+    await act(async () => finishSave(SAVED));
+
+    await waitFor(() => expect(screen.getByTestId('label-template-select')).toHaveValue('saved:5'));
+    expect(screen.getByTestId('label-field-weight')).toBeChecked();
+  });
+
+  it('refuses a PNG of more sheet pages than the resolution allows', () => {
+    const many = Array.from({ length: 26 }, (_, i) => ({ ...SPOOLS[0], id: i + 1 }));
+    render(
+      <LabelTemplatePickerModal
+        isOpen
+        onClose={vi.fn()}
+        availableSpools={many}
+        initialSelectedIds={many.map((s) => s.id)}
+        spoolmanMode={false}
+      />,
+    );
+    pickTemplate('custom');
+    typeLayout({ ...LAYOUT_3490, cols: '1', rows: '1' });
+    fireEvent.click(screen.getByTestId('label-format-png'));
+    fireEvent.change(screen.getByTestId('label-dpi-select'), { target: { value: '600' } });
+
+    // 26 one-label pages at 600 dpi is over the 25 allowed.
+    expect(screen.getByTestId('label-png-too-large')).toHaveTextContent('25 sheet pages at 600 dpi, and this is 26');
+    expect(screen.getByTestId('label-print-button')).toBeDisabled();
+
+    fireEvent.change(screen.getByTestId('label-dpi-select'), { target: { value: '300' } });
+    expect(screen.queryByTestId('label-png-too-large')).not.toBeInTheDocument();
+    expect(screen.getByTestId('label-print-button')).toBeEnabled();
+  });
+
+  it('leaves an edit of a template deleted elsewhere, once a fresh list says so', async () => {
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([SAVED]);
+    openModal();
+    await screen.findByRole('option', { name: 'Avery 3490' });
+    pickTemplate('saved:5');
+    fireEvent.click(screen.getByTestId('label-template-edit'));
+    expect(screen.getByTestId('label-sheet-editor')).toBeInTheDocument();
+
+    // Someone else deletes it; the list is fetched again when the window regains focus.
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([]);
+    act(() => {
+      focusManager.setFocused(false);
+      focusManager.setFocused(true);
+    });
+
+    await waitFor(() => expect(screen.getByTestId('label-template-select')).toHaveValue('ams_holder_74x33'));
+    expect(screen.queryByTestId('label-sheet-editor')).not.toBeInTheDocument();
+    act(() => focusManager.setFocused(undefined));
+  });
+
+  it('keeps a remembered template while a cached list is being refreshed', async () => {
+    // First open: the list has no templates, and stays cached in the query client.
+    vi.mocked(api.getLabelTemplates).mockResolvedValue([]);
+    const props = {
+      onClose: vi.fn(),
+      availableSpools: SPOOLS,
+      initialSelectedIds: [1],
+      spoolmanMode: false,
+    };
+    const view = render(<LabelTemplatePickerModal isOpen {...props} />);
+    await waitFor(() => expect(api.getLabelTemplates).toHaveBeenCalledTimes(1));
+    view.rerender(<LabelTemplatePickerModal isOpen={false} {...props} />);
+
+    // Meanwhile the template was saved elsewhere and chosen in this browser.
+    const store = new Map([['bambuddy-label-print-options', JSON.stringify({ template: 'saved:5' })]]);
+    vi.mocked(localStorage.getItem).mockImplementation((key) => store.get(key) ?? null);
+    vi.mocked(localStorage.setItem).mockImplementation((key, value) => void store.set(key, String(value)));
+    let finish: (value: (typeof SAVED)[]) => void = () => {};
+    vi.mocked(api.getLabelTemplates).mockReturnValue(new Promise((resolve) => (finish = resolve)));
+
+    // Reopen: the cached list lacks it, the refetch is still on its way.
+    view.rerender(<LabelTemplatePickerModal isOpen {...props} />);
+    await waitFor(() => expect(api.getLabelTemplates).toHaveBeenCalledTimes(2));
+    expect(store.get('bambuddy-label-print-options')).toContain('saved:5');
+
+    await act(async () => finish([SAVED]));
+    await waitFor(() => expect(screen.getByTestId('label-template-select')).toHaveValue('saved:5'));
+    expect(store.get('bambuddy-label-print-options')).toContain('saved:5');
+  });
+
+  describe('for a user who may print but not edit inventory', () => {
+    beforeEach(() => {
+      setAuthToken('viewer-token');
+      vi.mocked(api.getAuthStatus).mockResolvedValue({ auth_enabled: true, requires_setup: false } as never);
+      vi.mocked(api.getCurrentUser).mockResolvedValue({
+        id: 3,
+        username: 'viewer',
+        is_admin: false,
+        is_active: true,
+        groups: [],
+        permissions: ['inventory:read'],
+      } as never);
+      vi.mocked(api.getLabelTemplates).mockResolvedValue([SAVED]);
+    });
+
+    afterEach(() => {
+      setAuthToken(null);
+    });
+
+    it('can use saved templates and custom sheets but not change templates', async () => {
+      openModal();
+      await waitFor(() => expect(api.getCurrentUser).toHaveBeenCalled());
+      await screen.findByRole('option', { name: 'Avery 3490' });
+
+      pickTemplate('saved:5');
+      expect(screen.getByTestId('label-sheet-diagram')).toBeInTheDocument();
+      expect(screen.queryByTestId('label-template-edit')).not.toBeInTheDocument();
+      expect(screen.queryByTestId('label-template-delete')).not.toBeInTheDocument();
+
+      pickTemplate('custom');
+      typeLayout(LAYOUT_3490);
+      expect(screen.queryByTestId('label-template-save-as')).not.toBeInTheDocument();
+      expect(screen.getByTestId('label-print-button')).toBeEnabled();
+    });
+  });
+});

+ 37 - 2
frontend/src/api/client.ts

@@ -3893,9 +3893,37 @@ export const DEFAULT_SPOOL_LABEL_FIELDS: SpoolLabelField[] = [
   'spool_id',
 ];
 
+// A sheet of identical labels in a grid, in millimetres (#3296). Mirror of
+// backend.app.schemas.label_template.SheetLayoutSpec.
+export type LabelPageSize = 'a4' | 'letter';
+export interface LabelSheetLayout {
+  page: LabelPageSize;
+  label_w_mm: number;
+  label_h_mm: number;
+  cols: number;
+  rows: number;
+  top_mm: number;
+  left_mm: number;
+  col_gap_mm: number;
+  row_gap_mm: number;
+}
+
+// A custom sheet saved under a name, shared by everyone on the install.
+export interface SavedLabelTemplate {
+  id: number;
+  name: string;
+  layout: LabelSheetLayout;
+  created_at: string;
+  updated_at: string;
+}
+
+// A built-in template, or "custom" with the sheet itself in ``layout``.
+export type SpoolLabelRequestTemplate = SpoolLabelTemplate | 'custom';
+
 export interface PrintSpoolLabelsRequest {
   spool_ids: number[];
-  template: SpoolLabelTemplate;
+  template: SpoolLabelRequestTemplate;
+  layout?: LabelSheetLayout;
   monochrome: boolean;
   starting_position: number;
   fields?: SpoolLabelField[];
@@ -3905,7 +3933,8 @@ export interface PrintSpoolLabelsRequest {
 
 export interface PreviewSpoolLabelRequest {
   spool_id: number;
-  template: SpoolLabelTemplate;
+  template: SpoolLabelRequestTemplate;
+  layout?: LabelSheetLayout;
   monochrome: boolean;
   fields: SpoolLabelField[];
 }
@@ -7264,6 +7293,12 @@ export const api = {
     postForBlob('/inventory/labels/preview', data, signal),
   previewSpoolmanSpoolLabel: (data: PreviewSpoolLabelRequest, signal?: AbortSignal): Promise<Blob> =>
     postForBlob('/spoolman/labels/preview', data, signal),
+  getLabelTemplates: () => request<SavedLabelTemplate[]>('/label-templates'),
+  createLabelTemplate: (data: { name: string; layout: LabelSheetLayout }) =>
+    request<SavedLabelTemplate>('/label-templates', { method: 'POST', body: JSON.stringify(data) }),
+  updateLabelTemplate: (id: number, data: { name: string; layout: LabelSheetLayout }) =>
+    request<SavedLabelTemplate>(`/label-templates/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
+  deleteLabelTemplate: (id: number) => request<void>(`/label-templates/${id}`, { method: 'DELETE' }),
   getSpoolCatalog: () =>
     request<SpoolCatalogEntry[]>('/inventory/catalog'),
   addCatalogEntry: (data: { name: string; weight: number }) =>

+ 167 - 0
frontend/src/components/LabelSheetLayoutEditor.tsx

@@ -0,0 +1,167 @@
+import { useId } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { LabelPageSize, LabelSheetLayout } from '../api/client';
+import {
+  MAX_COLS,
+  MAX_ROWS,
+  PAGE_SIZES_MM,
+  formatMm,
+  parseDraft,
+  type LayoutDraft,
+  type LayoutNumberField,
+  type LayoutProblem,
+} from '../utils/labelSheetLayout';
+
+// The form for a custom label sheet, and a to-scale drawing of it (#3296).
+
+const NUMBER_INPUTS: { field: LayoutNumberField; i18nKey: string; fallback: string; unit: boolean }[] = [
+  { field: 'label_w_mm', i18nKey: 'labelWidth', fallback: 'Label width', unit: true },
+  { field: 'label_h_mm', i18nKey: 'labelHeight', fallback: 'Label height', unit: true },
+  { field: 'cols', i18nKey: 'columns', fallback: 'Columns', unit: false },
+  { field: 'rows', i18nKey: 'rows', fallback: 'Rows', unit: false },
+  { field: 'top_mm', i18nKey: 'topMargin', fallback: 'Top margin', unit: true },
+  { field: 'left_mm', i18nKey: 'leftMargin', fallback: 'Left margin', unit: true },
+  { field: 'col_gap_mm', i18nKey: 'columnGap', fallback: 'Gap between columns', unit: true },
+  { field: 'row_gap_mm', i18nKey: 'rowGap', fallback: 'Gap between rows', unit: true },
+];
+
+/** Every label of ``layout`` drawn on its page. Labels that run off the page
+ *  are red; the positions a starting position leaves blank are faded. */
+export function LabelSheetDiagram({
+  layout,
+  startingPosition = 1,
+}: {
+  layout: LabelSheetLayout;
+  startingPosition?: number;
+}) {
+  const { t } = useTranslation();
+  const [pageW, pageH] = PAGE_SIZES_MM[layout.page];
+  const cells = [];
+  for (let row = 0; row < layout.rows; row++) {
+    for (let col = 0; col < layout.cols; col++) {
+      const x = layout.left_mm + col * (layout.label_w_mm + layout.col_gap_mm);
+      const y = layout.top_mm + row * (layout.label_h_mm + layout.row_gap_mm);
+      const offPage = x + layout.label_w_mm > pageW + 0.01 || y + layout.label_h_mm > pageH + 0.01;
+      const skipped = row * layout.cols + col < startingPosition - 1;
+      cells.push(
+        <rect
+          key={`${row}-${col}`}
+          data-testid={offPage ? 'label-sheet-cell-off-page' : 'label-sheet-cell'}
+          x={x}
+          y={y}
+          width={layout.label_w_mm}
+          height={layout.label_h_mm}
+          rx={1}
+          className={
+            offPage
+              ? 'fill-red-400/40 stroke-red-500'
+              : skipped
+                ? 'fill-transparent stroke-gray-300'
+                : 'fill-bambu-green/25 stroke-bambu-green'
+          }
+          strokeWidth={0.6}
+        />,
+      );
+    }
+  }
+  return (
+    <svg
+      data-testid="label-sheet-diagram"
+      role="img"
+      aria-label={t('inventory.labels.custom.diagram', 'Drawing of the sheet with every label in place')}
+      // Room around the page so a grid that runs off it still shows.
+      viewBox={`-4 -4 ${pageW + 8} ${pageH + 8}`}
+      className="h-48 w-auto mx-auto overflow-visible"
+    >
+      <rect x={0} y={0} width={pageW} height={pageH} className="fill-white stroke-gray-400" strokeWidth={0.6} />
+      {cells}
+    </svg>
+  );
+}
+
+export function LabelSheetLayoutEditor({
+  draft,
+  onChange,
+  problem,
+  startingPosition,
+}: {
+  draft: LayoutDraft;
+  onChange: (draft: LayoutDraft) => void;
+  problem: LayoutProblem | null;
+  startingPosition?: number;
+}) {
+  const { t, i18n } = useTranslation();
+  const hintId = useId();
+  const problemId = useId();
+  const layout = parseDraft(draft);
+  // Lengths and counts in the reader's own number format (70,5 mm in German).
+  const problemParams = problem?.params
+    ? Object.fromEntries(Object.entries(problem.params).map(([k, v]) => [k, formatMm(v, i18n.language)]))
+    : undefined;
+  // Drawn while it is a sane grid, overflow included: that is what the drawing
+  // is for. A grid past the limits is only explained, not drawn box by box.
+  const drawable =
+    layout !== null &&
+    Number.isInteger(layout.cols) &&
+    Number.isInteger(layout.rows) &&
+    layout.cols >= 1 &&
+    layout.cols <= MAX_COLS &&
+    layout.rows >= 1 &&
+    layout.rows <= MAX_ROWS &&
+    layout.label_w_mm > 0 &&
+    layout.label_h_mm > 0;
+  const inputClass =
+    'w-full px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green';
+
+  return (
+    <fieldset data-testid="label-sheet-editor" className="space-y-3">
+      <legend className="text-sm font-medium text-white">
+        {t('inventory.labels.custom.layoutTitle', 'Sheet layout')}
+      </legend>
+      <div className="grid grid-cols-2 gap-x-3 gap-y-2">
+        <label className="col-span-2 space-y-0.5">
+          <span className="text-xs text-bambu-gray">{t('inventory.labels.custom.page', 'Page')}</span>
+          <select
+            data-testid="label-sheet-page"
+            value={draft.page}
+            onChange={(e) => onChange({ ...draft, page: e.target.value as LabelPageSize })}
+            className={inputClass}
+          >
+            <option value="a4">{t('inventory.labels.custom.pageA4', 'A4 (210 × 297 mm)')}</option>
+            <option value="letter">{t('inventory.labels.custom.pageLetter', 'US Letter (215.9 × 279.4 mm)')}</option>
+          </select>
+        </label>
+        {NUMBER_INPUTS.map(({ field, i18nKey, fallback, unit }) => (
+          <label key={field} className="space-y-0.5">
+            <span className="text-xs text-bambu-gray">
+              {t(`inventory.labels.custom.${i18nKey}`, fallback)}
+              {unit && ' (mm)'}
+            </span>
+            <input
+              data-testid={`label-sheet-${field}`}
+              type="text"
+              inputMode={unit ? 'decimal' : 'numeric'}
+              value={draft[field]}
+              onChange={(e) => onChange({ ...draft, [field]: e.target.value })}
+              aria-describedby={problem ? `${hintId} ${problemId}` : hintId}
+              aria-invalid={problem ? true : undefined}
+              className={inputClass}
+            />
+          </label>
+        ))}
+      </div>
+      <p id={hintId} className="text-xs text-bambu-gray">
+        {t(
+          'inventory.labels.custom.measureHint',
+          'Measure from the top-left corner of the page: margins to the first label, gaps between neighbouring labels.',
+        )}
+      </p>
+      {problem && (
+        <p id={problemId} data-testid="label-sheet-problem" aria-live="polite" className="text-xs text-red-400">
+          {t(`inventory.labels.custom.problems.${problem.key}`, problem.fallback, problemParams)}
+        </p>
+      )}
+      {drawable && layout && <LabelSheetDiagram layout={layout} startingPosition={startingPosition} />}
+    </fieldset>
+  );
+}

+ 434 - 22
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -1,16 +1,35 @@
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { X, Loader2, Printer, CheckSquare, Square, Search, Download } from 'lucide-react';
 import {
   api,
+  ApiError,
   DEFAULT_SPOOL_LABEL_FIELDS,
   SPOOL_LABEL_FIELDS,
+  type LabelSheetLayout,
+  type SavedLabelTemplate,
   type SpoolLabelField,
+  type SpoolLabelRequestTemplate,
   type SpoolLabelTemplate,
   type InventorySpool,
 } from '../api/client';
 import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { LabelSheetDiagram, LabelSheetLayoutEditor } from './LabelSheetLayoutEditor';
+import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
+import {
+  DEFAULT_LAYOUT,
+  MAX_PNG_SHEET_PAGES_X_DPI,
+  draftFromLayout,
+  formatMm,
+  isLayoutDraft,
+  layoutProblem,
+  parseDraft,
+  sheetPageCount,
+  type LayoutDraft,
+} from '../utils/labelSheetLayout';
 import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
 import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
 
@@ -108,10 +127,32 @@ const FIELD_LABELS: Record<SpoolLabelField, { i18nKey: string; fallback: string
 // setting would need settings access to save.
 const PREFS_STORAGE_KEY = 'bambuddy-label-print-options';
 
+// What the label size dropdown holds: a built-in template, a one-off custom
+// sheet, or a saved template by id (#3296).
+type TemplateChoice = SpoolLabelTemplate | 'custom' | `saved:${number}`;
+
+const SAVED_TEMPLATES_KEY = ['labelTemplates'];
+
+function savedChoice(id: number): TemplateChoice {
+  return `saved:${id}`;
+}
+
+function savedIdOf(choice: TemplateChoice): number | null {
+  return choice.startsWith('saved:') ? Number(choice.slice('saved:'.length)) : null;
+}
+
+function isTemplateChoice(value: unknown): value is TemplateChoice {
+  if (typeof value !== 'string') return false;
+  if (value === 'custom' || /^saved:[1-9]\d*$/.test(value)) return true;
+  return TEMPLATE_OPTIONS.some((o) => o.value === value);
+}
+
 interface LabelPrefs {
-  template: SpoolLabelTemplate;
+  template: TemplateChoice;
   /** Chosen lines per template: a 40 × 30 roll and an A4 sheet fit different amounts. */
-  fields: Partial<Record<SpoolLabelTemplate, SpoolLabelField[]>>;
+  fields: Partial<Record<TemplateChoice, SpoolLabelField[]>>;
+  /** The one-off custom sheet, as last typed. */
+  customDraft: LayoutDraft;
   monochrome: boolean;
   format: OutputFormat;
   dpi: PngDpi;
@@ -120,6 +161,7 @@ interface LabelPrefs {
 const DEFAULT_PREFS: LabelPrefs = {
   template: TEMPLATE_OPTIONS[0].value,
   fields: {},
+  customDraft: draftFromLayout(DEFAULT_LAYOUT),
   monochrome: false,
   format: 'pdf',
   dpi: 300,
@@ -134,21 +176,19 @@ function loadPrefs(): LabelPrefs {
   }
   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 =>
+      if (!isTemplateChoice(tpl) || !Array.isArray(list)) continue;
+      fields[tpl] = 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,
+    template: isTemplateChoice(r.template) ? r.template : DEFAULT_PREFS.template,
     fields,
+    customDraft: isLayoutDraft(r.customDraft) ? r.customDraft : DEFAULT_PREFS.customDraft,
     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,
@@ -163,7 +203,7 @@ function savePrefs(prefs: LabelPrefs): void {
   }
 }
 
-function fieldsFor(prefs: LabelPrefs, template: SpoolLabelTemplate): SpoolLabelField[] {
+function fieldsFor(prefs: LabelPrefs, template: TemplateChoice): SpoolLabelField[] {
   return prefs.fields[template] ?? DEFAULT_SPOOL_LABEL_FIELDS;
 }
 
@@ -191,7 +231,7 @@ function openBlobInNewTab(blob: Blob): void {
 }
 
 /** Save a PNG, or a ZIP of PNGs when there were several pages. */
-function downloadBlob(blob: Blob, template: SpoolLabelTemplate): void {
+function downloadBlob(blob: Blob, template: SpoolLabelRequestTemplate): void {
   const url = window.URL.createObjectURL(blob);
   const a = document.createElement('a');
   a.href = url;
@@ -282,11 +322,16 @@ export function LabelTemplatePickerModal({
   initialSelectedIds,
   spoolmanMode,
 }: LabelTemplatePickerModalProps) {
-  const { t } = useTranslation();
+  const { t, i18n } = useTranslation();
   // The spool filter below resolves colour names through the catalog; its
   // memo has to recompute when the catalog finishes loading (#3090).
   const colorCatalogVersion = useColorCatalogVersion();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
+  // Saving, editing and deleting templates is inventory:update, like storage
+  // locations; anyone who can print can use them (#3296).
+  const canManageTemplates = hasPermission('inventory:update');
+  const queryClient = useQueryClient();
   const [pending, setPending] = useState(false);
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
   const [search, setSearch] = useState('');
@@ -300,6 +345,57 @@ export function LabelTemplatePickerModal({
   // 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);
+  // A saved template being edited: its name and layout as typed, not yet saved.
+  const [editing, setEditing] = useState<{ name: string; draft: LayoutDraft } | null>(null);
+  // The name typed for "Save as template"; null while that form is closed.
+  const [newTemplateName, setNewTemplateName] = useState<string | null>(null);
+  const [confirmDelete, setConfirmDelete] = useState(false);
+  // When the dialog last opened: a remembered template is only given up on
+  // when a list fetched since then lacks it, not a cached one (#3296 review).
+  const openedAtRef = useRef(0);
+
+  const savedQuery = useQuery({
+    queryKey: SAVED_TEMPLATES_KEY,
+    queryFn: api.getLabelTemplates,
+    enabled: isOpen,
+    // Someone else may have saved or deleted one since the last open.
+    staleTime: 0,
+  });
+  const savedTemplates = useMemo(() => savedQuery.data ?? [], [savedQuery.data]);
+
+  const saveMutation = useMutation({
+    mutationFn: ({ id, name, layout }: { id?: number; name: string; layout: LabelSheetLayout }) =>
+      id === undefined ? api.createLabelTemplate({ name, layout }) : api.updateLabelTemplate(id, { name, layout }),
+    onSuccess: (saved) => {
+      // In the list before it is selected, so the selection doesn't fall back.
+      queryClient.setQueryData<SavedLabelTemplate[]>(SAVED_TEMPLATES_KEY, (old = []) => [
+        ...old.filter((tpl) => tpl.id !== saved.id),
+        saved,
+      ]);
+      void queryClient.invalidateQueries({ queryKey: SAVED_TEMPLATES_KEY });
+    },
+    onError: (err) => {
+      if (err instanceof ApiError && err.status === 409) {
+        showToast(t('inventory.labels.custom.nameTaken', 'A label template with this name already exists.'), 'error');
+        return;
+      }
+      const msg = err instanceof Error ? err.message : String(err);
+      showToast(t('inventory.labels.custom.saveFailed', 'Could not save the template: {{msg}}', { msg }), 'error');
+    },
+  });
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteLabelTemplate(id),
+    onSuccess: (_, id) => {
+      queryClient.setQueryData<SavedLabelTemplate[]>(SAVED_TEMPLATES_KEY, (old = []) =>
+        old.filter((tpl) => tpl.id !== id),
+      );
+      void queryClient.invalidateQueries({ queryKey: SAVED_TEMPLATES_KEY });
+    },
+    onError: (err) => {
+      const msg = err instanceof Error ? err.message : String(err);
+      showToast(t('inventory.labels.custom.deleteFailed', 'Could not delete the template: {{msg}}', { msg }), 'error');
+    },
+  });
 
   // 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
@@ -307,6 +403,7 @@ export function LabelTemplatePickerModal({
   // label options come back as the user last left them.
   useEffect(() => {
     if (isOpen) {
+      openedAtRef.current = Date.now();
       const allowed = new Set(availableSpools.map((s) => s.id));
       setSelectedIds(new Set(initialSelectedIds.filter((id) => allowed.has(id))));
       setSearch('');
@@ -315,6 +412,9 @@ export function LabelTemplatePickerModal({
       setPrefs(loadPrefs());
       setStartingPositionInput('1');
       setPending(false);
+      setEditing(null);
+      setNewTemplateName(null);
+      setConfirmDelete(false);
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [isOpen]);
@@ -367,6 +467,46 @@ export function LabelTemplatePickerModal({
   const template = prefs.template;
   const fields = fieldsFor(prefs, template);
   const fieldsKey = fields.join(',');
+
+  // A saved template or a one-off custom sheet prints as "custom" with its
+  // layout. The layout on screen is what prints, so an edit can be tried
+  // before it is saved.
+  const savedId = savedIdOf(template);
+  const savedTemplate = savedId === null ? undefined : savedTemplates.find((tpl) => tpl.id === savedId);
+  const isCustomSheet = template === 'custom' || savedId !== null;
+  const activeDraft: LayoutDraft | null =
+    template === 'custom'
+      ? prefs.customDraft
+      : editing
+        ? editing.draft
+        : savedTemplate
+          ? draftFromLayout(savedTemplate.layout)
+          : null;
+  const problem = activeDraft ? layoutProblem(activeDraft) : null;
+  const activeLayout = activeDraft && !problem ? parseDraft(activeDraft) : null;
+  // Waiting for the saved list, or a layout that can't print: nothing to send.
+  const sheetReady = !isCustomSheet || activeLayout !== null;
+  const requestTemplate: SpoolLabelRequestTemplate = isCustomSheet ? 'custom' : (template as SpoolLabelTemplate);
+  const layoutKey = activeLayout ? JSON.stringify(activeLayout) : '';
+
+  // A remembered saved template that has since been deleted: back to the
+  // default, and out of any edit of it, which would otherwise still send its
+  // layout with the built-in template (a 422). Only once a list fetched since
+  // the dialog opened says so.
+  const savedListIsFresh =
+    savedQuery.isSuccess && !savedQuery.isFetching && savedQuery.dataUpdatedAt >= openedAtRef.current;
+  useEffect(() => {
+    if (!isOpen || !savedListIsFresh || savedId === null) return;
+    if (savedTemplates.some((tpl) => tpl.id === savedId)) return;
+    setEditing(null);
+    setNewTemplateName(null);
+    setConfirmDelete(false);
+    setPrefs((prev) => {
+      const next = { ...prev, template: DEFAULT_PREFS.template };
+      savePrefs(next);
+      return next;
+    });
+  }, [isOpen, savedListIsFresh, savedTemplates, savedId]);
   // The preview shows the first spool that will print, in print order.
   const previewSpoolId = sortedSpools.find((s) => selectedIds.has(s.id))?.id ?? null;
 
@@ -379,7 +519,7 @@ export function LabelTemplatePickerModal({
   // 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) {
+    if (!isOpen || previewSpoolId === null || !sheetReady) {
       showPreview(null);
       setPreviewError(null);
       setPreviewLoading(false);
@@ -390,7 +530,8 @@ export function LabelTemplatePickerModal({
     const timer = window.setTimeout(async () => {
       const request = {
         spool_id: previewSpoolId,
-        template,
+        template: requestTemplate,
+        ...(layoutKey ? { layout: JSON.parse(layoutKey) as LabelSheetLayout } : {}),
         monochrome: prefs.monochrome,
         fields: fieldsKey ? (fieldsKey.split(',') as SpoolLabelField[]) : [],
       };
@@ -413,7 +554,7 @@ export function LabelTemplatePickerModal({
       window.clearTimeout(timer);
       controller.abort();
     };
-  }, [isOpen, previewSpoolId, template, fieldsKey, prefs.monochrome, spoolmanMode]);
+  }, [isOpen, previewSpoolId, sheetReady, requestTemplate, layoutKey, fieldsKey, prefs.monochrome, spoolmanMode]);
 
   // Free the last preview when the modal unmounts.
   useEffect(
@@ -427,12 +568,23 @@ export function LabelTemplatePickerModal({
 
   const selectedCount = selectedIds.size;
   const noSelection = selectedCount === 0;
-  const sheetCapacity = SHEET_CAPACITIES[template];
+  const sheetCapacity = isCustomSheet
+    ? activeLayout
+      ? activeLayout.cols * activeLayout.rows
+      : undefined
+    : SHEET_CAPACITIES[template as SpoolLabelTemplate];
   const startingPosition = Number(startingPositionInput);
   const startingPositionIsValid =
     sheetCapacity === undefined ||
     (Number.isInteger(startingPosition) && startingPosition >= 1 && startingPosition <= sheetCapacity);
   const templateOption = TEMPLATE_OPTIONS.find((o) => o.value === template) ?? TEMPLATE_OPTIONS[0];
+  // PNG of whole sheet pages is capped at pages × dpi, as on the server.
+  const pngSheetPages =
+    prefs.format === 'png' && sheetCapacity !== undefined && startingPositionIsValid
+      ? sheetPageCount(sheetCapacity, selectedCount, startingPosition)
+      : 0;
+  const pngTooLarge = pngSheetPages * prefs.dpi > MAX_PNG_SHEET_PAGES_X_DPI;
+  const canPrint = !noSelection && !pending && startingPositionIsValid && sheetReady && !pngTooLarge;
 
   function updatePrefs(change: Partial<LabelPrefs>) {
     setPrefs((prev) => {
@@ -442,6 +594,79 @@ export function LabelTemplatePickerModal({
     });
   }
 
+  function chooseTemplate(choice: TemplateChoice) {
+    setEditing(null);
+    setNewTemplateName(null);
+    updatePrefs({ template: choice });
+  }
+
+  function layoutSummary(layout: LabelSheetLayout): string {
+    return t(
+      'inventory.labels.custom.summary',
+      '{{page}} · {{width}} × {{height}} mm · {{capacity}} labels ({{cols}} × {{rows}})',
+      {
+        page:
+          layout.page === 'a4'
+            ? t('inventory.labels.custom.pageShortA4', 'A4')
+            : t('inventory.labels.custom.pageShortLetter', 'Letter'),
+        width: formatMm(layout.label_w_mm, i18n.language),
+        height: formatMm(layout.label_h_mm, i18n.language),
+        capacity: layout.cols * layout.rows,
+        cols: layout.cols,
+        rows: layout.rows,
+      },
+    );
+  }
+
+  function saveNewTemplate(name: string, layout: LabelSheetLayout) {
+    // The new template keeps the lines chosen for the sheet it was made from,
+    // as they are when the save completes: they can be ticked meanwhile.
+    const source = template;
+    saveMutation.mutate(
+      { name: name.trim(), layout },
+      {
+        onSuccess: (saved) => {
+          setEditing(null);
+          setNewTemplateName(null);
+          const choice = savedChoice(saved.id);
+          setPrefs((prev) => {
+            const next = {
+              ...prev,
+              template: choice,
+              fields: { ...prev.fields, [choice]: fieldsFor(prev, source) },
+            };
+            savePrefs(next);
+            return next;
+          });
+          showToast(t('inventory.labels.custom.saved', 'Template "{{name}}" saved', { name: saved.name }), 'success');
+        },
+      },
+    );
+  }
+
+  function saveEditedTemplate(id: number, name: string, layout: LabelSheetLayout) {
+    saveMutation.mutate(
+      { id, name: name.trim(), layout },
+      {
+        onSuccess: (saved) => {
+          setEditing(null);
+          showToast(t('inventory.labels.custom.saved', 'Template "{{name}}" saved', { name: saved.name }), 'success');
+        },
+      },
+    );
+  }
+
+  function deleteSavedTemplate(tpl: SavedLabelTemplate) {
+    deleteMutation.mutate(tpl.id, {
+      onSuccess: () => {
+        setConfirmDelete(false);
+        setEditing(null);
+        updatePrefs({ template: DEFAULT_PREFS.template });
+        showToast(t('inventory.labels.custom.deleted', 'Template "{{name}}" deleted', { name: tpl.name }), 'success');
+      },
+    });
+  }
+
   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));
@@ -482,14 +707,15 @@ export function LabelTemplatePickerModal({
   }
 
   async function handlePrint() {
-    if (noSelection || pending || !startingPositionIsValid) return;
+    if (!canPrint) 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 output instead of being clobbered by an ascending-ID re-sort.
     const ids = sortedSpools.filter((s) => selectedIds.has(s.id)).map((s) => s.id);
     const request = {
       spool_ids: ids,
-      template,
+      template: requestTemplate,
+      ...(activeLayout ? { layout: activeLayout } : {}),
       monochrome: prefs.monochrome,
       starting_position: sheetCapacity === undefined ? 1 : startingPosition,
       fields,
@@ -502,7 +728,7 @@ export function LabelTemplatePickerModal({
         ? await api.printSpoolmanSpoolLabels(request)
         : await api.printSpoolLabels(request);
       if (prefs.format === 'pdf') openBlobInNewTab(blob);
-      else downloadBlob(blob, template);
+      else downloadBlob(blob, requestTemplate);
       onClose();
     } catch (err) {
       const msg = err instanceof Error ? err.message : String(err);
@@ -717,7 +943,7 @@ export function LabelTemplatePickerModal({
                 id="label-template"
                 data-testid="label-template-select"
                 value={template}
-                onChange={(e) => updatePrefs({ template: e.target.value as SpoolLabelTemplate })}
+                onChange={(e) => chooseTemplate(e.target.value as TemplateChoice)}
                 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) => (
@@ -725,12 +951,133 @@ export function LabelTemplatePickerModal({
                     {t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel)}
                   </option>
                 ))}
+                {savedTemplates.length > 0 && (
+                  <optgroup label={t('inventory.labels.custom.savedGroup', 'Saved templates')}>
+                    {savedTemplates.map((tpl) => (
+                      <option key={tpl.id} value={savedChoice(tpl.id)}>
+                        {tpl.name}
+                      </option>
+                    ))}
+                  </optgroup>
+                )}
+                <option value="custom">{t('inventory.labels.custom.option', 'Custom sheet…')}</option>
               </select>
-              <p className="text-xs text-bambu-gray">
-                {t(`inventory.labels.templates.${templateOption.i18nKey}.hint`, templateOption.fallbackHint)}
+              <p data-testid="label-template-hint" className="text-xs text-bambu-gray">
+                {template === 'custom'
+                  ? t('inventory.labels.custom.hint', 'Describe your own label sheet below.')
+                  : savedId !== null
+                    ? savedTemplate
+                      ? layoutSummary(savedTemplate.layout)
+                      : null
+                    : t(`inventory.labels.templates.${templateOption.i18nKey}.hint`, templateOption.fallbackHint)}
               </p>
             </div>
 
+            {/* A saved template: its sheet, and Edit / Delete for those who may (#3296). */}
+            {savedTemplate && !editing && (
+              <div className="space-y-2">
+                <LabelSheetDiagram layout={savedTemplate.layout} startingPosition={startingPositionIsValid ? startingPosition : 1} />
+                {canManageTemplates && (
+                  <div className="flex justify-center gap-4 text-xs">
+                    <button
+                      type="button"
+                      data-testid="label-template-edit"
+                      onClick={() =>
+                        setEditing({ name: savedTemplate.name, draft: draftFromLayout(savedTemplate.layout) })
+                      }
+                      className="text-bambu-green hover:underline"
+                    >
+                      {t('inventory.labels.custom.edit', 'Edit')}
+                    </button>
+                    <button
+                      type="button"
+                      data-testid="label-template-delete"
+                      onClick={() => setConfirmDelete(true)}
+                      className="text-red-400 hover:underline"
+                    >
+                      {t('inventory.labels.custom.delete', 'Delete')}
+                    </button>
+                  </div>
+                )}
+              </div>
+            )}
+
+            {/* Editing a saved template: the name and the whole layout. */}
+            {savedTemplate && editing && (
+              <div className="space-y-3">
+                <TemplateNameInput
+                  value={editing.name}
+                  onChange={(name) => setEditing({ ...editing, name })}
+                />
+                <LabelSheetLayoutEditor
+                  draft={editing.draft}
+                  onChange={(draft) => setEditing({ ...editing, draft })}
+                  problem={problem}
+                  startingPosition={startingPositionIsValid ? startingPosition : 1}
+                />
+                <div className="flex flex-wrap justify-end gap-2">
+                  <Button variant="secondary" onClick={() => setEditing(null)} disabled={saveMutation.isPending}>
+                    {t('common.cancel', 'Cancel')}
+                  </Button>
+                  <Button
+                    variant="secondary"
+                    data-testid="label-template-save-as-new"
+                    disabled={!activeLayout || !editing.name.trim() || saveMutation.isPending}
+                    onClick={() => activeLayout && saveNewTemplate(editing.name, activeLayout)}
+                  >
+                    {t('inventory.labels.custom.saveAsNew', 'Save as new')}
+                  </Button>
+                  <Button
+                    data-testid="label-template-save-changes"
+                    disabled={!activeLayout || !editing.name.trim() || saveMutation.isPending}
+                    onClick={() => activeLayout && saveEditedTemplate(savedTemplate.id, editing.name, activeLayout)}
+                  >
+                    {t('inventory.labels.custom.saveChanges', 'Save changes')}
+                  </Button>
+                </div>
+              </div>
+            )}
+
+            {/* A one-off custom sheet, and saving it under a name. */}
+            {template === 'custom' && (
+              <div className="space-y-3">
+                <LabelSheetLayoutEditor
+                  draft={prefs.customDraft}
+                  onChange={(customDraft) => updatePrefs({ customDraft })}
+                  problem={problem}
+                  startingPosition={startingPositionIsValid ? startingPosition : 1}
+                />
+                {canManageTemplates &&
+                  (newTemplateName === null ? (
+                    <button
+                      type="button"
+                      data-testid="label-template-save-as"
+                      disabled={!activeLayout}
+                      onClick={() => setNewTemplateName('')}
+                      className="text-xs text-bambu-green hover:underline disabled:opacity-50 disabled:no-underline disabled:cursor-not-allowed"
+                    >
+                      {t('inventory.labels.custom.saveAs', 'Save as template…')}
+                    </button>
+                  ) : (
+                    <div className="flex flex-wrap items-end gap-2">
+                      <div className="flex-1 min-w-40">
+                        <TemplateNameInput value={newTemplateName} onChange={setNewTemplateName} autoFocus />
+                      </div>
+                      <Button variant="secondary" onClick={() => setNewTemplateName(null)} disabled={saveMutation.isPending}>
+                        {t('common.cancel', 'Cancel')}
+                      </Button>
+                      <Button
+                        data-testid="label-template-save"
+                        disabled={!activeLayout || !newTemplateName.trim() || saveMutation.isPending}
+                        onClick={() => activeLayout && saveNewTemplate(newTemplateName, activeLayout)}
+                      >
+                        {t('inventory.labels.custom.save', 'Save')}
+                      </Button>
+                    </div>
+                  ))}
+              </div>
+            )}
+
             {/* Preview */}
             <div className="space-y-1">
               <div className="text-sm font-medium text-white">
@@ -746,6 +1093,10 @@ export function LabelTemplatePickerModal({
                   <span className="text-sm text-bambu-gray text-center">
                     {t('inventory.labels.previewNoSelection', 'Select a spool to see its label.')}
                   </span>
+                ) : !sheetReady ? (
+                  <span className="text-sm text-bambu-gray text-center">
+                    {t('inventory.labels.custom.previewWaiting', 'The preview appears once the sheet layout is complete.')}
+                  </span>
                 ) : previewError ? (
                   <span className="text-sm text-red-400 text-center">
                     {t('inventory.labels.previewError', 'Could not render the preview: {{msg}}', { msg: previewError })}
@@ -874,6 +1225,15 @@ export function LabelTemplatePickerModal({
                 </div>
               )}
 
+              {sheetCapacity !== undefined && (
+                <p data-testid="label-scale-hint" className="text-xs text-bambu-gray">
+                  {t(
+                    'inventory.labels.scaleHint',
+                    'Print sheets at 100% (actual size), not "Fit to page": a shrunk page drifts off the labels.',
+                  )}
+                </p>
+              )}
+
               <div className="flex flex-wrap items-center gap-1.5">
                 <span className="text-sm text-white mr-1">
                   {t('inventory.labels.format', 'Output:')}
@@ -920,6 +1280,15 @@ export function LabelTemplatePickerModal({
                   )}
                 </p>
               )}
+              {pngTooLarge && (
+                <p data-testid="label-png-too-large" className="text-xs text-red-400">
+                  {t(
+                    'inventory.labels.pngTooLarge',
+                    'PNG export is limited to {{maxPages}} sheet pages at {{dpi}} dpi, and this is {{pages}}. Use PDF, a lower resolution or fewer spools.',
+                    { maxPages: Math.floor(MAX_PNG_SHEET_PAGES_X_DPI / prefs.dpi), dpi: prefs.dpi, pages: pngSheetPages },
+                  )}
+                </p>
+              )}
             </div>
           </div>
         </div>
@@ -931,7 +1300,7 @@ export function LabelTemplatePickerModal({
           <Button
             data-testid="label-print-button"
             onClick={handlePrint}
-            disabled={noSelection || pending || !startingPositionIsValid}
+            disabled={!canPrint}
           >
             {pending ? (
               <Loader2 className="w-4 h-4 animate-spin" />
@@ -946,6 +1315,49 @@ export function LabelTemplatePickerModal({
           </Button>
         </div>
       </div>
+
+      {confirmDelete && savedTemplate && (
+        <ConfirmModal
+          title={t('inventory.labels.custom.deleteTitle', 'Delete label template')}
+          message={t(
+            'inventory.labels.custom.deleteMessage',
+            'Delete "{{name}}" for everyone? Labels already printed are not affected.',
+            { name: savedTemplate.name },
+          )}
+          confirmText={t('inventory.labels.custom.delete', 'Delete')}
+          variant="danger"
+          overlayZIndex="z-[60]"
+          isLoading={deleteMutation.isPending}
+          onConfirm={() => deleteSavedTemplate(savedTemplate)}
+          onCancel={() => setConfirmDelete(false)}
+        />
+      )}
     </div>
   );
 }
+
+function TemplateNameInput({
+  value,
+  onChange,
+  autoFocus = false,
+}: {
+  value: string;
+  onChange: (name: string) => void;
+  autoFocus?: boolean;
+}) {
+  const { t } = useTranslation();
+  return (
+    <label className="block space-y-0.5">
+      <span className="text-xs text-bambu-gray">{t('inventory.labels.custom.name', 'Template name')}</span>
+      <input
+        data-testid="label-template-name"
+        type="text"
+        maxLength={100}
+        autoFocus={autoFocus}
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        className="w-full px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
+      />
+    </label>
+  );
+}

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

@@ -5140,6 +5140,55 @@ export default {
         id: 'Nach ID',
         color: 'Nach Farbe',
       },
+      scaleHint: 'Bögen mit 100 % (tatsächliche Größe) drucken, nicht „An Seite anpassen“: Eine verkleinerte Seite verrutscht gegenüber den Etiketten.',
+      pngTooLarge: 'Der PNG-Export ist bei {{dpi}} dpi auf {{maxPages}} Bogenseiten begrenzt, dies sind {{pages}}. Nutze PDF, eine niedrigere Auflösung oder weniger Spulen.',
+      custom: {
+        option: 'Eigener Bogen…',
+        savedGroup: 'Gespeicherte Vorlagen',
+        hint: 'Beschreibe unten deinen eigenen Etikettenbogen.',
+        layoutTitle: 'Bogenlayout',
+        page: 'Seite',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'US-Letter (215,9 × 279,4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'US-Letter',
+        labelWidth: 'Etikettenbreite',
+        labelHeight: 'Etikettenhöhe',
+        columns: 'Spalten',
+        rows: 'Zeilen',
+        topMargin: 'Oberer Rand',
+        leftMargin: 'Linker Rand',
+        columnGap: 'Abstand zwischen Spalten',
+        rowGap: 'Abstand zwischen Zeilen',
+        measureHint: 'Von der oberen linken Ecke der Seite messen: Ränder bis zum ersten Etikett, Abstände zwischen benachbarten Etiketten.',
+        diagram: 'Zeichnung des Bogens mit allen Etiketten an ihrem Platz',
+        summary: '{{page}} · {{width}} × {{height}} mm · {{capacity}} Etiketten ({{cols}} × {{rows}})',
+        previewWaiting: 'Die Vorschau erscheint, sobald das Bogenlayout vollständig ist.',
+        saveAs: 'Als Vorlage speichern…',
+        name: 'Vorlagenname',
+        save: 'Speichern',
+        saveChanges: 'Änderungen speichern',
+        saveAsNew: 'Als neue speichern',
+        edit: 'Bearbeiten',
+        delete: 'Löschen',
+        deleteTitle: 'Etikettenvorlage löschen',
+        deleteMessage: '„{{name}}“ für alle löschen? Bereits gedruckte Etiketten sind nicht betroffen.',
+        saved: 'Vorlage „{{name}}“ gespeichert',
+        deleted: 'Vorlage „{{name}}“ gelöscht',
+        saveFailed: 'Vorlage konnte nicht gespeichert werden: {{msg}}',
+        deleteFailed: 'Vorlage konnte nicht gelöscht werden: {{msg}}',
+        nameTaken: 'Eine Etikettenvorlage mit diesem Namen gibt es bereits.',
+        problems: {
+          incomplete: 'Fülle jedes Feld mit einer Zahl aus.',
+          tooSmall: 'Etiketten müssen mindestens {{minWidth}} mm breit und {{minHeight}} mm hoch sein, sonst passt der Text nicht.',
+          tooLong: 'Größen, Ränder und Abstände dürfen höchstens {{max}} mm betragen.',
+          columns: 'Spalten müssen eine ganze Zahl von 1 bis {{max}} sein.',
+          rows: 'Zeilen müssen eine ganze Zahl von 1 bis {{max}} sein.',
+          negative: 'Ränder und Abstände dürfen nicht negativ sein.',
+          tooWide: 'Die Etiketten sind mit dem linken Rand {{size}} mm breit, die Seite aber nur {{page}} mm.',
+          tooTall: 'Die Etiketten sind mit dem oberen Rand {{size}} mm hoch, die Seite aber nur {{page}} mm.',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'AMS-Halter — klein (74 × 33 mm)',

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

@@ -5178,6 +5178,55 @@ export default {
         id: 'By ID',
         color: 'By colour',
       },
+      scaleHint: 'Print sheets at 100% (actual size), not "Fit to page": a shrunk page drifts off the labels.',
+      pngTooLarge: 'PNG export is limited to {{maxPages}} sheet pages at {{dpi}} dpi, and this is {{pages}}. Use PDF, a lower resolution or fewer spools.',
+      custom: {
+        option: 'Custom sheet…',
+        savedGroup: 'Saved templates',
+        hint: 'Describe your own label sheet below.',
+        layoutTitle: 'Sheet layout',
+        page: 'Page',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'US Letter (215.9 × 279.4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'Letter',
+        labelWidth: 'Label width',
+        labelHeight: 'Label height',
+        columns: 'Columns',
+        rows: 'Rows',
+        topMargin: 'Top margin',
+        leftMargin: 'Left margin',
+        columnGap: 'Gap between columns',
+        rowGap: 'Gap between rows',
+        measureHint: 'Measure from the top-left corner of the page: margins to the first label, gaps between neighbouring labels.',
+        diagram: 'Drawing of the sheet with every label in place',
+        summary: '{{page}} · {{width}} × {{height}} mm · {{capacity}} labels ({{cols}} × {{rows}})',
+        previewWaiting: 'The preview appears once the sheet layout is complete.',
+        saveAs: 'Save as template…',
+        name: 'Template name',
+        save: 'Save',
+        saveChanges: 'Save changes',
+        saveAsNew: 'Save as new',
+        edit: 'Edit',
+        delete: 'Delete',
+        deleteTitle: 'Delete label template',
+        deleteMessage: 'Delete "{{name}}" for everyone? Labels already printed are not affected.',
+        saved: 'Template "{{name}}" saved',
+        deleted: 'Template "{{name}}" deleted',
+        saveFailed: 'Could not save the template: {{msg}}',
+        deleteFailed: 'Could not delete the template: {{msg}}',
+        nameTaken: 'A label template with this name already exists.',
+        problems: {
+          incomplete: 'Fill in every field with a number.',
+          tooSmall: 'Labels must be at least {{minWidth}} mm wide and {{minHeight}} mm tall, or the text doesn’t fit.',
+          tooLong: 'Sizes, margins and gaps can be at most {{max}} mm.',
+          columns: 'Columns must be a whole number from 1 to {{max}}.',
+          rows: 'Rows must be a whole number from 1 to {{max}}.',
+          negative: 'Margins and gaps can’t be negative.',
+          tooWide: 'The labels are {{size}} mm wide with the left margin, but the page is {{page}} mm.',
+          tooTall: 'The labels are {{size}} mm tall with the top margin, but the page is {{page}} mm.',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'AMS holder — small (74 × 33 mm)',

+ 49 - 0
frontend/src/i18n/locales/es.ts

@@ -5142,6 +5142,55 @@ export default {
         id: 'Por ID',
         color: 'Por color',
       },
+      scaleHint: 'Imprime las hojas al 100 % (tamaño real), no con «Ajustar a la página»: una página reducida se desplaza respecto a las etiquetas.',
+      pngTooLarge: 'La exportación PNG está limitada a {{maxPages}} páginas de hoja a {{dpi}} ppp, y esto son {{pages}}. Usa PDF, una resolución menor o menos bobinas.',
+      custom: {
+        option: 'Hoja personalizada…',
+        savedGroup: 'Plantillas guardadas',
+        hint: 'Describe abajo tu propia hoja de etiquetas.',
+        layoutTitle: 'Disposición de la hoja',
+        page: 'Página',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'Carta EE. UU. (215,9 × 279,4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'Carta',
+        labelWidth: 'Ancho de etiqueta',
+        labelHeight: 'Alto de etiqueta',
+        columns: 'Columnas',
+        rows: 'Filas',
+        topMargin: 'Margen superior',
+        leftMargin: 'Margen izquierdo',
+        columnGap: 'Separación entre columnas',
+        rowGap: 'Separación entre filas',
+        measureHint: 'Mide desde la esquina superior izquierda de la página: márgenes hasta la primera etiqueta, separaciones entre etiquetas contiguas.',
+        diagram: 'Dibujo de la hoja con cada etiqueta en su sitio',
+        summary: '{{page}} · {{width}} × {{height}} mm · {{capacity}} etiquetas ({{cols}} × {{rows}})',
+        previewWaiting: 'La vista previa aparece cuando la disposición de la hoja esté completa.',
+        saveAs: 'Guardar como plantilla…',
+        name: 'Nombre de la plantilla',
+        save: 'Guardar',
+        saveChanges: 'Guardar cambios',
+        saveAsNew: 'Guardar como nueva',
+        edit: 'Editar',
+        delete: 'Eliminar',
+        deleteTitle: 'Eliminar plantilla de etiquetas',
+        deleteMessage: '¿Eliminar «{{name}}» para todos? Las etiquetas ya impresas no se ven afectadas.',
+        saved: 'Plantilla «{{name}}» guardada',
+        deleted: 'Plantilla «{{name}}» eliminada',
+        saveFailed: 'No se pudo guardar la plantilla: {{msg}}',
+        deleteFailed: 'No se pudo eliminar la plantilla: {{msg}}',
+        nameTaken: 'Ya existe una plantilla de etiquetas con este nombre.',
+        problems: {
+          incomplete: 'Rellena cada campo con un número.',
+          tooSmall: 'Las etiquetas deben medir al menos {{minWidth}} mm de ancho y {{minHeight}} mm de alto, o el texto no cabe.',
+          tooLong: 'Los tamaños, márgenes y separaciones pueden medir como máximo {{max}} mm.',
+          columns: 'Las columnas deben ser un número entero de 1 a {{max}}.',
+          rows: 'Las filas deben ser un número entero de 1 a {{max}}.',
+          negative: 'Los márgenes y las separaciones no pueden ser negativos.',
+          tooWide: 'Las etiquetas miden {{size}} mm de ancho con el margen izquierdo, pero la página mide {{page}} mm.',
+          tooTall: 'Las etiquetas miden {{size}} mm de alto con el margen superior, pero la página mide {{page}} mm.',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'Soporte de AMS — pequeño (74 × 33 mm)',

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

@@ -5128,6 +5128,55 @@ export default {
         id: 'Par ID',
         color: 'Par couleur',
       },
+      scaleHint: 'Imprimez les planches à 100 % (taille réelle), pas en « Ajuster à la page » : une page réduite se décale par rapport aux étiquettes.',
+      pngTooLarge: 'L’export PNG est limité à {{maxPages}} pages de planche à {{dpi}} ppp, et il y en a {{pages}}. Utilisez le PDF, une résolution plus basse ou moins de bobines.',
+      custom: {
+        option: 'Planche personnalisée…',
+        savedGroup: 'Modèles enregistrés',
+        hint: 'Décrivez ci-dessous votre propre planche d’étiquettes.',
+        layoutTitle: 'Disposition de la planche',
+        page: 'Page',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'US Letter (215,9 × 279,4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'Lettre US',
+        labelWidth: 'Largeur d’étiquette',
+        labelHeight: 'Hauteur d’étiquette',
+        columns: 'Colonnes',
+        rows: 'Lignes',
+        topMargin: 'Marge du haut',
+        leftMargin: 'Marge de gauche',
+        columnGap: 'Espace entre colonnes',
+        rowGap: 'Espace entre lignes',
+        measureHint: 'Mesurez depuis le coin supérieur gauche de la page : marges jusqu’à la première étiquette, espaces entre étiquettes voisines.',
+        diagram: 'Dessin de la planche avec chaque étiquette à sa place',
+        summary: '{{page}} · {{width}} × {{height}} mm · {{capacity}} étiquettes ({{cols}} × {{rows}})',
+        previewWaiting: 'L’aperçu s’affiche une fois la disposition de la planche complète.',
+        saveAs: 'Enregistrer comme modèle…',
+        name: 'Nom du modèle',
+        save: 'Enregistrer',
+        saveChanges: 'Enregistrer les modifications',
+        saveAsNew: 'Enregistrer comme nouveau',
+        edit: 'Modifier',
+        delete: 'Supprimer',
+        deleteTitle: 'Supprimer le modèle d’étiquettes',
+        deleteMessage: 'Supprimer « {{name}} » pour tout le monde ? Les étiquettes déjà imprimées ne sont pas concernées.',
+        saved: 'Modèle « {{name}} » enregistré',
+        deleted: 'Modèle « {{name}} » supprimé',
+        saveFailed: 'Impossible d’enregistrer le modèle : {{msg}}',
+        deleteFailed: 'Impossible de supprimer le modèle : {{msg}}',
+        nameTaken: 'Un modèle d’étiquettes porte déjà ce nom.',
+        problems: {
+          incomplete: 'Remplissez chaque champ avec un nombre.',
+          tooSmall: 'Les étiquettes doivent mesurer au moins {{minWidth}} mm de large et {{minHeight}} mm de haut, sinon le texte ne tient pas.',
+          tooLong: 'Les tailles, marges et espaces ne peuvent pas dépasser {{max}} mm.',
+          columns: 'Le nombre de colonnes doit être un entier de 1 à {{max}}.',
+          rows: 'Le nombre de lignes doit être un entier de 1 à {{max}}.',
+          negative: 'Les marges et les espaces ne peuvent pas être négatifs.',
+          tooWide: 'Les étiquettes font {{size}} mm de large avec la marge de gauche, mais la page fait {{page}} mm.',
+          tooTall: 'Les étiquettes font {{size}} mm de haut avec la marge du haut, mais la page fait {{page}} mm.',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'Support AMS — petit (74 × 33 mm)',

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

@@ -5127,6 +5127,55 @@ export default {
         id: 'Per ID',
         color: 'Per colore',
       },
+      scaleHint: 'Stampa i fogli al 100% (dimensioni reali), non con «Adatta alla pagina»: una pagina rimpicciolita si sposta rispetto alle etichette.',
+      pngTooLarge: 'L’esportazione PNG è limitata a {{maxPages}} pagine di foglio a {{dpi}} dpi, e queste sono {{pages}}. Usa il PDF, una risoluzione più bassa o meno bobine.',
+      custom: {
+        option: 'Foglio personalizzato…',
+        savedGroup: 'Modelli salvati',
+        hint: 'Descrivi qui sotto il tuo foglio di etichette.',
+        layoutTitle: 'Disposizione del foglio',
+        page: 'Pagina',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'Letter USA (215,9 × 279,4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'Letter USA',
+        labelWidth: 'Larghezza etichetta',
+        labelHeight: 'Altezza etichetta',
+        columns: 'Colonne',
+        rows: 'Righe',
+        topMargin: 'Margine superiore',
+        leftMargin: 'Margine sinistro',
+        columnGap: 'Spazio tra le colonne',
+        rowGap: 'Spazio tra le righe',
+        measureHint: 'Misura dall’angolo in alto a sinistra della pagina: margini fino alla prima etichetta, spazi tra etichette vicine.',
+        diagram: 'Disegno del foglio con ogni etichetta al suo posto',
+        summary: '{{page}} · {{width}} × {{height}} mm · {{capacity}} etichette ({{cols}} × {{rows}})',
+        previewWaiting: 'L’anteprima compare quando la disposizione del foglio è completa.',
+        saveAs: 'Salva come modello…',
+        name: 'Nome del modello',
+        save: 'Salva',
+        saveChanges: 'Salva modifiche',
+        saveAsNew: 'Salva come nuovo',
+        edit: 'Modifica',
+        delete: 'Elimina',
+        deleteTitle: 'Elimina modello di etichette',
+        deleteMessage: 'Eliminare «{{name}}» per tutti? Le etichette già stampate non cambiano.',
+        saved: 'Modello «{{name}}» salvato',
+        deleted: 'Modello «{{name}}» eliminato',
+        saveFailed: 'Impossibile salvare il modello: {{msg}}',
+        deleteFailed: 'Impossibile eliminare il modello: {{msg}}',
+        nameTaken: 'Esiste già un modello di etichette con questo nome.',
+        problems: {
+          incomplete: 'Compila ogni campo con un numero.',
+          tooSmall: 'Le etichette devono essere larghe almeno {{minWidth}} mm e alte almeno {{minHeight}} mm, altrimenti il testo non ci sta.',
+          tooLong: 'Dimensioni, margini e spazi possono essere al massimo di {{max}} mm.',
+          columns: 'Le colonne devono essere un numero intero da 1 a {{max}}.',
+          rows: 'Le righe devono essere un numero intero da 1 a {{max}}.',
+          negative: 'Margini e spazi non possono essere negativi.',
+          tooWide: 'Le etichette sono larghe {{size}} mm con il margine sinistro, ma la pagina è di {{page}} mm.',
+          tooTall: 'Le etichette sono alte {{size}} mm con il margine superiore, ma la pagina è di {{page}} mm.',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'Supporto AMS — piccolo (74 × 33 mm)',

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

@@ -5140,6 +5140,55 @@ export default {
         id: 'ID順',
         color: '色順',
       },
+      scaleHint: 'シートは「ページに合わせる」ではなく100%(実際のサイズ)で印刷してください。縮小されるとラベルからずれます。',
+      pngTooLarge: 'PNG出力は{{dpi}} dpiでシート{{maxPages}}ページまでですが、これは{{pages}}ページです。PDF、低い解像度、または少ないスプールを使ってください。',
+      custom: {
+        option: 'カスタムシート…',
+        savedGroup: '保存済みテンプレート',
+        hint: '下で独自のラベルシートを設定します。',
+        layoutTitle: 'シートのレイアウト',
+        page: '用紙',
+        pageA4: 'A4(210 × 297 mm)',
+        pageLetter: 'USレター(215.9 × 279.4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'レター',
+        labelWidth: 'ラベルの幅',
+        labelHeight: 'ラベルの高さ',
+        columns: '列数',
+        rows: '行数',
+        topMargin: '上余白',
+        leftMargin: '左余白',
+        columnGap: '列の間隔',
+        rowGap: '行の間隔',
+        measureHint: '用紙の左上の角から測ります。余白は最初のラベルまで、間隔は隣り合うラベルの間です。',
+        diagram: 'すべてのラベルを配置したシートの図',
+        summary: '{{page}} · {{width}} × {{height}} mm · {{capacity}} 枚({{cols}} × {{rows}})',
+        previewWaiting: 'シートのレイアウトがすべて入力されるとプレビューが表示されます。',
+        saveAs: 'テンプレートとして保存…',
+        name: 'テンプレート名',
+        save: '保存',
+        saveChanges: '変更を保存',
+        saveAsNew: '新規として保存',
+        edit: '編集',
+        delete: '削除',
+        deleteTitle: 'ラベルテンプレートを削除',
+        deleteMessage: '「{{name}}」を全員から削除しますか?印刷済みのラベルには影響しません。',
+        saved: 'テンプレート「{{name}}」を保存しました',
+        deleted: 'テンプレート「{{name}}」を削除しました',
+        saveFailed: 'テンプレートを保存できませんでした:{{msg}}',
+        deleteFailed: 'テンプレートを削除できませんでした:{{msg}}',
+        nameTaken: 'この名前のラベルテンプレートは既にあります。',
+        problems: {
+          incomplete: 'すべての項目に数値を入力してください。',
+          tooSmall: 'ラベルは幅{{minWidth}} mm以上、高さ{{minHeight}} mm以上にしてください。小さいと文字が入りません。',
+          tooLong: 'サイズ、余白、間隔は最大{{max}} mmです。',
+          columns: '列数は1~{{max}}の整数にしてください。',
+          rows: '行数は1~{{max}}の整数にしてください。',
+          negative: '余白と間隔に負の値は使えません。',
+          tooWide: 'ラベルの幅は左余白を含めて{{size}} mmですが、用紙は{{page}} mmです。',
+          tooTall: 'ラベルの高さは上余白を含めて{{size}} mmですが、用紙は{{page}} mmです。',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'AMS ホルダー — 小 (74 × 33 mm)',

+ 49 - 0
frontend/src/i18n/locales/ko.ts

@@ -4894,6 +4894,55 @@ export default {
       bulkTitle: '현재 표시된 {{count}}개에서 라벨을 인쇄할 스풀 선택',
       noSpoolsTitle: '라벨을 붙일 스풀 없음',
       error: '라벨을 생성할 수 없습니다: {{msg}}',
+      scaleHint: '시트는 "페이지에 맞춤"이 아닌 100%(실제 크기)로 인쇄하세요. 축소되면 라벨에서 어긋납니다.',
+      pngTooLarge: 'PNG 내보내기는 {{dpi}} dpi에서 시트 {{maxPages}}페이지까지인데 이 작업은 {{pages}}페이지입니다. PDF, 더 낮은 해상도 또는 더 적은 스풀을 사용하세요.',
+      custom: {
+        option: '사용자 지정 시트…',
+        savedGroup: '저장된 템플릿',
+        hint: '아래에서 직접 라벨 시트를 설정하세요.',
+        layoutTitle: '시트 레이아웃',
+        page: '용지',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'US 레터 (215.9 × 279.4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: '레터',
+        labelWidth: '라벨 너비',
+        labelHeight: '라벨 높이',
+        columns: '열',
+        rows: '행',
+        topMargin: '위쪽 여백',
+        leftMargin: '왼쪽 여백',
+        columnGap: '열 간격',
+        rowGap: '행 간격',
+        measureHint: '용지의 왼쪽 위 모서리부터 측정하세요. 여백은 첫 라벨까지, 간격은 이웃한 라벨 사이입니다.',
+        diagram: '모든 라벨이 배치된 시트 그림',
+        summary: '{{page}} · {{width}} × {{height}} mm · 라벨 {{capacity}}개 ({{cols}} × {{rows}})',
+        previewWaiting: '시트 레이아웃을 모두 입력하면 미리보기가 나타납니다.',
+        saveAs: '템플릿으로 저장…',
+        name: '템플릿 이름',
+        save: '저장',
+        saveChanges: '변경 사항 저장',
+        saveAsNew: '새로 저장',
+        edit: '편집',
+        delete: '삭제',
+        deleteTitle: '라벨 템플릿 삭제',
+        deleteMessage: '"{{name}}"을(를) 모든 사용자에게서 삭제할까요? 이미 인쇄한 라벨에는 영향이 없습니다.',
+        saved: '"{{name}}" 템플릿을 저장했습니다',
+        deleted: '"{{name}}" 템플릿을 삭제했습니다',
+        saveFailed: '템플릿을 저장하지 못했습니다: {{msg}}',
+        deleteFailed: '템플릿을 삭제하지 못했습니다: {{msg}}',
+        nameTaken: '같은 이름의 라벨 템플릿이 이미 있습니다.',
+        problems: {
+          incomplete: '모든 칸에 숫자를 입력하세요.',
+          tooSmall: '라벨은 너비 {{minWidth}} mm, 높이 {{minHeight}} mm 이상이어야 텍스트가 들어갑니다.',
+          tooLong: '크기, 여백, 간격은 최대 {{max}} mm입니다.',
+          columns: '열은 1에서 {{max}} 사이의 정수여야 합니다.',
+          rows: '행은 1에서 {{max}} 사이의 정수여야 합니다.',
+          negative: '여백과 간격은 음수일 수 없습니다.',
+          tooWide: '라벨 너비가 왼쪽 여백을 포함해 {{size}} mm인데 용지는 {{page}} mm입니다.',
+          tooTall: '라벨 높이가 위쪽 여백을 포함해 {{size}} mm인데 용지는 {{page}} mm입니다.',
+        },
+      },
       templates: {
         box40x30: {
           label: '박스 라벨 (40 × 30 mm)',

+ 49 - 0
frontend/src/i18n/locales/nl.ts

@@ -5176,6 +5176,55 @@ export default {
         id: 'Op ID',
         color: 'Op kleur',
       },
+      scaleHint: 'Druk vellen af op 100% (werkelijke grootte), niet met "Aanpassen aan pagina": een verkleinde pagina verschuift ten opzichte van de labels.',
+      pngTooLarge: 'PNG-export is beperkt tot {{maxPages}} vel-pagina’s bij {{dpi}} dpi, en dit zijn er {{pages}}. Gebruik PDF, een lagere resolutie of minder spoelen.',
+      custom: {
+        option: 'Eigen vel…',
+        savedGroup: 'Opgeslagen sjablonen',
+        hint: 'Beschrijf hieronder je eigen labelvel.',
+        layoutTitle: 'Indeling van het vel',
+        page: 'Pagina',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'US Letter (215,9 × 279,4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'US Letter-formaat',
+        labelWidth: 'Labelbreedte',
+        labelHeight: 'Labelhoogte',
+        columns: 'Kolommen',
+        rows: 'Rijen',
+        topMargin: 'Bovenmarge',
+        leftMargin: 'Linkermarge',
+        columnGap: 'Ruimte tussen kolommen',
+        rowGap: 'Ruimte tussen rijen',
+        measureHint: 'Meet vanaf de linkerbovenhoek van de pagina: marges tot het eerste label, ruimtes tussen naastgelegen labels.',
+        diagram: 'Tekening van het vel met elk label op zijn plek',
+        summary: '{{page}} · {{width}} × {{height}} mm · {{capacity}} labels ({{cols}} × {{rows}})',
+        previewWaiting: 'Het voorbeeld verschijnt zodra de indeling van het vel compleet is.',
+        saveAs: 'Opslaan als sjabloon…',
+        name: 'Naam van het sjabloon',
+        save: 'Opslaan',
+        saveChanges: 'Wijzigingen opslaan',
+        saveAsNew: 'Opslaan als nieuw',
+        edit: 'Bewerken',
+        delete: 'Verwijderen',
+        deleteTitle: 'Labelsjabloon verwijderen',
+        deleteMessage: '"{{name}}" voor iedereen verwijderen? Al afgedrukte labels blijven zoals ze zijn.',
+        saved: 'Sjabloon "{{name}}" opgeslagen',
+        deleted: 'Sjabloon "{{name}}" verwijderd',
+        saveFailed: 'Kon het sjabloon niet opslaan: {{msg}}',
+        deleteFailed: 'Kon het sjabloon niet verwijderen: {{msg}}',
+        nameTaken: 'Er bestaat al een labelsjabloon met deze naam.',
+        problems: {
+          incomplete: 'Vul elk veld in met een getal.',
+          tooSmall: 'Labels moeten minstens {{minWidth}} mm breed en {{minHeight}} mm hoog zijn, anders past de tekst niet.',
+          tooLong: 'Maten, marges en tussenruimtes mogen hoogstens {{max}} mm zijn.',
+          columns: 'Kolommen moeten een heel getal van 1 tot {{max}} zijn.',
+          rows: 'Rijen moeten een heel getal van 1 tot {{max}} zijn.',
+          negative: 'Marges en tussenruimtes kunnen niet negatief zijn.',
+          tooWide: 'De labels zijn met de linkermarge {{size}} mm breed, maar de pagina is {{page}} mm.',
+          tooTall: 'De labels zijn met de bovenmarge {{size}} mm hoog, maar de pagina is {{page}} mm.',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'AMS-houder — klein (74 × 33 mm)',

+ 49 - 0
frontend/src/i18n/locales/pl.ts

@@ -5178,6 +5178,55 @@ export default {
         id: 'Według ID',
         color: 'Według koloru',
       },
+      scaleHint: 'Drukuj arkusze w skali 100% (rzeczywisty rozmiar), nie „Dopasuj do strony”: pomniejszona strona rozjeżdża się z etykietami.',
+      pngTooLarge: 'Eksport PNG jest ograniczony do {{maxPages}} stron arkusza przy {{dpi}} dpi, a tu jest ich {{pages}}. Użyj PDF, niższej rozdzielczości lub mniejszej liczby szpul.',
+      custom: {
+        option: 'Własny arkusz…',
+        savedGroup: 'Zapisane szablony',
+        hint: 'Opisz poniżej własny arkusz etykiet.',
+        layoutTitle: 'Układ arkusza',
+        page: 'Strona',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'US Letter (215,9 × 279,4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'Letter',
+        labelWidth: 'Szerokość etykiety',
+        labelHeight: 'Wysokość etykiety',
+        columns: 'Kolumny',
+        rows: 'Wiersze',
+        topMargin: 'Margines górny',
+        leftMargin: 'Margines lewy',
+        columnGap: 'Odstęp między kolumnami',
+        rowGap: 'Odstęp między wierszami',
+        measureHint: 'Mierz od lewego górnego rogu strony: marginesy do pierwszej etykiety, odstępy między sąsiednimi etykietami.',
+        diagram: 'Rysunek arkusza z każdą etykietą na swoim miejscu',
+        summary: '{{page}} · {{width}} × {{height}} mm · etykiety: {{capacity}} ({{cols}} × {{rows}})',
+        previewWaiting: 'Podgląd pojawi się, gdy układ arkusza będzie kompletny.',
+        saveAs: 'Zapisz jako szablon…',
+        name: 'Nazwa szablonu',
+        save: 'Zapisz',
+        saveChanges: 'Zapisz zmiany',
+        saveAsNew: 'Zapisz jako nowy',
+        edit: 'Edytuj',
+        delete: 'Usuń',
+        deleteTitle: 'Usuń szablon etykiet',
+        deleteMessage: 'Usunąć „{{name}}” dla wszystkich? Wydrukowanych już etykiet to nie dotyczy.',
+        saved: 'Zapisano szablon „{{name}}”',
+        deleted: 'Usunięto szablon „{{name}}”',
+        saveFailed: 'Nie udało się zapisać szablonu: {{msg}}',
+        deleteFailed: 'Nie udało się usunąć szablonu: {{msg}}',
+        nameTaken: 'Szablon etykiet o tej nazwie już istnieje.',
+        problems: {
+          incomplete: 'Wpisz liczbę w każde pole.',
+          tooSmall: 'Etykiety muszą mieć co najmniej {{minWidth}} mm szerokości i {{minHeight}} mm wysokości, inaczej tekst się nie zmieści.',
+          tooLong: 'Rozmiary, marginesy i odstępy mogą mieć najwyżej {{max}} mm.',
+          columns: 'Liczba kolumn musi być liczbą całkowitą od 1 do {{max}}.',
+          rows: 'Liczba wierszy musi być liczbą całkowitą od 1 do {{max}}.',
+          negative: 'Marginesy i odstępy nie mogą być ujemne.',
+          tooWide: 'Etykiety z lewym marginesem mają {{size}} mm szerokości, a strona {{page}} mm.',
+          tooTall: 'Etykiety z górnym marginesem mają {{size}} mm wysokości, a strona {{page}} mm.',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'Uchwyt AMS — mały (74 × 33 mm)',

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

@@ -5127,6 +5127,55 @@ export default {
         id: 'Por ID',
         color: 'Por cor',
       },
+      scaleHint: 'Imprima as folhas em 100% (tamanho real), não em "Ajustar à página": uma página reduzida se desloca em relação às etiquetas.',
+      pngTooLarge: 'A exportação PNG é limitada a {{maxPages}} páginas de folha a {{dpi}} dpi, e esta tem {{pages}}. Use PDF, uma resolução menor ou menos carretéis.',
+      custom: {
+        option: 'Folha personalizada…',
+        savedGroup: 'Modelos salvos',
+        hint: 'Descreva abaixo sua própria folha de etiquetas.',
+        layoutTitle: 'Layout da folha',
+        page: 'Página',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'Carta EUA (215,9 × 279,4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'Carta',
+        labelWidth: 'Largura da etiqueta',
+        labelHeight: 'Altura da etiqueta',
+        columns: 'Colunas',
+        rows: 'Linhas',
+        topMargin: 'Margem superior',
+        leftMargin: 'Margem esquerda',
+        columnGap: 'Espaço entre colunas',
+        rowGap: 'Espaço entre linhas',
+        measureHint: 'Meça a partir do canto superior esquerdo da página: margens até a primeira etiqueta, espaços entre etiquetas vizinhas.',
+        diagram: 'Desenho da folha com cada etiqueta no lugar',
+        summary: '{{page}} · {{width}} × {{height}} mm · {{capacity}} etiquetas ({{cols}} × {{rows}})',
+        previewWaiting: 'A pré-visualização aparece quando o layout da folha estiver completo.',
+        saveAs: 'Salvar como modelo…',
+        name: 'Nome do modelo',
+        save: 'Salvar',
+        saveChanges: 'Salvar alterações',
+        saveAsNew: 'Salvar como novo',
+        edit: 'Editar',
+        delete: 'Excluir',
+        deleteTitle: 'Excluir modelo de etiquetas',
+        deleteMessage: 'Excluir "{{name}}" para todos? Etiquetas já impressas não são afetadas.',
+        saved: 'Modelo "{{name}}" salvo',
+        deleted: 'Modelo "{{name}}" excluído',
+        saveFailed: 'Não foi possível salvar o modelo: {{msg}}',
+        deleteFailed: 'Não foi possível excluir o modelo: {{msg}}',
+        nameTaken: 'Já existe um modelo de etiquetas com este nome.',
+        problems: {
+          incomplete: 'Preencha cada campo com um número.',
+          tooSmall: 'As etiquetas devem ter pelo menos {{minWidth}} mm de largura e {{minHeight}} mm de altura, ou o texto não cabe.',
+          tooLong: 'Tamanhos, margens e espaços podem ter no máximo {{max}} mm.',
+          columns: 'As colunas devem ser um número inteiro de 1 a {{max}}.',
+          rows: 'As linhas devem ser um número inteiro de 1 a {{max}}.',
+          negative: 'Margens e espaços não podem ser negativos.',
+          tooWide: 'As etiquetas têm {{size}} mm de largura com a margem esquerda, mas a página tem {{page}} mm.',
+          tooTall: 'As etiquetas têm {{size}} mm de altura com a margem superior, mas a página tem {{page}} mm.',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'Suporte AMS — pequeno (74 × 33 mm)',

+ 49 - 0
frontend/src/i18n/locales/ru.ts

@@ -4918,6 +4918,55 @@ export default {
         id: "По ID",
         color: "По цвету",
       },
+      scaleHint: "Печатайте листы в масштабе 100% (реальный размер), а не «По размеру страницы»: уменьшенная страница съезжает с этикеток.",
+      pngTooLarge: "Экспорт в PNG ограничен {{maxPages}} страницами листов при {{dpi}} dpi, а здесь их {{pages}}. Используйте PDF, меньшее разрешение или меньше катушек.",
+      custom: {
+        option: "Свой лист…",
+        savedGroup: "Сохранённые шаблоны",
+        hint: "Опишите свой лист этикеток ниже.",
+        layoutTitle: "Разметка листа",
+        page: "Страница",
+        pageA4: "A4 (210 × 297 мм)",
+        pageLetter: "US Letter (215,9 × 279,4 мм)",
+        pageShortA4: "A4",
+        pageShortLetter: "Letter (США)",
+        labelWidth: "Ширина этикетки",
+        labelHeight: "Высота этикетки",
+        columns: "Столбцы",
+        rows: "Строки",
+        topMargin: "Верхнее поле",
+        leftMargin: "Левое поле",
+        columnGap: "Промежуток между столбцами",
+        rowGap: "Промежуток между строками",
+        measureHint: "Измеряйте от левого верхнего угла страницы: поля — до первой этикетки, промежутки — между соседними этикетками.",
+        diagram: "Схема листа со всеми этикетками на своих местах",
+        summary: "{{page}} · {{width}} × {{height}} мм · этикеток: {{capacity}} ({{cols}} × {{rows}})",
+        previewWaiting: "Предпросмотр появится, когда разметка листа будет заполнена.",
+        saveAs: "Сохранить как шаблон…",
+        name: "Название шаблона",
+        save: "Сохранить",
+        saveChanges: "Сохранить изменения",
+        saveAsNew: "Сохранить как новый",
+        edit: "Изменить",
+        delete: "Удалить",
+        deleteTitle: "Удалить шаблон этикеток",
+        deleteMessage: "Удалить «{{name}}» для всех? Уже напечатанные этикетки это не затронет.",
+        saved: "Шаблон «{{name}}» сохранён",
+        deleted: "Шаблон «{{name}}» удалён",
+        saveFailed: "Не удалось сохранить шаблон: {{msg}}",
+        deleteFailed: "Не удалось удалить шаблон: {{msg}}",
+        nameTaken: "Шаблон этикеток с таким названием уже существует.",
+        problems: {
+          incomplete: "Заполните каждое поле числом.",
+          tooSmall: "Ширина этикеток должна быть не меньше {{minWidth}} мм, а высота — не меньше {{minHeight}} мм, иначе текст не поместится.",
+          tooLong: "Размеры, поля и промежутки не могут превышать {{max}} мм.",
+          columns: "Число столбцов должно быть целым от 1 до {{max}}.",
+          rows: "Число строк должно быть целым от 1 до {{max}}.",
+          negative: "Поля и промежутки не могут быть отрицательными.",
+          tooWide: "Ширина этикеток с левым полем — {{size}} мм, а ширина страницы — {{page}} мм.",
+          tooTall: "Высота этикеток с верхним полем — {{size}} мм, а высота страницы — {{page}} мм.",
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: "Держатель AMS — малый (74 × 33 мм)",

+ 49 - 0
frontend/src/i18n/locales/sv.ts

@@ -5175,6 +5175,55 @@ errors: {
         id: 'Efter ID',
         color: 'Efter färg',
       },
+      scaleHint: 'Skriv ut ark i 100 % (faktisk storlek), inte "Anpassa till sidan": en förminskad sida glider iväg från etiketterna.',
+      pngTooLarge: 'PNG-export är begränsad till {{maxPages}} arksidor vid {{dpi}} dpi, och det här är {{pages}}. Använd PDF, lägre upplösning eller färre spolar.',
+      custom: {
+        option: 'Eget ark…',
+        savedGroup: 'Sparade mallar',
+        hint: 'Beskriv ditt eget etikettark nedan.',
+        layoutTitle: 'Arkets layout',
+        page: 'Sida',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'US Letter (215,9 × 279,4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'US Letter-format',
+        labelWidth: 'Etikettbredd',
+        labelHeight: 'Etiketthöjd',
+        columns: 'Kolumner',
+        rows: 'Rader',
+        topMargin: 'Övre marginal',
+        leftMargin: 'Vänster marginal',
+        columnGap: 'Avstånd mellan kolumner',
+        rowGap: 'Avstånd mellan rader',
+        measureHint: 'Mät från sidans övre vänstra hörn: marginaler till första etiketten, avstånd mellan intilliggande etiketter.',
+        diagram: 'Ritning av arket med varje etikett på sin plats',
+        summary: '{{page}} · {{width}} × {{height}} mm · {{capacity}} etiketter ({{cols}} × {{rows}})',
+        previewWaiting: 'Förhandsvisningen visas när arkets layout är ifylld.',
+        saveAs: 'Spara som mall…',
+        name: 'Mallens namn',
+        save: 'Spara',
+        saveChanges: 'Spara ändringar',
+        saveAsNew: 'Spara som ny',
+        edit: 'Redigera',
+        delete: 'Ta bort',
+        deleteTitle: 'Ta bort etikettmall',
+        deleteMessage: 'Ta bort "{{name}}" för alla? Etiketter som redan skrivits ut påverkas inte.',
+        saved: 'Mallen "{{name}}" sparades',
+        deleted: 'Mallen "{{name}}" togs bort',
+        saveFailed: 'Kunde inte spara mallen: {{msg}}',
+        deleteFailed: 'Kunde inte ta bort mallen: {{msg}}',
+        nameTaken: 'Det finns redan en etikettmall med det här namnet.',
+        problems: {
+          incomplete: 'Fyll i varje fält med ett tal.',
+          tooSmall: 'Etiketter måste vara minst {{minWidth}} mm breda och {{minHeight}} mm höga, annars får texten inte plats.',
+          tooLong: 'Storlekar, marginaler och avstånd får vara högst {{max}} mm.',
+          columns: 'Kolumner måste vara ett heltal från 1 till {{max}}.',
+          rows: 'Rader måste vara ett heltal från 1 till {{max}}.',
+          negative: 'Marginaler och avstånd kan inte vara negativa.',
+          tooWide: 'Etiketterna är {{size}} mm breda med vänstermarginalen, men sidan är {{page}} mm.',
+          tooTall: 'Etiketterna är {{size}} mm höga med övre marginalen, men sidan är {{page}} mm.',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'AMS-hållare — liten (74 × 33 mm)',

+ 49 - 0
frontend/src/i18n/locales/tr.ts

@@ -5129,6 +5129,55 @@ export default {
         id: 'ID\'ye göre',
         color: 'Renge göre',
       },
+      scaleHint: 'Sayfaları "Sayfaya sığdır" ile değil, %100 (gerçek boyut) ölçekte yazdırın: küçültülen sayfa etiketlerden kayar.',
+      pngTooLarge: 'PNG dışa aktarma {{dpi}} dpi\'de {{maxPages}} sayfa ile sınırlıdır, bu ise {{pages}} sayfa. PDF, daha düşük çözünürlük veya daha az makara kullanın.',
+      custom: {
+        option: 'Özel sayfa…',
+        savedGroup: 'Kayıtlı şablonlar',
+        hint: 'Kendi etiket sayfanızı aşağıda tanımlayın.',
+        layoutTitle: 'Sayfa düzeni',
+        page: 'Sayfa',
+        pageA4: 'A4 (210 × 297 mm)',
+        pageLetter: 'ABD Letter (215,9 × 279,4 mm)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'ABD Letter',
+        labelWidth: 'Etiket genişliği',
+        labelHeight: 'Etiket yüksekliği',
+        columns: 'Sütunlar',
+        rows: 'Satırlar',
+        topMargin: 'Üst kenar boşluğu',
+        leftMargin: 'Sol kenar boşluğu',
+        columnGap: 'Sütunlar arası boşluk',
+        rowGap: 'Satırlar arası boşluk',
+        measureHint: 'Sayfanın sol üst köşesinden ölçün: kenar boşlukları ilk etikete kadar, aralıklar komşu etiketler arasında.',
+        diagram: 'Tüm etiketleri yerinde gösteren sayfa çizimi',
+        summary: '{{page}} · {{width}} × {{height}} mm · {{capacity}} etiket ({{cols}} × {{rows}})',
+        previewWaiting: 'Sayfa düzeni tamamlandığında önizleme görünür.',
+        saveAs: 'Şablon olarak kaydet…',
+        name: 'Şablon adı',
+        save: 'Kaydet',
+        saveChanges: 'Değişiklikleri kaydet',
+        saveAsNew: 'Yeni olarak kaydet',
+        edit: 'Düzenle',
+        delete: 'Sil',
+        deleteTitle: 'Etiket şablonunu sil',
+        deleteMessage: '"{{name}}" herkes için silinsin mi? Önceden yazdırılmış etiketler etkilenmez.',
+        saved: '"{{name}}" şablonu kaydedildi',
+        deleted: '"{{name}}" şablonu silindi',
+        saveFailed: 'Şablon kaydedilemedi: {{msg}}',
+        deleteFailed: 'Şablon silinemedi: {{msg}}',
+        nameTaken: 'Bu adla bir etiket şablonu zaten var.',
+        problems: {
+          incomplete: 'Her alana bir sayı girin.',
+          tooSmall: 'Metnin sığması için etiketler en az {{minWidth}} mm genişliğinde ve {{minHeight}} mm yüksekliğinde olmalıdır.',
+          tooLong: 'Boyutlar, kenar boşlukları ve aralıklar en fazla {{max}} mm olabilir.',
+          columns: 'Sütun sayısı 1 ile {{max}} arasında bir tam sayı olmalıdır.',
+          rows: 'Satır sayısı 1 ile {{max}} arasında bir tam sayı olmalıdır.',
+          negative: 'Kenar boşlukları ve aralıklar negatif olamaz.',
+          tooWide: 'Etiketler sol kenar boşluğuyla birlikte {{size}} mm genişliğinde, ancak sayfa {{page}} mm.',
+          tooTall: 'Etiketler üst kenar boşluğuyla birlikte {{size}} mm yüksekliğinde, ancak sayfa {{page}} mm.',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'AMS tutucu — küçük (74 × 33 mm)',

+ 49 - 0
frontend/src/i18n/locales/uk.ts

@@ -5172,6 +5172,55 @@ export default {
         id: "За ідентифікатором",
         color: "За кольором",
       },
+      scaleHint: "Друкуйте аркуші в масштабі 100% (реальний розмір), а не «За розміром сторінки»: зменшена сторінка з’їжджає з етикеток.",
+      pngTooLarge: "Експорт у PNG обмежено {{maxPages}} сторінками аркушів за {{dpi}} dpi, а тут їх {{pages}}. Використайте PDF, нижчу роздільність або менше котушок.",
+      custom: {
+        option: "Власний аркуш…",
+        savedGroup: "Збережені шаблони",
+        hint: "Опишіть свій аркуш етикеток нижче.",
+        layoutTitle: "Розмітка аркуша",
+        page: "Сторінка",
+        pageA4: "A4 (210 × 297 мм)",
+        pageLetter: "US Letter (215,9 × 279,4 мм)",
+        pageShortA4: "A4",
+        pageShortLetter: "Letter (США)",
+        labelWidth: "Ширина етикетки",
+        labelHeight: "Висота етикетки",
+        columns: "Стовпці",
+        rows: "Рядки",
+        topMargin: "Верхнє поле",
+        leftMargin: "Ліве поле",
+        columnGap: "Проміжок між стовпцями",
+        rowGap: "Проміжок між рядками",
+        measureHint: "Вимірюйте від лівого верхнього кута сторінки: поля — до першої етикетки, проміжки — між сусідніми етикетками.",
+        diagram: "Схема аркуша з усіма етикетками на своїх місцях",
+        summary: "{{page}} · {{width}} × {{height}} мм · етикеток: {{capacity}} ({{cols}} × {{rows}})",
+        previewWaiting: "Попередній перегляд з’явиться, коли розмітку аркуша буде заповнено.",
+        saveAs: "Зберегти як шаблон…",
+        name: "Назва шаблону",
+        save: "Зберегти",
+        saveChanges: "Зберегти зміни",
+        saveAsNew: "Зберегти як новий",
+        edit: "Змінити",
+        delete: "Видалити",
+        deleteTitle: "Видалити шаблон етикеток",
+        deleteMessage: "Видалити «{{name}}» для всіх? Уже надруковані етикетки це не зачепить.",
+        saved: "Шаблон «{{name}}» збережено",
+        deleted: "Шаблон «{{name}}» видалено",
+        saveFailed: "Не вдалося зберегти шаблон: {{msg}}",
+        deleteFailed: "Не вдалося видалити шаблон: {{msg}}",
+        nameTaken: "Шаблон етикеток із такою назвою вже існує.",
+        problems: {
+          incomplete: "Заповніть кожне поле числом.",
+          tooSmall: "Ширина етикеток має бути не менше {{minWidth}} мм, а висота — не менше {{minHeight}} мм, інакше текст не вміститься.",
+          tooLong: "Розміри, поля й проміжки не можуть перевищувати {{max}} мм.",
+          columns: "Кількість стовпців має бути цілим числом від 1 до {{max}}.",
+          rows: "Кількість рядків має бути цілим числом від 1 до {{max}}.",
+          negative: "Поля й проміжки не можуть бути від’ємними.",
+          tooWide: "Ширина етикеток із лівим полем — {{size}} мм, а ширина сторінки — {{page}} мм.",
+          tooTall: "Висота етикеток із верхнім полем — {{size}} мм, а висота сторінки — {{page}} мм.",
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: "Тримач AMS — малий (74 × 33 мм)",

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

@@ -5127,6 +5127,55 @@ export default {
         id: '按 ID',
         color: '按颜色',
       },
+      scaleHint: '请以 100%(实际大小)打印标签纸,不要选“适合页面”:缩小的页面会与标签错位。',
+      pngTooLarge: 'PNG 导出在 {{dpi}} dpi 下最多 {{maxPages}} 页标签纸,而这次是 {{pages}} 页。请改用 PDF、更低的分辨率或更少的耗材。',
+      custom: {
+        option: '自定义标签纸…',
+        savedGroup: '已保存的模板',
+        hint: '在下方描述你自己的标签纸。',
+        layoutTitle: '标签纸布局',
+        page: '纸张',
+        pageA4: 'A4(210 × 297 毫米)',
+        pageLetter: '美国 Letter(215.9 × 279.4 毫米)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'Letter 纸',
+        labelWidth: '标签宽度',
+        labelHeight: '标签高度',
+        columns: '列数',
+        rows: '行数',
+        topMargin: '上边距',
+        leftMargin: '左边距',
+        columnGap: '列间距',
+        rowGap: '行间距',
+        measureHint: '从纸张左上角开始测量:边距量到第一张标签,间距量相邻标签之间。',
+        diagram: '每张标签就位的标签纸示意图',
+        summary: '{{page}} · {{width}} × {{height}} 毫米 · {{capacity}} 张标签({{cols}} × {{rows}})',
+        previewWaiting: '标签纸布局填写完整后会显示预览。',
+        saveAs: '另存为模板…',
+        name: '模板名称',
+        save: '保存',
+        saveChanges: '保存更改',
+        saveAsNew: '另存为新模板',
+        edit: '编辑',
+        delete: '删除',
+        deleteTitle: '删除标签模板',
+        deleteMessage: '要为所有人删除“{{name}}”吗?已打印的标签不受影响。',
+        saved: '模板“{{name}}”已保存',
+        deleted: '模板“{{name}}”已删除',
+        saveFailed: '无法保存模板:{{msg}}',
+        deleteFailed: '无法删除模板:{{msg}}',
+        nameTaken: '已有同名的标签模板。',
+        problems: {
+          incomplete: '请在每一栏填入数字。',
+          tooSmall: '标签宽度至少 {{minWidth}} 毫米、高度至少 {{minHeight}} 毫米,否则放不下文字。',
+          tooLong: '尺寸、边距和间距最多 {{max}} 毫米。',
+          columns: '列数必须是 1 到 {{max}} 之间的整数。',
+          rows: '行数必须是 1 到 {{max}} 之间的整数。',
+          negative: '边距和间距不能为负数。',
+          tooWide: '标签加上左边距宽 {{size}} 毫米,但纸张只有 {{page}} 毫米。',
+          tooTall: '标签加上上边距高 {{size}} 毫米,但纸张只有 {{page}} 毫米。',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'AMS 支架 — 小 (74 × 33 mm)',

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

@@ -5127,6 +5127,55 @@ export default {
         id: '按 ID',
         color: '按顏色',
       },
+      scaleHint: '請以 100%(實際大小)列印標籤紙,不要選「符合頁面大小」:縮小的頁面會與標籤錯位。',
+      pngTooLarge: 'PNG 匯出在 {{dpi}} dpi 下最多 {{maxPages}} 頁標籤紙,而這次是 {{pages}} 頁。請改用 PDF、較低的解析度或較少的線材。',
+      custom: {
+        option: '自訂標籤紙…',
+        savedGroup: '已儲存的範本',
+        hint: '在下方描述你自己的標籤紙。',
+        layoutTitle: '標籤紙版面',
+        page: '紙張',
+        pageA4: 'A4(210 × 297 公釐)',
+        pageLetter: '美國 Letter(215.9 × 279.4 公釐)',
+        pageShortA4: 'A4',
+        pageShortLetter: 'Letter 紙',
+        labelWidth: '標籤寬度',
+        labelHeight: '標籤高度',
+        columns: '欄數',
+        rows: '列數',
+        topMargin: '上邊界',
+        leftMargin: '左邊界',
+        columnGap: '欄間距',
+        rowGap: '列間距',
+        measureHint: '從紙張左上角開始量:邊界量到第一張標籤,間距量相鄰標籤之間。',
+        diagram: '每張標籤就定位的標籤紙示意圖',
+        summary: '{{page}} · {{width}} × {{height}} 公釐 · {{capacity}} 張標籤({{cols}} × {{rows}})',
+        previewWaiting: '標籤紙版面填寫完整後會顯示預覽。',
+        saveAs: '另存為範本…',
+        name: '範本名稱',
+        save: '儲存',
+        saveChanges: '儲存變更',
+        saveAsNew: '另存為新範本',
+        edit: '編輯',
+        delete: '刪除',
+        deleteTitle: '刪除標籤範本',
+        deleteMessage: '要為所有人刪除「{{name}}」嗎?已列印的標籤不受影響。',
+        saved: '範本「{{name}}」已儲存',
+        deleted: '範本「{{name}}」已刪除',
+        saveFailed: '無法儲存範本:{{msg}}',
+        deleteFailed: '無法刪除範本:{{msg}}',
+        nameTaken: '已有同名的標籤範本。',
+        problems: {
+          incomplete: '請在每一欄填入數字。',
+          tooSmall: '標籤寬度至少 {{minWidth}} 公釐、高度至少 {{minHeight}} 公釐,否則放不下文字。',
+          tooLong: '尺寸、邊界和間距最多 {{max}} 公釐。',
+          columns: '欄數必須是 1 到 {{max}} 之間的整數。',
+          rows: '列數必須是 1 到 {{max}} 之間的整數。',
+          negative: '邊界和間距不能是負數。',
+          tooWide: '標籤加上左邊界寬 {{size}} 公釐,但紙張只有 {{page}} 公釐。',
+          tooTall: '標籤加上上邊界高 {{size}} 公釐,但紙張只有 {{page}} 公釐。',
+        },
+      },
       templates: {
         amsHolderSmall: {
           label: 'AMS 支架 — 小 (74 × 33 mm)',

+ 152 - 0
frontend/src/utils/labelSheetLayout.ts

@@ -0,0 +1,152 @@
+// Custom label sheets (#3296): the form's draft, and the same "does it fit"
+// check the server makes in backend.app.services.label_renderer.SheetLayout,
+// so the dialog can explain a problem while the user types instead of after
+// a 422.
+
+import type { LabelPageSize, LabelSheetLayout } from '../api/client';
+
+// Mirrors of the renderer's limits (label_renderer.py), number for number, so
+// the dialog and the server never disagree about a layout.
+export const PAGE_SIZES_MM: Record<LabelPageSize, [number, number]> = {
+  a4: [210, 297],
+  letter: [215.9, 279.4],
+};
+// What the label layout can fill: the full layout from 20 mm tall, and room
+// for a four-digit spool ID beside the swatch and QR from 40 mm wide.
+export const MIN_LABEL_W_MM = 40;
+export const MIN_LABEL_H_MM = 20;
+export const MAX_LENGTH_MM = 300;
+export const MAX_COLS = 20;
+export const MAX_ROWS = 40;
+const FIT_TOLERANCE_MM = 0.01;
+// PNG export of sheets is capped at pages × dpi (MAX_PNG_SHEET_PAGES_X_DPI in labels.py).
+export const MAX_PNG_SHEET_PAGES_X_DPI = 15000;
+
+/** Pages ``labelCount`` labels take on a sheet of ``capacity``, from ``startingPosition``. */
+export function sheetPageCount(capacity: number, labelCount: number, startingPosition = 1): number {
+  return Math.ceil((startingPosition - 1 + labelCount) / capacity);
+}
+
+/** A length in millimetres, written the way the reader's language writes numbers. */
+export function formatMm(mm: number, language: string): string {
+  return new Intl.NumberFormat(language, { maximumFractionDigits: 2 }).format(mm);
+}
+
+export const LAYOUT_NUMBER_FIELDS = [
+  'label_w_mm',
+  'label_h_mm',
+  'cols',
+  'rows',
+  'top_mm',
+  'left_mm',
+  'col_gap_mm',
+  'row_gap_mm',
+] as const;
+export type LayoutNumberField = (typeof LAYOUT_NUMBER_FIELDS)[number];
+
+/** The form as typed: numbers stay strings so a half-typed "3." isn't lost. */
+export type LayoutDraft = { page: LabelPageSize } & Record<LayoutNumberField, string>;
+
+/** A reasonable starting point: a 3 × 7 grid on A4 (the Avery L7160). */
+export const DEFAULT_LAYOUT: LabelSheetLayout = {
+  page: 'a4',
+  label_w_mm: 63.5,
+  label_h_mm: 38.1,
+  cols: 3,
+  rows: 7,
+  top_mm: 15.15,
+  left_mm: 7,
+  col_gap_mm: 2.5,
+  row_gap_mm: 0,
+};
+
+export function draftFromLayout(layout: LabelSheetLayout): LayoutDraft {
+  const draft = { page: layout.page } as LayoutDraft;
+  for (const field of LAYOUT_NUMBER_FIELDS) draft[field] = String(layout[field]);
+  return draft;
+}
+
+/** The layout a draft describes, or null while a field is blank or not a number. */
+export function parseDraft(draft: LayoutDraft): LabelSheetLayout | null {
+  const layout = { page: draft.page } as LabelSheetLayout;
+  for (const field of LAYOUT_NUMBER_FIELDS) {
+    const text = draft[field].trim().replace(',', '.');
+    const value = Number(text);
+    if (text === '' || !Number.isFinite(value)) return null;
+    layout[field] = value;
+  }
+  return layout;
+}
+
+export function isLayoutDraft(value: unknown): value is LayoutDraft {
+  if (!value || typeof value !== 'object') return false;
+  const v = value as Record<string, unknown>;
+  return (
+    (v.page === 'a4' || v.page === 'letter') && LAYOUT_NUMBER_FIELDS.every((f) => typeof v[f] === 'string')
+  );
+}
+
+export interface LayoutProblem {
+  key: string;
+  fallback: string;
+  /** Lengths in mm and counts; the dialog formats them for the reader's language. */
+  params?: Record<string, number>;
+}
+
+/** Width and height the grid takes up, margins included. */
+export function gridExtentMm(layout: LabelSheetLayout): [number, number] {
+  return [
+    layout.left_mm + layout.cols * layout.label_w_mm + (layout.cols - 1) * layout.col_gap_mm,
+    layout.top_mm + layout.rows * layout.label_h_mm + (layout.rows - 1) * layout.row_gap_mm,
+  ];
+}
+
+/** Why a draft can't print, or null when it can. Same rules as the server. */
+export function layoutProblem(draft: LayoutDraft): LayoutProblem | null {
+  const layout = parseDraft(draft);
+  if (!layout) {
+    return { key: 'incomplete', fallback: 'Fill in every field with a number.' };
+  }
+  // Same order as SheetLayout.problem(), so both name the same problem first.
+  if (layout.label_w_mm < MIN_LABEL_W_MM || layout.label_h_mm < MIN_LABEL_H_MM) {
+    return {
+      key: 'tooSmall',
+      fallback: 'Labels must be at least {{minWidth}} mm wide and {{minHeight}} mm tall, or the text doesn’t fit.',
+      params: { minWidth: MIN_LABEL_W_MM, minHeight: MIN_LABEL_H_MM },
+    };
+  }
+  const lengths = [layout.label_w_mm, layout.label_h_mm, layout.top_mm, layout.left_mm, layout.col_gap_mm, layout.row_gap_mm];
+  if (Math.max(...lengths) > MAX_LENGTH_MM) {
+    return {
+      key: 'tooLong',
+      fallback: 'Sizes, margins and gaps can be at most {{max}} mm.',
+      params: { max: MAX_LENGTH_MM },
+    };
+  }
+  if (!Number.isInteger(layout.cols) || layout.cols < 1 || layout.cols > MAX_COLS) {
+    return { key: 'columns', fallback: 'Columns must be a whole number from 1 to {{max}}.', params: { max: MAX_COLS } };
+  }
+  if (!Number.isInteger(layout.rows) || layout.rows < 1 || layout.rows > MAX_ROWS) {
+    return { key: 'rows', fallback: 'Rows must be a whole number from 1 to {{max}}.', params: { max: MAX_ROWS } };
+  }
+  if (Math.min(layout.top_mm, layout.left_mm, layout.col_gap_mm, layout.row_gap_mm) < 0) {
+    return { key: 'negative', fallback: 'Margins and gaps can’t be negative.' };
+  }
+  const [pageW, pageH] = PAGE_SIZES_MM[layout.page];
+  const [gridW, gridH] = gridExtentMm(layout);
+  if (gridW > pageW + FIT_TOLERANCE_MM) {
+    return {
+      key: 'tooWide',
+      fallback: 'The labels are {{size}} mm wide with the left margin, but the page is {{page}} mm.',
+      params: { size: gridW, page: pageW },
+    };
+  }
+  if (gridH > pageH + FIT_TOLERANCE_MM) {
+    return {
+      key: 'tooTall',
+      fallback: 'The labels are {{size}} mm tall with the top margin, but the page is {{page}} mm.',
+      params: { size: gridH, page: pageH },
+    };
+  }
+  return null;
+}