Ver Fonte

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 há 2 meses atrás
pai
commit
5cf429f696

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

@@ -60,6 +60,9 @@ class LabelRequest(BaseModel):
         "avery_5160",
         "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:
@@ -170,7 +173,7 @@ 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)
+    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
     filename = f"bambuddy-labels-{body.template}.pdf"
     return _stream_pdf(pdf, filename)
 
@@ -214,6 +217,6 @@ 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)
+    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
     filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
     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""
     qr = qrcode.QRCode(
         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,
         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)
 
 
+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:
     """Embed a square QR at (x, y) with edge length ``size`` (in points)."""
     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
 
 
-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.
 
     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
 
     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:
-        _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(
@@ -236,11 +257,17 @@ def _draw_label_tight(
     inner_h: float,
     pad: float,
     data: LabelData,
+    monochrome: bool = False,
 ) -> None:
     """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_w = inner_w - swatch_w - pad
@@ -296,17 +323,22 @@ def _draw_label_roomy(
     inner_h: float,
     pad: float,
     data: LabelData,
+    monochrome: bool = False,
 ) -> None:
     """Box-label / Avery layout. Swatch left, QR right, text middle."""
     # Swatch: full inner height, ~18% of inner width but capped so we never
-    # eat the text column on extreme aspect ratios.
-    swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
-    swatch_h = inner_h
-    _draw_swatch(c, inner_x, inner_y, swatch_w, swatch_h, data)
-
-    # QR: square, capped at the smaller of (a fraction of width, the inner
-    # height, or 18 mm — beyond that the QR is overkill for the print size).
-    qr_size = min(inner_w * 0.20, inner_h, 18 * mm)
+    # 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_y = inner_y + (inner_h - qr_size) / 2
     _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]
     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})")
 
     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.save()
     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_w, page_h = page_size
 
@@ -433,23 +465,27 @@ def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> byt
             col = idx % cols
             x = left_margin + col * (label_w + col_gap)
             y = page_h - top_margin - (row + 1) * label_h - row * row_gap
-            _draw_label(c, x, y, label_w, label_h, data)
+            _draw_label(c, x, y, label_w, label_h, data, monochrome)
         c.showPage()
 
     c.save()
     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.
 
     Empty ``data_list`` still produces a valid (empty) PDF — callers should
     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:
-        return _render_single_label_pdf(template, data_list)
+        return _render_single_label_pdf(template, data_list, monochrome)
     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}")
 
 

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

@@ -129,9 +129,9 @@ class TestLocalInventoryLabels:
 
         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]
-            return original(template, data_list)
+            return original(template, data_list, **kwargs)
 
         with patch.object(labels_module, "render_labels", side_effect=_capture):
             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 ──
 
 
-def _render_uncompressed(template, data):
+def _render_uncompressed(template, data, monochrome=False):
     """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.
 
@@ -140,7 +140,7 @@ def _render_uncompressed(template, data):
         buf = _io.BytesIO()
         c = _rl_canvas.Canvas(buf, pagesize=(page_w, page_h), pageCompression=0)
         for d in data:
-            _draw_label(c, 0, 0, page_w, page_h, d)
+            _draw_label(c, 0, 0, page_w, page_h, d, monochrome)
             c.showPage()
         c.save()
         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"
     # 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"
+
+
+# ── #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({
         spool_ids: [1, 3],
         template: 'box_62x29',
+        monochrome: false,
       });
     });
     await waitFor(() => expect(onClose).toHaveBeenCalled());
@@ -232,6 +233,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1],
         template: 'ams_holder_75x55',
+        monochrome: false,
       });
     });
     expect(api.printSpoolLabels).not.toHaveBeenCalled();
@@ -361,6 +363,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 4, 2, 3],
         template: 'box_62x29',
+        monochrome: false,
       });
     });
   });
@@ -385,6 +388,31 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 2, 3, 4],
         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) ──────────────────────────────────────────
   // Both endpoints return application/pdf. Frontend opens the resulting Blob
   // in a new tab so the user can print or save from the browser's PDF viewer.
-  printSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate }): Promise<Blob> => {
+  printSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate; monochrome?: boolean }): 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`, {
@@ -5365,7 +5365,7 @@ export const api = {
     }
     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' };
     if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
     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 [materialFilter, setMaterialFilter] = useState<string>('');
   const [sortMode, setSortMode] = useState<SortMode>('id');
+  const [monochrome, setMonochrome] = useState(false);
 
   // 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
@@ -185,6 +186,7 @@ export function LabelTemplatePickerModal({
       setSearch('');
       setMaterialFilter('');
       setSortMode('id');
+      setMonochrome(false);
       setPending(null);
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -273,8 +275,8 @@ export function LabelTemplatePickerModal({
     setPending(template);
     try {
       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);
       onClose();
     } catch (err) {
@@ -464,10 +466,33 @@ export function LabelTemplatePickerModal({
           )}
         </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
             inside max-h-[90vh] even when browser chrome eats into the viewport
             (#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) => {
             const isPending = pending === opt.value;
             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',
       selectedCount: '{{count}} ausgewählt',
       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',
       filterByMaterial: 'Material:',
       allMaterials: 'Alle',

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

@@ -4225,6 +4225,8 @@ export default {
       title: 'Print spool labels',
       selectedCount: '{{count}} selected',
       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',
       filterByMaterial: 'Material:',
       allMaterials: 'All',

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

@@ -4194,6 +4194,8 @@ export default {
       title: 'Imprimir etiquetas de bobinas',
       selectedCount: '{{count}} seleccionadas',
       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',
       filterByMaterial: 'Material:',
       allMaterials: 'Todos',

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

@@ -4180,6 +4180,8 @@ export default {
       title: 'Imprimer étiquettes de bobines',
       selectedCount: '{{count}} sélectionné',
       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',
       filterByMaterial: 'Matériau :',
       allMaterials: 'Tout',

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

@@ -4179,6 +4179,8 @@ export default {
       title: 'Stampa etichette bobine',
       selectedCount: '{{count}} selezionato',
       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',
       filterByMaterial: 'Materiale:',
       allMaterials: 'Tutto',

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

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

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

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

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

@@ -4179,6 +4179,8 @@ export default {
       title: 'Imprimir etiquetas de bobinas',
       selectedCount: '{{count}} selecionado',
       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',
       filterByMaterial: 'Material:',
       allMaterials: 'Tudo',

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

@@ -4181,6 +4181,8 @@ export default {
       title: 'Makara etiketleri yazdır',
       selectedCount: '{{count}} seçildi',
       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',
       filterByMaterial: 'Malzeme:',
       allMaterials: 'Tümü',

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

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

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

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

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-D_ORAGtA.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <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">
   </head>
   <body>

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff