浏览代码

feat(labels): scannable QR on 203 dpi thermal printers + monochrome mode (#1870)

    The 40x30 mm box label rendered its QR too densely for low-res thermal
    printers — the modules bled together and wouldn't scan. Two causes: the QR
    was 20% of inner width (~7.5 mm on the narrowest template, half of the
    others) and used ERROR_CORRECT_M. Fix adaptively so all templates benefit:
    give the roomy-layout QR a 12 mm minimum size (box_40x30 -> 12 mm, ~3.5
    dots/module at 203 dpi) and switch label QRs to ERROR_CORRECT_L (same
    payload, chunkier modules; a label needs no M-level recovery). Keep the
    quiet-zone border at 2 — the size+L gains suffice without risking scans.

    Also add a Monochrome (black & white printer) option to the label dialog:
    drops the colour swatch (a useless grey block on B&W) and widens the text;
    the hex-code line still carries the colour. Threaded through the renderer,
    route, API client, and modal, with translations in all 11 locales.
maziggy 2 月之前
父节点
当前提交
5cf429f696

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

@@ -60,6 +60,9 @@ class LabelRequest(BaseModel):
         "avery_5160",
         "avery_5160",
         "avery_l7160",
         "avery_l7160",
     ]
     ]
+    # 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
 
 
 
 
 def _split_extra_colors(raw: str | None) -> list[str] | None:
 def _split_extra_colors(raw: str | None) -> list[str] | None:
@@ -170,7 +173,7 @@ async def render_local_inventory_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
     data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
 
 
-    pdf = render_labels(body.template, data_list)
+    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
     filename = f"bambuddy-labels-{body.template}.pdf"
     filename = f"bambuddy-labels-{body.template}.pdf"
     return _stream_pdf(pdf, filename)
     return _stream_pdf(pdf, filename)
 
 
@@ -214,6 +217,6 @@ async def render_spoolman_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     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]
     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)
+    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
     filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
     filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
     return _stream_pdf(pdf, filename)
     return _stream_pdf(pdf, filename)

+ 58 - 22
backend/app/services/label_renderer.py

@@ -130,7 +130,13 @@ def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
         return b""
         return b""
     qr = qrcode.QRCode(
     qr = qrcode.QRCode(
         version=None,
         version=None,
-        error_correction=qrcode.constants.ERROR_CORRECT_M,
+        # ERROR_CORRECT_L (7% recovery) rather than M (15%): a label QR only
+        # needs to survive being scanned off clean stock, not physical damage,
+        # and L encodes the same payload in a lower version (fewer, chunkier
+        # modules). That extra module size is what makes the code printable on
+        # low-resolution 203 dpi thermal printers, where M-level density bled
+        # the modules together on small labels (#1870).
+        error_correction=qrcode.constants.ERROR_CORRECT_L,
         box_size=box_size,
         box_size=box_size,
         border=border,
         border=border,
     )
     )
@@ -168,6 +174,19 @@ def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, da
     c.rect(x, y, w, h, stroke=1, fill=0)
     c.rect(x, y, w, h, stroke=1, fill=0)
 
 
 
 
+def _roomy_qr_size(inner_w: float, inner_h: float) -> float:
+    """QR edge length (points) for the roomy layout.
+
+    Historically a flat 20% of inner width, which on the narrowest label
+    (box_40x30, ~37.6 mm inner) rendered a ~7.5 mm QR — at 203 dpi each module
+    fell below ~2 dots and the code bled into itself on thermal printers
+    (#1870). A 12 mm floor keeps small labels scannable; the code is still
+    capped by the inner height, an 18 mm absolute max, and ~45% of inner width
+    so it can't crowd out the text column on an ultra-narrow label.
+    """
+    return min(max(inner_w * 0.20, 12 * mm), inner_h, 18 * mm, inner_w * 0.45)
+
+
 def _draw_qr(c: rl_canvas.Canvas, x: float, y: float, size: float, payload: str) -> None:
 def _draw_qr(c: rl_canvas.Canvas, x: float, y: float, size: float, payload: str) -> None:
     """Embed a square QR at (x, y) with edge length ``size`` (in points)."""
     """Embed a square QR at (x, y) with edge length ``size`` (in points)."""
     png = _qr_png_bytes(payload)
     png = _qr_png_bytes(payload)
