Преглед изворни кода

Allow Avery label sheets to start at an unused position (#2879) (#2918)

maziggy пре 1 недеља
родитељ
комит
72b097362f

+ 1 - 1
README.md

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

+ 28 - 5
backend/app/api/routes/labels.py

@@ -5,7 +5,8 @@ Two endpoints, one per inventory backend:
 - ``POST /inventory/labels``  — local-DB spools
 - ``POST /spoolman/labels``   — Spoolman-backed spools
 
-Both accept ``{spool_ids: [int], template: str}`` and return a PDF stream.
+Both accept ``{spool_ids: [int], template: str, starting_position: int}`` and
+return a PDF stream.
 The QR code on each label deep-links to ``/inventory?spool=<id>`` so a phone
 scan jumps straight back into Bambuddy at that spool's row.
 """
@@ -18,7 +19,7 @@ from typing import Literal
 
 from fastapi import APIRouter, Depends, HTTPException, Request
 from fastapi.responses import StreamingResponse
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, model_validator
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
@@ -28,7 +29,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.spool import Spool
 from backend.app.models.user import User
-from backend.app.services.label_renderer import LabelData, TemplateName, render_labels
+from backend.app.services.label_renderer import LabelData, TemplateName, get_sheet_capacity, render_labels
 from backend.app.services.spoolman import get_spoolman_client
 from backend.app.utils.http import build_content_disposition
 
@@ -63,6 +64,18 @@ class LabelRequest(BaseModel):
     # 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
+    starting_position: int = Field(default=1, ge=1)
+
+    @model_validator(mode="after")
+    def validate_starting_position(self) -> LabelRequest:
+        capacity = get_sheet_capacity(self.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}")
+        return self
 
 
 def _split_extra_colors(raw: str | None) -> list[str] | None:
@@ -173,7 +186,12 @@ async def render_local_inventory_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
 
-    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
+    pdf = render_labels(
+        body.template,
+        data_list,
+        monochrome=body.monochrome,
+        starting_position=body.starting_position,
+    )
     filename = f"bambuddy-labels-{body.template}.pdf"
     return _stream_pdf(pdf, filename)
 
@@ -217,6 +235,11 @@ async def render_spoolman_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids]
 
-    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
+    pdf = render_labels(
+        body.template,
+        data_list,
+        monochrome=body.monochrome,
+        starting_position=body.starting_position,
+    )
     filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
     return _stream_pdf(pdf, filename)

+ 61 - 23
backend/app/services/label_renderer.py

@@ -154,24 +154,28 @@ def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
 def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
     """Draw the colour swatch. Multi-colour spools use vertical stripes
     (matching the FilamentSwatch convention in the frontend)."""
-    primary = _color_from_hex(data.rgba)
-    extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
-    colors = [primary, *extras]
+    c.saveState()
+    try:
+        primary = _color_from_hex(data.rgba)
+        extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
+        colors = [primary, *extras]
 
-    if not colors:
-        c.setFillColor(HexColor(0x808080))
-        c.rect(x, y, w, h, stroke=0, fill=1)
-        return
+        if not colors:
+            c.setFillColor(HexColor(0x808080))
+            c.rect(x, y, w, h, stroke=0, fill=1)
+            return
 
-    stripe_w = w / len(colors)
-    for i, col in enumerate(colors):
-        c.setFillColor(col)
-        c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
+        stripe_w = w / len(colors)
+        for i, col in enumerate(colors):
+            c.setFillColor(col)
+            c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
 
-    # Thin black border so light-colour swatches stay visible on white labels.
-    c.setStrokeColor(black)
-    c.setLineWidth(0.3)
-    c.rect(x, y, w, h, stroke=1, fill=0)
+        # Thin black border so light-colour swatches stay visible on white labels.
+        c.setStrokeColor(black)
+        c.setLineWidth(0.3)
+        c.rect(x, y, w, h, stroke=1, fill=0)
+    finally:
+        c.restoreState()
 
 
 def _roomy_qr_size(inner_w: float, inner_h: float) -> float:
@@ -442,7 +446,20 @@ def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData],
     return buf.getvalue()
 
 
-def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
+def get_sheet_capacity(template: TemplateName) -> int | None:
+    """Return the number of slots on a sheet template, or ``None`` for roll labels."""
+    layout = _SHEET_TEMPLATES.get(template)
+    if layout is None:
+        return None
+    return layout[3] * layout[4]
+
+
+def _render_sheet_pdf(
+    template: TemplateName,
+    data_list: list[LabelData],
+    monochrome: bool,
+    starting_position: int,
+) -> bytes:
     page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
     page_w, page_h = page_size
 
@@ -458,21 +475,37 @@ def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monoch
     c.setTitle(f"Bambuddy spool labels ({template})")
 
     per_page = cols * rows
-    for page_start in range(0, len(data_list), per_page):
-        chunk = data_list[page_start : page_start + per_page]
+    if starting_position < 1 or starting_position > per_page:
+        raise ValueError(f"Starting position must be between 1 and {per_page} for {template}")
+
+    data_index = 0
+    page_number = 0
+    while data_index < len(data_list):
+        slot_offset = starting_position - 1 if page_number == 0 else 0
+        page_capacity = per_page - slot_offset
+        chunk = data_list[data_index : data_index + page_capacity]
         for idx, data in enumerate(chunk):
-            row = idx // cols
-            col = idx % cols
+            slot_index = slot_offset + idx
+            row = slot_index // cols
+            col = slot_index % cols
             x = left_margin + col * (label_w + col_gap)
             y = page_h - top_margin - (row + 1) * label_h - row * row_gap
             _draw_label(c, x, y, label_w, label_h, data, monochrome)
         c.showPage()
+        data_index += len(chunk)
+        page_number += 1
 
     c.save()
     return buf.getvalue()
 
 
-def render_labels(template: TemplateName, data_list: list[LabelData], *, monochrome: bool = False) -> bytes:
+def render_labels(
+    template: TemplateName,
+    data_list: list[LabelData],
+    *,
+    monochrome: bool = False,
+    starting_position: int = 1,
+) -> bytes:
     """Render ``data_list`` to a PDF using the named template. Returns bytes.
 
     Empty ``data_list`` still produces a valid (empty) PDF — callers should
@@ -481,14 +514,19 @@ def render_labels(template: TemplateName, data_list: list[LabelData], *, monochr
     ``monochrome`` drops the colour swatch (which prints as a useless grey block
     on black-and-white thermal printers) and reclaims the space for text; the
     hex-code line still carries the colour. See #1870.
+
+    ``starting_position`` is one-based and applies only to the first page of a
+    sheet template. Later pages always begin at the first slot.
     """
     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)
     if template in _SHEET_TEMPLATES:
-        return _render_sheet_pdf(template, data_list, monochrome)
+        return _render_sheet_pdf(template, data_list, monochrome, starting_position)
     raise ValueError(f"Unknown label template: {template!r}")
 
 
-__all__ = ["LabelData", "TemplateName", "render_labels"]
+__all__ = ["LabelData", "TemplateName", "get_sheet_capacity", "render_labels"]
 # white re-exported for completeness; future templates may need a paper-tone variant.
 _ = white

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

@@ -141,6 +141,29 @@ class TestLocalInventoryLabels:
         assert resp.status_code == 200
         assert captured["ids"] == [s3.id, s1.id, s2.id]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forwards_sheet_starting_position(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory()
+
+        from backend.app.api.routes import labels as labels_module
+
+        captured = {}
+        original = labels_module.render_labels
+
+        def _capture(template, data_list, **kwargs):
+            captured["starting_position"] = kwargs["starting_position"]
+            return original(template, data_list, **kwargs)
+
+        with patch.object(labels_module, "render_labels", side_effect=_capture):
+            resp = await async_client.post(
+                "/api/v1/inventory/labels",
+                json={"spool_ids": [spool.id], "template": "avery_5160", "starting_position": 8},
+            )
+
+        assert resp.status_code == 200
+        assert captured["starting_position"] == 8
+
 
 # ── /spoolman/labels (Spoolman-backed) ───────────────────────────────────────
 
@@ -237,6 +260,24 @@ class TestSpoolmanLabels:
 
 
 class TestValidation:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        ("template", "starting_position"),
+        (("avery_5160", 0), ("avery_5160", 31), ("avery_l7160", 22), ("box_62x29", 2)),
+    )
+    async def test_invalid_starting_position_rejected(
+        self,
+        async_client: AsyncClient,
+        template: str,
+        starting_position: int,
+    ):
+        resp = await async_client.post(
+            "/api/v1/inventory/labels",
+            json={"spool_ids": [1], "template": template, "starting_position": starting_position},
+        )
+        assert resp.status_code == 422
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_request_body_size_capped(self, async_client: AsyncClient):

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

@@ -2,7 +2,11 @@
 
 from __future__ import annotations
 
+from unittest.mock import patch
+
 import pytest
+from reportlab.lib.pagesizes import letter
+from reportlab.lib.units import mm
 
 from backend.app.services.label_renderer import LabelData, render_labels
 
@@ -96,6 +100,37 @@ def test_sheet_template_paginates_when_count_exceeds_one_sheet():
     assert len(two) > len(one)
 
 
+def test_sheet_starting_position_offsets_first_label():
+    with patch("backend.app.services.label_renderer._draw_label") as draw_label:
+        render_labels("avery_5160", [_sample(1)], starting_position=8)
+
+    first_call = draw_label.call_args_list[0].args
+    assert first_call[1] == pytest.approx(4.76 * mm + 66.675 * mm + 3.175 * mm)
+    assert first_call[2] == pytest.approx(letter[1] - 12.7 * mm - 3 * 25.4 * mm)
+
+
+def test_sheet_starting_position_resets_on_second_page():
+    data = [_sample(i) for i in range(1, 25)]
+    with patch("backend.app.services.label_renderer._draw_label") as draw_label:
+        render_labels("avery_5160", data, starting_position=8)
+
+    last_first_page_call = draw_label.call_args_list[22].args
+    first_second_page_call = draw_label.call_args_list[23].args
+    assert last_first_page_call[1] == pytest.approx(4.76 * mm + 2 * (66.675 * mm + 3.175 * mm))
+    assert last_first_page_call[2] == pytest.approx(letter[1] - 12.7 * mm - 10 * 25.4 * mm)
+    assert first_second_page_call[1] == pytest.approx(4.76 * mm)
+    assert first_second_page_call[2] == pytest.approx(letter[1] - 12.7 * mm - 25.4 * mm)
+
+
+@pytest.mark.parametrize(
+    ("template", "starting_position"),
+    (("avery_5160", 0), ("avery_5160", 31), ("avery_l7160", 22), ("box_62x29", 2)),
+)
+def test_invalid_starting_position_raises(template, starting_position):
+    with pytest.raises(ValueError, match="Starting position"):
+        render_labels(template, [_sample()], starting_position=starting_position)
+
+
 def test_qr_payload_is_present_in_pdf_stream():
     """The QR encodes the deeplink URL via embedded PNG; we can at least
     sanity-check that the PDF contains an image stream when a deeplink is set
@@ -172,6 +207,17 @@ def _render_uncompressed(template, data, monochrome=False):
     return buf.getvalue()
 
 
+def test_transparent_swatch_does_not_apply_its_alpha_to_qr():
+    pdf = _render_uncompressed(
+        "box_62x29",
+        [_sample(rgba="FF000000", deeplink_url="https://example.test/inventory?spool=1")],
+    )
+
+    alpha_start = pdf.index(b"/gRLs0 gs")
+    qr_draw = pdf.index(b" Do", alpha_start)
+    assert b"Q" in pdf[alpha_start:qr_draw]
+
+
 def test_ams_template_actually_renders_text():
     """Regression: the first cut of the AMS-holder layout produced labels with
     only swatch + QR and no text at all because the side-by-side layout left

+ 83 - 1
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -1,5 +1,5 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { screen, waitFor, fireEvent, within } from '@testing-library/react';
 import { render } from '../utils';
 import { LabelTemplatePickerModal } from '../../components/LabelTemplatePickerModal';
 import { api } from '../../api/client';
@@ -67,6 +67,22 @@ describe('LabelTemplatePickerModal', () => {
     expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
   });
 
+  it('keeps the panel from becoming a programmatically scrollable clipping container', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1, 2, 3, 4]}
+        spoolmanMode={false}
+      />,
+    );
+
+    const panel = screen.getByTestId('label-template-picker-panel');
+    expect(panel).toHaveClass('overflow-clip');
+    expect(panel).not.toHaveClass('overflow-hidden');
+  });
+
   it('shows the live selected count in the header', () => {
     render(
       <LabelTemplatePickerModal
@@ -208,6 +224,7 @@ describe('LabelTemplatePickerModal', () => {
         spool_ids: [1, 3],
         template: 'box_62x29',
         monochrome: false,
+        starting_position: 1,
       });
     });
     await waitFor(() => expect(onClose).toHaveBeenCalled());
@@ -234,6 +251,7 @@ describe('LabelTemplatePickerModal', () => {
         spool_ids: [1],
         template: 'ams_holder_75x55',
         monochrome: false,
+        starting_position: 1,
       });
     });
     expect(api.printSpoolLabels).not.toHaveBeenCalled();
@@ -364,6 +382,7 @@ describe('LabelTemplatePickerModal', () => {
         spool_ids: [1, 4, 2, 3],
         template: 'box_62x29',
         monochrome: false,
+        starting_position: 1,
       });
     });
   });
@@ -389,6 +408,7 @@ describe('LabelTemplatePickerModal', () => {
         spool_ids: [1, 2, 3, 4],
         template: 'box_40x30',
         monochrome: false,
+        starting_position: 1,
       });
     });
   });
@@ -413,7 +433,69 @@ describe('LabelTemplatePickerModal', () => {
         spool_ids: [1],
         template: 'box_40x30',
         monochrome: true,
+        starting_position: 1,
       });
     });
   });
+
+  it('sends the selected starting position for an Avery sheet', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+
+    fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '8' } });
+    expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(/Positions 1 through 7/i);
+    fireEvent.click(screen.getByTestId('print-labels-avery_5160'));
+
+    await waitFor(() => {
+      expect(api.printSpoolLabels).toHaveBeenCalledWith({
+        spool_ids: [1],
+        template: 'avery_5160',
+        monochrome: false,
+        starting_position: 8,
+      });
+    });
+  });
+
+  it('renders a single skipped position without treating the value as a pluralization key', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+
+    fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '2' } });
+    expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(
+      'Positions 1 through 1 will be left blank on the first sheet.',
+    );
+  });
+
+  it('explains each Avery template capacity when disabling it', () => {
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+
+    fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '25' } });
+    const l7160Button = screen.getByTestId('print-labels-avery_l7160');
+    expect(l7160Button).toBeDisabled();
+    expect(within(l7160Button).getByText(/between 1 and 21/)).toBeInTheDocument();
+    expect(screen.getByTestId('print-labels-avery_5160')).toBeEnabled();
+  });
 });

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

@@ -3465,6 +3465,13 @@ export type SpoolLabelTemplate =
   | 'avery_5160'
   | 'avery_l7160';
 
+export interface PrintSpoolLabelsRequest {
+  spool_ids: number[];
+  template: SpoolLabelTemplate;
+  monochrome: boolean;
+  starting_position: number;
+}
+
 export interface InventorySpool {
   id: number;
   material: string;
@@ -6360,7 +6367,7 @@ export const api = {
   // ── Spool label printing (#809) ──────────────────────────────────────────
   // Both endpoints return application/pdf. Frontend opens the resulting Blob
   // in a new tab so the user can print or save from the browser's PDF viewer.
-  printSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate; monochrome?: boolean }): Promise<Blob> => {
+  printSpoolLabels: async (data: PrintSpoolLabelsRequest): Promise<Blob> => {
     const headers: Record<string, string> = { 'Content-Type': 'application/json' };
     if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
     const response = await fetch(`${API_BASE}/inventory/labels`, {
@@ -6374,7 +6381,7 @@ export const api = {
     }
     return response.blob();
   },
-  printSpoolmanSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate; monochrome?: boolean }): Promise<Blob> => {
+  printSpoolmanSpoolLabels: async (data: PrintSpoolLabelsRequest): Promise<Blob> => {
     const headers: Record<string, string> = { 'Content-Type': 'application/json' };
     if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
     const response = await fetch(`${API_BASE}/spoolman/labels`, {

+ 105 - 6
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -71,6 +71,13 @@ const TEMPLATE_OPTIONS: TemplateOption[] = [
   },
 ];
 
+const SHEET_CAPACITIES: Partial<Record<SpoolLabelTemplate, number>> = {
+  avery_l7160: 21,
+  avery_5160: 30,
+};
+
+const MAX_SHEET_CAPACITY = Math.max(...Object.values(SHEET_CAPACITIES));
+
 function openBlobInNewTab(blob: Blob): void {
   const url = window.URL.createObjectURL(blob);
   // Do NOT pass `noopener,noreferrer`: per the WindowFeatures spec, `noopener`
@@ -175,6 +182,7 @@ export function LabelTemplatePickerModal({
   const [materialFilter, setMaterialFilter] = useState<string>('');
   const [sortMode, setSortMode] = useState<SortMode>('id');
   const [monochrome, setMonochrome] = useState(false);
+  const [startingPositionInput, setStartingPositionInput] = useState('1');
 
   // 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
@@ -187,6 +195,7 @@ export function LabelTemplatePickerModal({
       setMaterialFilter('');
       setSortMode('id');
       setMonochrome(false);
+      setStartingPositionInput('1');
       setPending(null);
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -236,6 +245,11 @@ export function LabelTemplatePickerModal({
 
   const selectedCount = selectedIds.size;
   const noSelection = selectedCount === 0;
+  const startingPosition = Number(startingPositionInput);
+  const startingPositionIsValid =
+    Number.isInteger(startingPosition) &&
+    startingPosition >= 1 &&
+    startingPosition <= MAX_SHEET_CAPACITY;
 
   function toggleOne(id: number) {
     setSelectedIds((prev) => {
@@ -268,6 +282,21 @@ export function LabelTemplatePickerModal({
 
   async function handlePick(template: SpoolLabelTemplate) {
     if (noSelection || pending) return;
+    const sheetCapacity = SHEET_CAPACITIES[template];
+    if (
+      sheetCapacity !== undefined &&
+      (!startingPositionIsValid || startingPosition > sheetCapacity)
+    ) {
+      showToast(
+        t(
+          'inventory.labels.startingPositionRangeError',
+          'Starting position must be between 1 and {{capacity}} for this sheet.',
+          { capacity: sheetCapacity },
+        ),
+        'error',
+      );
+      return;
+    }
     // Order matters: the backend (labels.py) prints labels in the same order
     // we send IDs. Use the sorted list so a "by colour" sort flows through to
     // the PDF instead of being clobbered by an ascending-ID re-sort.
@@ -275,8 +304,18 @@ export function LabelTemplatePickerModal({
     setPending(template);
     try {
       const blob = spoolmanMode
-        ? await api.printSpoolmanSpoolLabels({ spool_ids: ids, template, monochrome })
-        : await api.printSpoolLabels({ spool_ids: ids, template, monochrome });
+        ? await api.printSpoolmanSpoolLabels({
+            spool_ids: ids,
+            template,
+            monochrome,
+            starting_position: sheetCapacity === undefined ? 1 : startingPosition,
+          })
+        : await api.printSpoolLabels({
+            spool_ids: ids,
+            template,
+            monochrome,
+            starting_position: sheetCapacity === undefined ? 1 : startingPosition,
+          });
       openBlobInNewTab(blob);
       onClose();
     } catch (err) {
@@ -297,7 +336,10 @@ export function LabelTemplatePickerModal({
         onClick={onClose}
       />
 
-      <div className="relative w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col my-auto">
+      <div
+        data-testid="label-template-picker-panel"
+        className="relative w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-clip flex flex-col my-auto"
+      >
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div className="flex items-center gap-2">
@@ -467,7 +509,7 @@ export function LabelTemplatePickerModal({
         </div>
 
         {/* Print options */}
-        <div className="px-4 pt-2 pb-1 border-t border-bambu-dark-tertiary">
+        <div className="px-4 pt-2 pb-1 border-t border-bambu-dark-tertiary space-y-2">
           <label className="inline-flex items-center gap-2 cursor-pointer select-none">
             {monochrome ? (
               <CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
@@ -487,6 +529,52 @@ export function LabelTemplatePickerModal({
               {t('inventory.labels.monochromeHint', 'Drops the colour swatch and widens the text')}
             </span>
           </label>
+          <div className="flex items-start gap-3">
+            <label
+              htmlFor="label-starting-position"
+              className="text-sm text-white whitespace-nowrap pt-1.5"
+            >
+              {t('inventory.labels.startingPosition', 'Starting label position')}
+            </label>
+            <input
+              id="label-starting-position"
+              data-testid="label-starting-position"
+              type="number"
+              min={1}
+              max={MAX_SHEET_CAPACITY}
+              step={1}
+              value={startingPositionInput}
+              onChange={(event) => setStartingPositionInput(event.target.value)}
+              aria-describedby="label-starting-position-help"
+              className="w-20 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
+            />
+            <div id="label-starting-position-help" className="text-xs text-bambu-gray pt-1.5">
+              <div>
+                {t(
+                  'inventory.labels.startingPositionRange',
+                  'Sheet templates only: L7160 supports 1–21; 5160 supports 1–30.',
+                )}
+              </div>
+              <div
+                data-testid="label-starting-position-status"
+                className={startingPositionIsValid ? '' : 'text-red-400'}
+              >
+                {!startingPositionIsValid
+                  ? t(
+                      'inventory.labels.startingPositionInvalid',
+                      'Enter a whole number from 1 to {{capacity}}.',
+                      { capacity: MAX_SHEET_CAPACITY },
+                    )
+                  : startingPosition === 1
+                    ? t('inventory.labels.startingPositionFirst', 'Printing starts at position 1.')
+                    : t(
+                        'inventory.labels.startingPositionSkipped',
+                        'Positions 1 through {{lastPosition}} will be left blank on the first sheet.',
+                        { lastPosition: startingPosition - 1 },
+                      )}
+              </div>
+            </div>
+          </div>
         </div>
 
         {/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit
@@ -495,12 +583,23 @@ export function LabelTemplatePickerModal({
         <div className="px-3 pt-1 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
           {TEMPLATE_OPTIONS.map((opt) => {
             const isPending = pending === opt.value;
+            const sheetCapacity = SHEET_CAPACITIES[opt.value];
+            const startingPositionExceedsSheet =
+              sheetCapacity !== undefined &&
+              (!startingPositionIsValid || startingPosition > sheetCapacity);
             const label = t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel);
-            const hint = t(`inventory.labels.templates.${opt.i18nKey}.hint`, opt.fallbackHint);
+            const hint = startingPositionExceedsSheet
+              ? t(
+                  'inventory.labels.startingPositionRangeError',
+                  'Starting position must be between 1 and {{capacity}} for this sheet.',
+                  { capacity: sheetCapacity },
+                )
+              : t(`inventory.labels.templates.${opt.i18nKey}.hint`, opt.fallbackHint);
             return (
               <button
                 key={opt.value}
-                disabled={noSelection || pending !== null}
+                data-testid={`print-labels-${opt.value}`}
+                disabled={noSelection || pending !== null || startingPositionExceedsSheet}
                 onClick={() => handlePick(opt.value)}
                 title={`${label} — ${hint}`}
                 className="w-full text-left p-2.5 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-green hover:bg-bambu-green/10 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-bambu-dark-tertiary disabled:hover:bg-bambu-dark transition flex items-center gap-3"

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

@@ -4589,6 +4589,12 @@ export default {
       pickSpools: 'Wählen Sie, für welche Spulen Etiketten gedruckt werden sollen:',
       monochrome: 'Monochrom (Schwarz-Weiß-Drucker)',
       monochromeHint: 'Entfernt das Farbfeld und verbreitert den Text',
+      startingPosition: 'Startposition des Etiketts',
+      startingPositionRange: 'Nur Bogenvorlagen: L7160 unterstützt 1–21; 5160 unterstützt 1–30.',
+      startingPositionInvalid: 'Gib eine ganze Zahl zwischen 1 und {{capacity}} ein.',
+      startingPositionFirst: 'Der Druck beginnt bei Position 1.',
+      startingPositionSkipped: 'Die Positionen 1 bis {{lastPosition}} bleiben auf dem ersten Bogen leer.',
+      startingPositionRangeError: 'Die Startposition muss für diesen Bogen zwischen 1 und {{capacity}} liegen.',
       searchPlaceholder: 'Name, Marke oder #ID suchen',
       filterByMaterial: 'Material:',
       allMaterials: 'Alle',

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

@@ -4625,6 +4625,12 @@ export default {
       pickSpools: 'Pick which spools to print labels for:',
       monochrome: 'Monochrome (black & white printer)',
       monochromeHint: 'Drops the colour swatch and widens the text',
+      startingPosition: 'Starting label position',
+      startingPositionRange: 'Sheet templates only: L7160 supports 1–21; 5160 supports 1–30.',
+      startingPositionInvalid: 'Enter a whole number from 1 to {{capacity}}.',
+      startingPositionFirst: 'Printing starts at position 1.',
+      startingPositionSkipped: 'Positions 1 through {{lastPosition}} will be left blank on the first sheet.',
+      startingPositionRangeError: 'Starting position must be between 1 and {{capacity}} for this sheet.',
       searchPlaceholder: 'Search name, brand, or #ID',
       filterByMaterial: 'Material:',
       allMaterials: 'All',

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

@@ -4591,6 +4591,12 @@ export default {
       pickSpools: 'Elija para qué bobinas imprimir etiquetas:',
       monochrome: 'Monocromo (impresora en blanco y negro)',
       monochromeHint: 'Elimina la muestra de color y amplía el texto',
+      startingPosition: 'Posición inicial de la etiqueta',
+      startingPositionRange: 'Solo plantillas de hoja: L7160 admite 1–21; 5160 admite 1–30.',
+      startingPositionInvalid: 'Introduce un número entero del 1 al {{capacity}}.',
+      startingPositionFirst: 'La impresión comienza en la posición 1.',
+      startingPositionSkipped: 'Las posiciones 1 a {{lastPosition}} quedarán vacías en la primera hoja.',
+      startingPositionRangeError: 'La posición inicial debe estar entre 1 y {{capacity}} para esta hoja.',
       searchPlaceholder: 'Buscar nombre, marca o n.º de ID',
       filterByMaterial: 'Material:',
       allMaterials: 'Todos',

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

@@ -4578,6 +4578,12 @@ export default {
       pickSpools: 'Choisissez les bobines pour lesquelles imprimer des étiquettes :',
       monochrome: 'Monochrome (imprimante noir et blanc)',
       monochromeHint: 'Supprime la pastille de couleur et élargit le texte',
+      startingPosition: 'Position de départ de l’étiquette',
+      startingPositionRange: 'Modèles en feuille uniquement : L7160 accepte 1–21 ; 5160 accepte 1–30.',
+      startingPositionInvalid: 'Saisissez un nombre entier de 1 à {{capacity}}.',
+      startingPositionFirst: 'L’impression commence à la position 1.',
+      startingPositionSkipped: 'Les positions 1 à {{lastPosition}} resteront vides sur la première feuille.',
+      startingPositionRangeError: 'La position de départ doit être comprise entre 1 et {{capacity}} pour cette feuille.',
       searchPlaceholder: 'Rechercher par nom, marque ou #ID',
       filterByMaterial: 'Matériau :',
       allMaterials: 'Tout',

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

@@ -4577,6 +4577,12 @@ export default {
       pickSpools: 'Scegli per quali bobine stampare le etichette:',
       monochrome: 'Monocromatico (stampante in bianco e nero)',
       monochromeHint: 'Rimuove il campione di colore e allarga il testo',
+      startingPosition: 'Posizione iniziale dell’etichetta',
+      startingPositionRange: 'Solo modelli a foglio: L7160 supporta 1–21; 5160 supporta 1–30.',
+      startingPositionInvalid: 'Inserisci un numero intero da 1 a {{capacity}}.',
+      startingPositionFirst: 'La stampa inizia dalla posizione 1.',
+      startingPositionSkipped: 'Le posizioni da 1 a {{lastPosition}} resteranno vuote sul primo foglio.',
+      startingPositionRangeError: 'La posizione iniziale deve essere compresa tra 1 e {{capacity}} per questo foglio.',
       searchPlaceholder: 'Cerca per nome, marca o #ID',
       filterByMaterial: 'Materiale:',
       allMaterials: 'Tutto',

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

@@ -4589,6 +4589,12 @@ export default {
       pickSpools: 'ラベルを印刷するスプールを選択:',
       monochrome: 'モノクロ(白黒プリンター)',
       monochromeHint: 'カラースウォッチを削除してテキストを広げます',
+      startingPosition: 'ラベルの開始位置',
+      startingPositionRange: 'シートテンプレートのみ:L7160 は1~21、5160 は1~30に対応します。',
+      startingPositionInvalid: '1~{{capacity}}の整数を入力してください。',
+      startingPositionFirst: '位置1から印刷を開始します。',
+      startingPositionSkipped: '最初のシートでは位置1~{{lastPosition}}を空白にします。',
+      startingPositionRangeError: 'このシートの開始位置は1~{{capacity}}で指定してください。',
       searchPlaceholder: '名前、ブランド、#IDで検索',
       filterByMaterial: '素材:',
       allMaterials: 'すべて',

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

@@ -4376,6 +4376,12 @@ export default {
       pickSpools: '라벨을 인쇄할 스풀 선택:',
       monochrome: '흑백 (흑백 프린터)',
       monochromeHint: '색상 견본을 제거하고 텍스트를 넓힙니다',
+      startingPosition: '라벨 시작 위치',
+      startingPositionRange: '시트 템플릿만 해당: L7160은 1–21, 5160은 1–30을 지원합니다.',
+      startingPositionInvalid: '1에서 {{capacity}} 사이의 정수를 입력하세요.',
+      startingPositionFirst: '위치 1부터 인쇄를 시작합니다.',
+      startingPositionSkipped: '첫 번째 시트의 위치 1부터 {{lastPosition}}까지는 비워 둡니다.',
+      startingPositionRangeError: '이 시트의 시작 위치는 1에서 {{capacity}} 사이여야 합니다.',
       searchPlaceholder: '이름, 브랜드 또는 #ID 검색',
       filterByMaterial: '재료:',
       allMaterials: '전체',

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

@@ -4577,6 +4577,12 @@ export default {
       pickSpools: 'Escolha para quais bobinas imprimir etiquetas:',
       monochrome: 'Monocromático (impressora preto e branco)',
       monochromeHint: 'Remove a amostra de cor e amplia o texto',
+      startingPosition: 'Posição inicial da etiqueta',
+      startingPositionRange: 'Somente modelos em folha: L7160 aceita 1–21; 5160 aceita 1–30.',
+      startingPositionInvalid: 'Digite um número inteiro de 1 a {{capacity}}.',
+      startingPositionFirst: 'A impressão começa na posição 1.',
+      startingPositionSkipped: 'As posições de 1 a {{lastPosition}} ficarão vazias na primeira folha.',
+      startingPositionRangeError: 'A posição inicial deve estar entre 1 e {{capacity}} para esta folha.',
       searchPlaceholder: 'Buscar por nome, marca ou #ID',
       filterByMaterial: 'Material:',
       allMaterials: 'Tudo',

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

@@ -4366,6 +4366,12 @@ export default {
       pickSpools: "Выберите катушки для печати этикеток:",
       monochrome: "Монохромный режим (чёрно-белый принтер)",
       monochromeHint: "Убирает образец цвета и расширяет область текста",
+      startingPosition: "Начальная позиция этикетки",
+      startingPositionRange: "Только для листовых шаблонов: L7160 — 1–21; 5160 — 1–30.",
+      startingPositionInvalid: "Введите целое число от 1 до {{capacity}}.",
+      startingPositionFirst: "Печать начинается с позиции 1.",
+      startingPositionSkipped: "Позиции с 1 по {{lastPosition}} останутся пустыми на первом листе.",
+      startingPositionRangeError: "Начальная позиция для этого листа должна быть от 1 до {{capacity}}.",
       searchPlaceholder: "Поиск по названию, бренду или #ID",
       filterByMaterial: "Материал:",
       allMaterials: "Все",

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

@@ -4578,6 +4578,12 @@ export default {
       pickSpools: 'Hangi makaralar için etiket yazdırılacağını seçin:',
       monochrome: 'Tek renk (siyah beyaz yazıcı)',
       monochromeHint: 'Renk örneğini kaldırır ve metni genişletir',
+      startingPosition: 'Başlangıç etiket konumu',
+      startingPositionRange: 'Yalnızca sayfa şablonları: L7160 için 1–21; 5160 için 1–30.',
+      startingPositionInvalid: '1 ile {{capacity}} arasında bir tam sayı girin.',
+      startingPositionFirst: 'Yazdırma 1. konumdan başlar.',
+      startingPositionSkipped: 'İlk sayfada 1 ile {{lastPosition}} arasındaki konumlar boş bırakılır.',
+      startingPositionRangeError: 'Bu sayfanın başlangıç konumu 1 ile {{capacity}} arasında olmalıdır.',
       searchPlaceholder: 'Ad, marka veya #ID ara',
       filterByMaterial: 'Malzeme:',
       allMaterials: 'Tümü',

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

@@ -4622,6 +4622,12 @@ export default {
       pickSpools: "Виберіть котушки для друку етикеток:",
       monochrome: "Монохромний (чорно-білий принтер)",
       monochromeHint: "Прибирає зразок кольору та розширює область тексту",
+      startingPosition: "Початкова позиція етикетки",
+      startingPositionRange: "Лише для аркушів: L7160 підтримує 1–21; 5160 підтримує 1–30.",
+      startingPositionInvalid: "Введіть ціле число від 1 до {{capacity}}.",
+      startingPositionFirst: "Друк починається з позиції 1.",
+      startingPositionSkipped: "Позиції від 1 до {{lastPosition}} залишаться порожніми на першому аркуші.",
+      startingPositionRangeError: "Початкова позиція для цього аркуша має бути від 1 до {{capacity}}.",
       searchPlaceholder: "Шукайте назву, бренд або #ID",
       filterByMaterial: "матеріал:",
       allMaterials: "все",

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

@@ -4577,6 +4577,12 @@ export default {
       pickSpools: '选择要打印标签的线材:',
       monochrome: '单色(黑白打印机)',
       monochromeHint: '移除颜色色块并加宽文本',
+      startingPosition: '标签起始位置',
+      startingPositionRange: '仅适用于标签纸模板:L7160 支持 1–21;5160 支持 1–30。',
+      startingPositionInvalid: '请输入 1 到 {{capacity}} 之间的整数。',
+      startingPositionFirst: '从位置 1 开始打印。',
+      startingPositionSkipped: '第一张标签纸的位置 1 到 {{lastPosition}} 将留空。',
+      startingPositionRangeError: '此标签纸的起始位置必须在 1 到 {{capacity}} 之间。',
       searchPlaceholder: '按名称、品牌或 #ID 搜索',
       filterByMaterial: '材料:',
       allMaterials: '全部',

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

@@ -4577,6 +4577,12 @@ export default {
       pickSpools: '選擇要列印標籤的線材:',
       monochrome: '單色(黑白印表機)',
       monochromeHint: '移除顏色色塊並加寬文字',
+      startingPosition: '標籤起始位置',
+      startingPositionRange: '僅適用於標籤紙範本:L7160 支援 1–21;5160 支援 1–30。',
+      startingPositionInvalid: '請輸入 1 到 {{capacity}} 之間的整數。',
+      startingPositionFirst: '從位置 1 開始列印。',
+      startingPositionSkipped: '第一張標籤紙的位置 1 到 {{lastPosition}} 將留空。',
+      startingPositionRangeError: '此標籤紙的起始位置必須在 1 到 {{capacity}} 之間。',
       searchPlaceholder: '按名稱、品牌或 #ID 搜尋',
       filterByMaterial: '材料:',
       allMaterials: '全部',