@@ -189,7 +208,9 @@ def _truncate_to_width(c: rl_canvas.Canvas, text: str, font: str, size: float, m
     return text + ell if text else ell
     return text + ell if text else ell
 
 
 
 
-def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
+def _draw_label(
+    c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData, monochrome: bool = False
+) -> None:
     """Render one label inside the box (x, y, w, h). Origin is bottom-left.
     """Render one label inside the box (x, y, w, h). Origin is bottom-left.
 
 
     Two layouts, picked by available height:
     Two layouts, picked by available height:
@@ -219,9 +240,9 @@ def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, dat
     is_tight = h < 20 * mm
     is_tight = h < 20 * mm
 
 
     if is_tight:
     if is_tight:
-        _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
+        _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
     else:
     else:
-        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
+        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
 
 
 
 
 def _draw_label_tight(
 def _draw_label_tight(
@@ -236,11 +257,17 @@ def _draw_label_tight(
     inner_h: float,
     inner_h: float,
     pad: float,
     pad: float,
     data: LabelData,
     data: LabelData,
+    monochrome: bool = False,
 ) -> None:
 ) -> None:
     """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR."""
     """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR."""
-    swatch_w = min(inner_h, inner_w * 0.35)
-    swatch_y = inner_y + (inner_h - swatch_w) / 2
-    _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
+    # Monochrome: drop the colour swatch (see _draw_label_roomy) and give the
+    # width to the text column (#1870).
+    if monochrome:
+        swatch_w = 0.0
+    else:
+        swatch_w = min(inner_h, inner_w * 0.35)
+        swatch_y = inner_y + (inner_h - swatch_w) / 2
+        _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
 
 
     text_x = inner_x + swatch_w + pad
     text_x = inner_x + swatch_w + pad
     text_w = inner_w - swatch_w - pad
     text_w = inner_w - swatch_w - pad
@@ -296,17 +323,22 @@ def _draw_label_roomy(
     inner_h: float,
     inner_h: float,
     pad: float,
     pad: float,
     data: LabelData,
     data: LabelData,
+    monochrome: bool = False,
 ) -> None:
 ) -> None:
     """Box-label / Avery layout. Swatch left, QR right, text middle."""
     """Box-label / Avery layout. Swatch left, QR right, text middle."""
     # Swatch: full inner height, ~18% of inner width but capped so we never
     # Swatch: full inner height, ~18% of inner width but capped so we never
-    # eat the text column on extreme aspect ratios.
-    swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
-    swatch_h = inner_h
-    _draw_swatch(c, inner_x, inner_y, swatch_w, swatch_h, data)
-
-    # QR: square, capped at the smaller of (a fraction of width, the inner
-    # height, or 18 mm — beyond that the QR is overkill for the print size).
-    qr_size = min(inner_w * 0.20, inner_h, 18 * mm)
+    # eat the text column on extreme aspect ratios. Omitted entirely in
+    # monochrome mode — on a B&W thermal printer a colour block prints as a
+    # muddy grey that conveys nothing, so we reclaim the space for text and
+    # rely on the hex-code line to carry the colour (#1870, requested by
+    # @Geoff-S). The hex code already renders below whenever rgba is set.
+    if monochrome:
+        swatch_w = 0.0
+    else:
+        swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
+        _draw_swatch(c, inner_x, inner_y, swatch_w, inner_h, data)
+
+    qr_size = _roomy_qr_size(inner_w, inner_h)
     qr_x = x + w - pad - qr_size
     qr_x = x + w - pad - qr_size
     qr_y = inner_y + (inner_h - qr_size) / 2
     qr_y = inner_y + (inner_h - qr_size) / 2
     _draw_qr(c, qr_x, qr_y, qr_size, data.deeplink_url)
     _draw_qr(c, qr_x, qr_y, qr_size, data.deeplink_url)
@@ -394,7 +426,7 @@ _SHEET_TEMPLATES: dict[str, tuple] = {
 }
 }
 
 
 
 
-def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
     w_mm, h_mm = _SINGLE_LABEL_SIZES_MM[template]
     w_mm, h_mm = _SINGLE_LABEL_SIZES_MM[template]
     page_w, page_h = w_mm * mm, h_mm * mm
     page_w, page_h = w_mm * mm, h_mm * mm
 
 
@@ -403,14 +435,14 @@ def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData])
     c.setTitle(f"Bambuddy spool labels ({template})")
     c.setTitle(f"Bambuddy spool labels ({template})")
 
 
     for data in data_list:
     for data in data_list:
-        _draw_label(c, 0, 0, page_w, page_h, data)
+        _draw_label(c, 0, 0, page_w, page_h, data, monochrome)
         c.showPage()
         c.showPage()
 
 
     c.save()
     c.save()
     return buf.getvalue()
     return buf.getvalue()
 
 
 
 
-def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
     page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
     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
     page_w, page_h = page_size
 
 
@@ -433,23 +465,27 @@ def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> byt
             col = idx % cols
             col = idx % cols
             x = left_margin + col * (label_w + col_gap)
             x = left_margin + col * (label_w + col_gap)
             y = page_h - top_margin - (row + 1) * label_h - row * row_gap
             y = page_h - top_margin - (row + 1) * label_h - row * row_gap
-            _draw_label(c, x, y, label_w, label_h, data)
+            _draw_label(c, x, y, label_w, label_h, data, monochrome)
         c.showPage()
         c.showPage()
 
 
     c.save()
     c.save()
     return buf.getvalue()
     return buf.getvalue()
 
 
 
 
-def render_labels(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def render_labels(template: TemplateName, data_list: list[LabelData], *, monochrome: bool = False) -> bytes:
     """Render ``data_list`` to a PDF using the named template. Returns bytes.
     """Render ``data_list`` to a PDF using the named template. Returns bytes.
 
 
     Empty ``data_list`` still produces a valid (empty) PDF — callers should
     Empty ``data_list`` still produces a valid (empty) PDF — callers should
     short-circuit beforehand if that's not desired.
     short-circuit beforehand if that's not desired.
+
+    ``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.
     """
     """
     if template in _SINGLE_LABEL_SIZES_MM:
     if template in _SINGLE_LABEL_SIZES_MM:
-        return _render_single_label_pdf(template, data_list)
+        return _render_single_label_pdf(template, data_list, monochrome)
     if template in _SHEET_TEMPLATES:
     if template in _SHEET_TEMPLATES:
-        return _render_sheet_pdf(template, data_list)
+        return _render_sheet_pdf(template, data_list, monochrome)
     raise ValueError(f"Unknown label template: {template!r}")
     raise ValueError(f"Unknown label template: {template!r}")
 
 
 
 

+ 2 - 2
backend/tests/integration/test_labels.py

@@ -129,9 +129,9 @@ class TestLocalInventoryLabels:
 
 
         original = labels_module.render_labels
         original = labels_module.render_labels
 
 
-        def _capture(template, data_list):
+        def _capture(template, data_list, **kwargs):
             captured["ids"] = [d.spool_id for d in data_list]
             captured["ids"] = [d.spool_id for d in data_list]
-            return original(template, data_list)
+            return original(template, data_list, **kwargs)
 
 
         with patch.object(labels_module, "render_labels", side_effect=_capture):
         with patch.object(labels_module, "render_labels", side_effect=_capture):
             resp = await async_client.post(
             resp = await async_client.post(

+ 70 - 2
backend/tests/unit/services/test_label_renderer.py

@@ -112,7 +112,7 @@ def test_qr_payload_is_present_in_pdf_stream():
 # ── Regression tests for the two render bugs found in the first cut ──
 # ── Regression tests for the two render bugs found in the first cut ──
 
 
 
 
-def _render_uncompressed(template, data):
+def _render_uncompressed(template, data, monochrome=False):
     """Render with pageCompression=0 so the resulting PDF contains text as
     """Render with pageCompression=0 so the resulting PDF contains text as
     ASCII bytes. Lets tests assert "X is on the label" by grepping the PDF.
     ASCII bytes. Lets tests assert "X is on the label" by grepping the PDF.
 
 
@@ -140,7 +140,7 @@ def _render_uncompressed(template, data):
         buf = _io.BytesIO()
         buf = _io.BytesIO()
         c = _rl_canvas.Canvas(buf, pagesize=(page_w, page_h), pageCompression=0)
         c = _rl_canvas.Canvas(buf, pagesize=(page_w, page_h), pageCompression=0)
         for d in data:
         for d in data:
-            _draw_label(c, 0, 0, page_w, page_h, d)
+            _draw_label(c, 0, 0, page_w, page_h, d, monochrome)
             c.showPage()
             c.showPage()
         c.save()
         c.save()
         return buf.getvalue()
         return buf.getvalue()
@@ -303,3 +303,71 @@ def test_box_template_does_not_truncate_normal_brand_or_name():
     assert b"Shelf 3, slot B" in pdf, "box template must render the storage location"
     assert b"Shelf 3, slot B" in pdf, "box template must render the storage location"
     # Big spool ID at bottom.
     # Big spool ID at bottom.
     assert b"#7" in pdf or (b"7" in pdf and b"#" in pdf), "box template must render the spool ID"
     assert b"#7" in pdf or (b"7" in pdf and b"#" in pdf), "box template must render the spool ID"
+
+
+# ── #1870: low-res thermal-printer optimisations ──
+
+
+@pytest.mark.parametrize("template", ALL_TEMPLATES)
+def test_monochrome_renders_valid_pdf_for_each_template(template):
+    """Monochrome mode must render a valid PDF for every template (#1870)."""
+    pdf = render_labels(template, [_sample(7), _sample(8)], monochrome=True)
+    assert pdf.startswith(b"%PDF"), f"{template} monochrome did not produce a PDF header"
+
+
+def test_monochrome_omits_colour_swatch():
+    """Monochrome drops the colour swatch (useless grey block on a B&W printer)
+    while the default keeps it (#1870, requested by @Geoff-S)."""
+    from unittest.mock import patch
+
+    import backend.app.services.label_renderer as lr
+
+    with patch.object(lr, "_draw_swatch") as mock_swatch:
+        render_labels("box_40x30", [_sample(1)], monochrome=True)
+        assert mock_swatch.call_count == 0, "monochrome must not draw the colour swatch"
+
+    with patch.object(lr, "_draw_swatch") as mock_swatch:
+        render_labels("box_40x30", [_sample(1)], monochrome=False)
+        assert mock_swatch.call_count == 1, "colour mode must draw the swatch"
+
+
+def test_monochrome_still_renders_text_and_hex():
+    """Dropping the swatch must not lose the colour info — the hex code line and
+    the text fields still render (the hex is how colour is conveyed in B&W)."""
+    data = [
+        LabelData(
+            spool_id=42,
+            name="Polymaker Ivory",
+            material="PLA",
+            brand="Polymaker",
+            subtype="Matte",
+            rgba="F5E6D3FF",
+            deeplink_url="https://example.test/inventory?spool=42",
+        )
+    ]
+    pdf = _render_uncompressed("box_40x30", data, monochrome=True)
+    assert b"Polymaker" in pdf, "monochrome label must still render the brand"
+    assert b"#F5E6D3" in pdf, "monochrome label must still render the hex colour code"
+    assert b"#42" in pdf or (b"42" in pdf and b"#" in pdf), "monochrome label must render the spool ID"
+
+
+def test_roomy_qr_size_has_floor_for_narrow_labels():
+    """#1870 regression: box_40x30's QR must not shrink below a scannable size.
+    The pre-fix ``inner_w * 0.20`` gave ~7.5 mm on that label; the floor keeps
+    it at 12 mm so each module clears ~3 dots on a 203 dpi thermal head.
+    """
+    from reportlab.lib.units import mm
+
+    from backend.app.services.label_renderer import _roomy_qr_size
+
+    pad = 1.2 * mm
+    # box_40x30 inner dimensions.
+    inner_w = 40 * mm - 2 * pad
+    inner_h = 30 * mm - 2 * pad
+    assert _roomy_qr_size(inner_w, inner_h) >= 12 * mm - 0.01
+
+    # Larger templates are unaffected (already above the floor) and still capped.
+    inner_w_big = 75 * mm - 2 * pad
+    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

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

@@ -207,6 +207,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 3],
         spool_ids: [1, 3],
         template: 'box_62x29',
         template: 'box_62x29',
+        monochrome: false,
       });
       });
     });
     });
     await waitFor(() => expect(onClose).toHaveBeenCalled());
     await waitFor(() => expect(onClose).toHaveBeenCalled());
@@ -232,6 +233,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
       expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1],
         spool_ids: [1],
         template: 'ams_holder_75x55',
         template: 'ams_holder_75x55',
+        monochrome: false,
       });
       });
     });
     });
     expect(api.printSpoolLabels).not.toHaveBeenCalled();
     expect(api.printSpoolLabels).not.toHaveBeenCalled();
@@ -361,6 +363,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 4, 2, 3],
         spool_ids: [1, 4, 2, 3],
         template: 'box_62x29',
         template: 'box_62x29',
+        monochrome: false,
       });
       });
     });
     });
   });
   });
@@ -385,6 +388,31 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 2, 3, 4],
         spool_ids: [1, 2, 3, 4],
         template: 'box_40x30',
         template: 'box_40x30',
+        monochrome: false,
+      });
+    });
+  });
+
+  it('sends monochrome:true when the black & white checkbox is ticked (#1870)', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+
+    fireEvent.click(screen.getByText(/black & white printer/i));
+    fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
+
+    await waitFor(() => {
+      expect(api.printSpoolLabels).toHaveBeenCalledWith({
+        spool_ids: [1],
+        template: 'box_40x30',
+        monochrome: true,
       });
       });
     });
     });
   });
   });

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

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

+ 28 - 3
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -174,6 +174,7 @@ export function LabelTemplatePickerModal({
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
   const [materialFilter, setMaterialFilter] = useState<string>('');
   const [materialFilter, setMaterialFilter] = useState<string>('');
   const [sortMode, setSortMode] = useState<SortMode>('id');
   const [sortMode, setSortMode] = useState<SortMode>('id');
+  const [monochrome, setMonochrome] = useState(false);
 
 
   // Sync from caller and reset transient state on open. Intentionally not
   // 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
   // reactive to props while open — once the user starts editing we don't want
@@ -185,6 +186,7 @@ export function LabelTemplatePickerModal({
       setSearch('');
       setSearch('');
       setMaterialFilter('');
       setMaterialFilter('');
       setSortMode('id');
       setSortMode('id');
+      setMonochrome(false);
       setPending(null);
       setPending(null);
     }
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -273,8 +275,8 @@ export function LabelTemplatePickerModal({
     setPending(template);
     setPending(template);
     try {
     try {
       const blob = spoolmanMode
       const blob = spoolmanMode
-        ? await api.printSpoolmanSpoolLabels({ spool_ids: ids, template })
-        : await api.printSpoolLabels({ spool_ids: ids, template });
+        ? await api.printSpoolmanSpoolLabels({ spool_ids: ids, template, monochrome })
+        : await api.printSpoolLabels({ spool_ids: ids, template, monochrome });
       openBlobInNewTab(blob);
       openBlobInNewTab(blob);
       onClose();
       onClose();
     } catch (err) {
     } catch (err) {
@@ -464,10 +466,33 @@ export function LabelTemplatePickerModal({
           )}
           )}
         </div>
         </div>
 
 
+        {/* Print options */}
+        <div className="px-4 pt-2 pb-1 border-t border-bambu-dark-tertiary">
+          <label className="inline-flex items-center gap-2 cursor-pointer select-none">
+            {monochrome ? (
+              <CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
+            ) : (
+              <Square className="w-4 h-4 text-bambu-gray shrink-0" />
+            )}
+            <input
+              type="checkbox"
+              checked={monochrome}
+              onChange={(e) => setMonochrome(e.target.checked)}
+              className="sr-only"
+            />
+            <span className="text-sm text-white">
+              {t('inventory.labels.monochrome', 'Monochrome (black & white printer)')}
+            </span>
+            <span className="text-xs text-bambu-gray">
+              {t('inventory.labels.monochromeHint', 'Drops the colour swatch and widens the text')}
+            </span>
+          </label>
+        </div>
+
         {/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit
         {/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit
             inside max-h-[90vh] even when browser chrome eats into the viewport
             inside max-h-[90vh] even when browser chrome eats into the viewport
             (#1230). Stacked single column on mobile widths. */}
             (#1230). Stacked single column on mobile widths. */}
-        <div className="px-3 pt-2 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2 border-t border-bambu-dark-tertiary">
+        <div className="px-3 pt-1 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
           {TEMPLATE_OPTIONS.map((opt) => {
           {TEMPLATE_OPTIONS.map((opt) => {
             const isPending = pending === opt.value;
             const isPending = pending === opt.value;
             const label = t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel);
             const label = t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel);

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

@@ -4191,6 +4191,8 @@ export default {
       title: 'Spulen-Etiketten drucken',
       title: 'Spulen-Etiketten drucken',
       selectedCount: '{{count}} ausgewählt',
       selectedCount: '{{count}} ausgewählt',
       pickSpools: 'Wählen Sie, für welche Spulen Etiketten gedruckt werden sollen:',
       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',
       searchPlaceholder: 'Name, Marke oder #ID suchen',
       searchPlaceholder: 'Name, Marke oder #ID suchen',
       filterByMaterial: 'Material:',
       filterByMaterial: 'Material:',
       allMaterials: 'Alle',
       allMaterials: 'Alle',

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

@@ -4225,6 +4225,8 @@ export default {
       title: 'Print spool labels',
       title: 'Print spool labels',
       selectedCount: '{{count}} selected',
       selectedCount: '{{count}} selected',
       pickSpools: 'Pick which spools to print labels for:',
       pickSpools: 'Pick which spools to print labels for:',
+      monochrome: 'Monochrome (black & white printer)',
+      monochromeHint: 'Drops the colour swatch and widens the text',
       searchPlaceholder: 'Search name, brand, or #ID',
       searchPlaceholder: 'Search name, brand, or #ID',
       filterByMaterial: 'Material:',
       filterByMaterial: 'Material:',
       allMaterials: 'All',
       allMaterials: 'All',

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

@@ -4194,6 +4194,8 @@ export default {
       title: 'Imprimir etiquetas de bobinas',
       title: 'Imprimir etiquetas de bobinas',
       selectedCount: '{{count}} seleccionadas',
       selectedCount: '{{count}} seleccionadas',
       pickSpools: 'Elija para qué bobinas imprimir etiquetas:',
       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',
       searchPlaceholder: 'Buscar nombre, marca o n.º de ID',
       searchPlaceholder: 'Buscar nombre, marca o n.º de ID',
       filterByMaterial: 'Material:',
       filterByMaterial: 'Material:',
       allMaterials: 'Todos',
       allMaterials: 'Todos',

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

@@ -4180,6 +4180,8 @@ export default {
       title: 'Imprimer étiquettes de bobines',
       title: 'Imprimer étiquettes de bobines',
       selectedCount: '{{count}} sélectionné',
       selectedCount: '{{count}} sélectionné',
       pickSpools: 'Choisissez les bobines pour lesquelles imprimer des étiquettes :',
       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',
       searchPlaceholder: 'Rechercher par nom, marque ou #ID',
       searchPlaceholder: 'Rechercher par nom, marque ou #ID',
       filterByMaterial: 'Matériau :',
       filterByMaterial: 'Matériau :',
       allMaterials: 'Tout',
       allMaterials: 'Tout',

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

@@ -4179,6 +4179,8 @@ export default {
       title: 'Stampa etichette bobine',
       title: 'Stampa etichette bobine',
       selectedCount: '{{count}} selezionato',
       selectedCount: '{{count}} selezionato',
       pickSpools: 'Scegli per quali bobine stampare le etichette:',
       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',
       searchPlaceholder: 'Cerca per nome, marca o #ID',
       searchPlaceholder: 'Cerca per nome, marca o #ID',
       filterByMaterial: 'Materiale:',
       filterByMaterial: 'Materiale:',
       allMaterials: 'Tutto',
       allMaterials: 'Tutto',

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

@@ -4191,6 +4191,8 @@ export default {
       title: 'スプールラベルを印刷',
       title: 'スプールラベルを印刷',
       selectedCount: '{{count}}件選択中',
       selectedCount: '{{count}}件選択中',
       pickSpools: 'ラベルを印刷するスプールを選択:',
       pickSpools: 'ラベルを印刷するスプールを選択:',
+      monochrome: 'モノクロ(白黒プリンター)',
+      monochromeHint: 'カラースウォッチを削除してテキストを広げます',
       searchPlaceholder: '名前、ブランド、#IDで検索',
       searchPlaceholder: '名前、ブランド、#IDで検索',
       filterByMaterial: '素材:',
       filterByMaterial: '素材:',
       allMaterials: 'すべて',
       allMaterials: 'すべて',

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

@@ -3980,6 +3980,8 @@ export default {
       title: '스풀 라벨 인쇄',
       title: '스풀 라벨 인쇄',
       selectedCount: '{{count}}개 선택됨',
       selectedCount: '{{count}}개 선택됨',
       pickSpools: '라벨을 인쇄할 스풀 선택:',
       pickSpools: '라벨을 인쇄할 스풀 선택:',
+      monochrome: '흑백 (흑백 프린터)',
+      monochromeHint: '색상 견본을 제거하고 텍스트를 넓힙니다',
       searchPlaceholder: '이름, 브랜드 또는 #ID 검색',
       searchPlaceholder: '이름, 브랜드 또는 #ID 검색',
       filterByMaterial: '재료:',
       filterByMaterial: '재료:',
       allMaterials: '전체',
       allMaterials: '전체',

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

@@ -4179,6 +4179,8 @@ export default {
       title: 'Imprimir etiquetas de bobinas',
       title: 'Imprimir etiquetas de bobinas',
       selectedCount: '{{count}} selecionado',
       selectedCount: '{{count}} selecionado',
       pickSpools: 'Escolha para quais bobinas imprimir etiquetas:',
       pickSpools: 'Escolha para quais bobinas imprimir etiquetas:',
+      monochrome: 'Monocromático (impressora preto e branco)',
+      monochromeHint: 'Remove a amostra de cor e amplia o texto',
       searchPlaceholder: 'Buscar por nome, marca ou #ID',
       searchPlaceholder: 'Buscar por nome, marca ou #ID',
       filterByMaterial: 'Material:',
       filterByMaterial: 'Material:',
       allMaterials: 'Tudo',
       allMaterials: 'Tudo',

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

@@ -4181,6 +4181,8 @@ export default {
       title: 'Makara etiketleri yazdır',
       title: 'Makara etiketleri yazdır',
       selectedCount: '{{count}} seçildi',
       selectedCount: '{{count}} seçildi',
       pickSpools: 'Hangi makaralar için etiket yazdırılacağını seçin:',
       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',
       searchPlaceholder: 'Ad, marka veya #ID ara',
       searchPlaceholder: 'Ad, marka veya #ID ara',
       filterByMaterial: 'Malzeme:',
       filterByMaterial: 'Malzeme:',
       allMaterials: 'Tümü',
       allMaterials: 'Tümü',

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

@@ -4179,6 +4179,8 @@ export default {
       title: '打印线材标签',
       title: '打印线材标签',
       selectedCount: '已选 {{count}} 项',
       selectedCount: '已选 {{count}} 项',
       pickSpools: '选择要打印标签的线材:',
       pickSpools: '选择要打印标签的线材:',
+      monochrome: '单色(黑白打印机)',
+      monochromeHint: '移除颜色色块并加宽文本',
       searchPlaceholder: '按名称、品牌或 #ID 搜索',
       searchPlaceholder: '按名称、品牌或 #ID 搜索',
       filterByMaterial: '材料:',
       filterByMaterial: '材料:',
       allMaterials: '全部',
       allMaterials: '全部',

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

@@ -4179,6 +4179,8 @@ export default {
       title: '列印線材標籤',
       title: '列印線材標籤',
       selectedCount: '已選 {{count}} 項',
       selectedCount: '已選 {{count}} 項',
       pickSpools: '選擇要列印標籤的線材:',
       pickSpools: '選擇要列印標籤的線材:',
+      monochrome: '單色(黑白印表機)',
+      monochromeHint: '移除顏色色塊並加寬文字',
       searchPlaceholder: '按名稱、品牌或 #ID 搜尋',
       searchPlaceholder: '按名稱、品牌或 #ID 搜尋',
       filterByMaterial: '材料:',
       filterByMaterial: '材料:',
       allMaterials: '全部',
       allMaterials: '全部',

文件差异内容过多而无法显示
+ 0 - 0
static/assets/index-D_ORAGtA.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DmIKOnp9.js"></script>
+    <script type="module" crossorigin src="/assets/index-D_ORAGtA.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DvjR9OL3.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DvjR9OL3.css">
   </head>
   </head>
   <body>
   <body>

部分文件因为文件数量过多而无法显示