Browse Source

feat(labels): add 40x30 mm template, hex colour code, bolder brand (issue #809 follow-up)

  Three enhancements requested by @oliboehm after the V1 label-printing
  ship in #809:

  - New box_40x30 single-label template (common DK/Brother roll size,
    good for filament-bag and storage-bin labels). Routes through the
    existing roomy layout since height >= 20 mm.

  - Colour hex code (#RRGGBB, alpha-stripped, uppercase) rendered on
    every label - useful when several near-identical material/colour
    spools sit next to each other and the swatch alone isn't enough to
    tell them apart. Skipped silently when rgba is None or malformed.

  - Brand line bumped to Helvetica-Bold (was regular) and a couple of
    points larger on both layouts so it reads cleanly at arm's length.

  Wired through the SpoolLabelTemplate union, the modal's
  TEMPLATE_OPTIONS, and the inventory.labels.templates.box40x30 i18n
  key in all 8 locales (native translations for de/fr/it/ja/pt-BR/
  zh-CN/zh-TW). Modal regression test widened from 4 to 5 template
  buttons. Three new renderer tests pin the hex-code render, the
  hex-code skip on invalid rgba, and the bold-brand font reference.
maziggy 2 weeks ago
parent
commit
83a83ed724

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


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

@@ -38,6 +38,7 @@ router = APIRouter(tags=["labels"])
 
 
 _VALID_TEMPLATES: tuple[TemplateName, ...] = (
 _VALID_TEMPLATES: tuple[TemplateName, ...] = (
     "ams_30x15",
     "ams_30x15",
+    "box_40x30",
     "box_62x29",
     "box_62x29",
     "avery_5160",
     "avery_5160",
     "avery_l7160",
     "avery_l7160",
@@ -50,7 +51,7 @@ MAX_LABELS_PER_REQUEST = 500
 
 
 class LabelRequest(BaseModel):
 class LabelRequest(BaseModel):
     spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
     spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
-    template: Literal["ams_30x15", "box_62x29", "avery_5160", "avery_l7160"]
+    template: Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
 
 
 
 
 def _split_extra_colors(raw: str | None) -> list[str] | None:
 def _split_extra_colors(raw: str | None) -> list[str] | None:

+ 59 - 13
backend/app/services/label_renderer.py

@@ -1,9 +1,12 @@
 """PDF spool label rendering.
 """PDF spool label rendering.
 
 
-Four fixed templates:
+Five fixed templates:
 
 
 - ``ams_30x15``  — 30×15 mm single label, fits the popular Makerworld AMS
 - ``ams_30x15``  — 30×15 mm single label, fits the popular Makerworld AMS
   Filament Label Holder (model 752566). One label per page.
   Filament Label Holder (model 752566). One label per page.
+- ``box_40x30``  — 40×30 mm single label, common DK/Brother roll size and a
+  good fit for filament-bag/storage-bin labels (#809 follow-up). Roomy
+  layout — swatch, QR, full text column with hex code.
 - ``box_62x29``  — 62×29 mm single label, sized for Brother PT/QL and Dymo
 - ``box_62x29``  — 62×29 mm single label, sized for Brother PT/QL and Dymo
   generic small labels. One label per page.
   generic small labels. One label per page.
 - ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
 - ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
@@ -31,7 +34,7 @@ from reportlab.lib.pagesizes import A4, letter
 from reportlab.lib.units import mm
 from reportlab.lib.units import mm
 from reportlab.pdfgen import canvas as rl_canvas
 from reportlab.pdfgen import canvas as rl_canvas
 
 
-TemplateName = Literal["ams_30x15", "box_62x29", "avery_5160", "avery_l7160"]
+TemplateName = Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
 
 
 
 
 @dataclass
 @dataclass
@@ -83,6 +86,25 @@ def _luminance(color: Color) -> float:
     return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
     return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
 
 
 
 
+def _hex_code_label(rgba: str | None) -> str:
+    """Format ``data.rgba`` as a printable ``#RRGGBB`` string for the label.
+
+    Drops the alpha channel (printed labels can't show transparency) and
+    upper-cases the hex digits to match the colour-picker convention used in
+    the inventory UI. Returns an empty string for None / malformed input so
+    the caller can ``if hex_code:`` skip drawing without an exception.
+    """
+    if not rgba:
+        return ""
+    h = rgba.lstrip("#").strip()
+    if len(h) not in (6, 8):
+        return ""
+    rgb = h[:6]
+    if not all(c in "0123456789abcdefABCDEF" for c in rgb):
+        return ""
+    return f"#{rgb.upper()}"
+
+
 # ── QR generation ────────────────────────────────────────────────────────────
 # ── QR generation ────────────────────────────────────────────────────────────
 
 
 
 
@@ -201,7 +223,7 @@ def _draw_label_tight(
     pad: float,
     pad: float,
     data: LabelData,
     data: LabelData,
 ) -> None:
 ) -> None:
-    """AMS-holder layout (e.g. 30×15 mm). Swatch + 3-line text, no QR."""
+    """AMS-holder layout (e.g. 30×15 mm). Swatch + brand/material/hex/ID, no QR."""
     swatch_w = min(inner_h, inner_w * 0.35)
     swatch_w = min(inner_h, inner_w * 0.35)
     swatch_y = inner_y + (inner_h - swatch_w) / 2
     swatch_y = inner_y + (inner_h - swatch_w) / 2
     _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
     _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
@@ -213,21 +235,33 @@ def _draw_label_tight(
 
 
     c.setFillColor(black)
     c.setFillColor(black)
 
 
-    # Top: brand (small)
-    brand_size = 5.5
+    # Top: brand — bumped to bold + larger per the #809 follow-up so it's the
+    # easiest thing to read on a small AMS holder at arm's length.
+    brand_size = 6.5
     if data.brand:
     if data.brand:
-        c.setFont("Helvetica", brand_size)
-        brand = _truncate_to_width(c, data.brand, "Helvetica", brand_size, text_w)
+        c.setFont("Helvetica-Bold", brand_size)
+        brand = _truncate_to_width(c, data.brand, "Helvetica-Bold", brand_size, text_w)
         c.drawString(text_x, y + h - pad - brand_size, brand)
         c.drawString(text_x, y + h - pad - brand_size, brand)
 
 
     # Second line: material + subtype, small
     # Second line: material + subtype, small
-    sub_size = 5.5
+    sub_size = 5
     sub_line = " ".join(filter(None, [data.material, data.subtype]))
     sub_line = " ".join(filter(None, [data.material, data.subtype]))
+    sub_y_baseline = y + h - pad - brand_size - 0.6 - sub_size
     if sub_line:
     if sub_line:
         c.setFont("Helvetica", sub_size)
         c.setFont("Helvetica", sub_size)
         sub_line = _truncate_to_width(c, sub_line, "Helvetica", sub_size, text_w)
         sub_line = _truncate_to_width(c, sub_line, "Helvetica", sub_size, text_w)
-        sub_y = y + h - pad - brand_size - 0.6 - sub_size
-        c.drawString(text_x, sub_y, sub_line)
+        c.drawString(text_x, sub_y_baseline, sub_line)
+
+    # Third line (when there's room): hex code, tiny — useful when the user
+    # has multiple near-identical colours in the same material family.
+    hex_code = _hex_code_label(data.rgba)
+    if hex_code:
+        hex_size = 4.5
+        hex_y = sub_y_baseline - 0.4 - hex_size
+        # Don't render if it'd collide with the spool ID at the bottom.
+        if hex_y > inner_y + 13:
+            c.setFont("Helvetica", hex_size)
+            c.drawString(text_x, hex_y, hex_code)
 
 
     # Bottom: BIG spool ID — the killer field at-a-glance.
     # Bottom: BIG spool ID — the killer field at-a-glance.
     id_size = 13
     id_size = 13
@@ -274,14 +308,16 @@ def _draw_label_roomy(
     line1 = data.brand or ""
     line1 = data.brand or ""
     line2 = " · ".join(filter(None, [data.material, data.subtype]))
     line2 = " · ".join(filter(None, [data.material, data.subtype]))
     name = data.name or ""
     name = data.name or ""
+    hex_code = _hex_code_label(data.rgba)
 
 
     # Layout from the top of the text column.
     # Layout from the top of the text column.
     cursor_y = y + h - pad
     cursor_y = y + h - pad
 
 
+    # Brand — bumped to bold + larger per the #809 follow-up.
     if line1:
     if line1:
-        size = 7
-        c.setFont("Helvetica", size)
-        text = _truncate_to_width(c, line1, "Helvetica", size, text_w)
+        size = 8
+        c.setFont("Helvetica-Bold", size)
+        text = _truncate_to_width(c, line1, "Helvetica-Bold", size, text_w)
         cursor_y -= size
         cursor_y -= size
         c.drawString(text_x, cursor_y, text)
         c.drawString(text_x, cursor_y, text)
         cursor_y -= 1.2
         cursor_y -= 1.2
@@ -294,6 +330,15 @@ def _draw_label_roomy(
         c.drawString(text_x, cursor_y, text)
         c.drawString(text_x, cursor_y, text)
         cursor_y -= 1.5
         cursor_y -= 1.5
 
 
+    # Hex colour code — useful for telling near-identical material+colour
+    # spools apart when the swatch is small or the user is colour-blind.
+    if hex_code:
+        size = 6.5
+        c.setFont("Helvetica", size)
+        cursor_y -= size
+        c.drawString(text_x, cursor_y, hex_code)
+        cursor_y -= 1.2
+
     if name and name != line1:
     if name and name != line1:
         size = 9
         size = 9
         c.setFont("Helvetica-Bold", size)
         c.setFont("Helvetica-Bold", size)
@@ -321,6 +366,7 @@ def _draw_label_roomy(
 # (label_w_mm, label_h_mm) for single-label-per-page templates.
 # (label_w_mm, label_h_mm) for single-label-per-page templates.
 _SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
 _SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
     "ams_30x15": (30.0, 15.0),
     "ams_30x15": (30.0, 15.0),
+    "box_40x30": (40.0, 30.0),
     "box_62x29": (62.0, 29.0),
     "box_62x29": (62.0, 29.0),
 }
 }
 
 

+ 75 - 3
backend/tests/unit/services/test_label_renderer.py

@@ -6,7 +6,7 @@ import pytest
 
 
 from backend.app.services.label_renderer import LabelData, render_labels
 from backend.app.services.label_renderer import LabelData, render_labels
 
 
-ALL_TEMPLATES = ("ams_30x15", "box_62x29", "avery_5160", "avery_l7160")
+ALL_TEMPLATES = ("ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160")
 
 
 
 
 def _sample(spool_id: int = 1, **overrides) -> LabelData:
 def _sample(spool_id: int = 1, **overrides) -> LabelData:
@@ -121,8 +121,12 @@ def _render_uncompressed(template, data):
     from backend.app.services.label_renderer import _draw_label  # noqa: PLC0415
     from backend.app.services.label_renderer import _draw_label  # noqa: PLC0415
 
 
     # Mirror the page-size choice from render_labels but force pageCompression=0.
     # Mirror the page-size choice from render_labels but force pageCompression=0.
-    if template in ("ams_30x15", "box_62x29"):
-        sizes = {"ams_30x15": (30.0, 15.0), "box_62x29": (62.0, 29.0)}
+    if template in ("ams_30x15", "box_40x30", "box_62x29"):
+        sizes = {
+            "ams_30x15": (30.0, 15.0),
+            "box_40x30": (40.0, 30.0),
+            "box_62x29": (62.0, 29.0),
+        }
         w_mm, h_mm = sizes[template]
         w_mm, h_mm = sizes[template]
         page_w, page_h = w_mm * _mm, h_mm * _mm
         page_w, page_h = w_mm * _mm, h_mm * _mm
         buf = _io.BytesIO()
         buf = _io.BytesIO()
@@ -189,6 +193,74 @@ def test_ams_template_actually_renders_text():
     )
     )
 
 
 
 
+def test_hex_color_code_rendered_when_rgba_set():
+    """#809 follow-up: the colour hex code (#RRGGBB, alpha-stripped, uppercase)
+    must appear on the rendered label so the user can tell near-identical
+    spools apart at a glance.
+    """
+    data = [
+        LabelData(
+            spool_id=12,
+            name="Polymaker Ivory",
+            material="PLA",
+            brand="Polymaker",
+            subtype="Matte",
+            rgba="f5e6d3FF",
+            deeplink_url="https://example.test/inventory?spool=12",
+        )
+    ]
+    pdf = _render_uncompressed("box_62x29", data)
+    assert b"#F5E6D3" in pdf, "box label must render the hex colour code"
+
+    pdf = _render_uncompressed("box_40x30", data)
+    assert b"#F5E6D3" in pdf, "40x30 box label must render the hex colour code"
+
+
+def test_hex_color_code_skipped_when_rgba_invalid():
+    """Malformed rgba must NOT render any '#' hex string apart from the spool
+    ID — silently skipping the hex line is better than crashing or rendering
+    garbage. The spool ID still uses '#' so we look for the specific shape.
+    """
+    data = [
+        LabelData(
+            spool_id=99,
+            name="Test",
+            material="PLA",
+            brand="Polymaker",
+            rgba="not-a-color",
+            deeplink_url="https://example.test/inventory?spool=99",
+        )
+    ]
+    pdf = _render_uncompressed("box_62x29", data)
+    # No 6-hex-digit '#XXXXXX' substring should appear (only '#99' for the ID).
+    import re
+
+    matches = re.findall(rb"#[0-9A-F]{6}", pdf)
+    assert matches == [], f"expected no hex code on label, found {matches!r}"
+
+
+def test_brand_rendered_in_bold_per_809_followup():
+    """#809 follow-up: brand should render in Helvetica-Bold (not regular).
+    Uncompressed PDFs include font-name tokens like '/F2' tied to a font
+    resource; we can grep for the bold font's basename in the resource block.
+    """
+    data = [
+        LabelData(
+            spool_id=5,
+            name="Acme PLA",
+            material="PLA",
+            brand="Polymaker",
+            rgba="FF8800FF",
+            deeplink_url="https://example.test/inventory?spool=5",
+        )
+    ]
+    pdf = _render_uncompressed("box_62x29", data)
+    # ReportLab references the bold variant of Helvetica via /Helvetica-Bold
+    # in the font dictionary — both the spool ID (always bold) and the brand
+    # (now bold per #809 follow-up) cause the resource to be embedded.
+    assert b"Helvetica-Bold" in pdf, "label PDF must reference Helvetica-Bold for the brand line"
+
+
 def test_box_template_does_not_truncate_normal_brand_or_name():
 def test_box_template_does_not_truncate_normal_brand_or_name():
     """Regression: the first cut of the box-label layout sized the swatch and
     """Regression: the first cut of the box-label layout sized the swatch and
     QR each at ~14 mm on a 26-mm-wide text column, leaving only ~16 mm for
     QR each at ~14 mm on a 26-mm-wide text column, leaving only ~16 mm for

+ 15 - 11
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -193,7 +193,9 @@ describe('LabelTemplatePickerModal', () => {
     );
     );
 
 
     fireEvent.click(screen.getByText(/Blue · Sunlu/));  // uncheck spool 2
     fireEvent.click(screen.getByText(/Blue · Sunlu/));  // uncheck spool 2
-    fireEvent.click(screen.getByText(/Box label/i));
+    // Two "Box label …" templates exist now (40×30 and 62×29) — pin the
+    // specific one we want to send so the assertion below stays meaningful.
+    fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
 
 
     await waitFor(() => {
     await waitFor(() => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
@@ -275,15 +277,15 @@ describe('LabelTemplatePickerModal', () => {
     expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
     expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
   });
   });
 
 
-  it('packs templates into a 2x2 grid so all 4 plus Cancel fit on short viewports (#1230)', () => {
-    // Regression for #1230: with 4 templates stacked vertically (~310px) plus
+  it('packs templates into a 2-column grid so they plus Cancel fit on short viewports (#1230)', () => {
+    // Regression for #1230: with templates stacked vertically (~310-390px) plus
     // header/search/action bar/footer, the modal blew past max-h-[90vh] on
     // header/search/action bar/footer, the modal blew past max-h-[90vh] on
     // Windows-11 + Brave-style viewports where browser chrome eats into 90vh.
     // Windows-11 + Brave-style viewports where browser chrome eats into 90vh.
-    // overflow-hidden on the modal then clipped Avery 5160 and the Cancel
-    // footer with no scroll path. The fix uses sm:grid-cols-2 so the 4
-    // templates render as a 2x2 grid (~155px), trimming ~150px of vertical
-    // and leaving room for the footer. The earlier min-h-0 on the spool list
-    // is kept so it still yields any remaining slack.
+    // overflow-hidden on the modal then clipped the bottom templates and the
+    // Cancel footer with no scroll path. The fix uses sm:grid-cols-2 so the
+    // templates render as a 2-column grid, trimming ~150px of vertical and
+    // leaving room for the footer. The earlier min-h-0 on the spool list is
+    // kept so it still yields any remaining slack.
     const { container } = render(
     const { container } = render(
       <LabelTemplatePickerModal
       <LabelTemplatePickerModal
         isOpen={true}
         isOpen={true}
@@ -294,9 +296,11 @@ describe('LabelTemplatePickerModal', () => {
       />,
       />,
     );
     );
 
 
-    // All 4 templates must be in the DOM, including the last one.
+    // All five templates must be in the DOM. Use the dimension suffix to
+    // disambiguate the two "Box label …" entries.
     expect(screen.getByText(/AMS holder/i)).toBeInTheDocument();
     expect(screen.getByText(/AMS holder/i)).toBeInTheDocument();
-    expect(screen.getByText(/Box label/i)).toBeInTheDocument();
+    expect(screen.getByText(/Box label \(40 × 30 mm\)/i)).toBeInTheDocument();
+    expect(screen.getByText(/Box label \(62 × 29 mm\)/i)).toBeInTheDocument();
     expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
     expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
     expect(screen.getByText(/Avery 5160/i)).toBeInTheDocument();
     expect(screen.getByText(/Avery 5160/i)).toBeInTheDocument();
     expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
     expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
@@ -307,7 +311,7 @@ describe('LabelTemplatePickerModal', () => {
     const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
     const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
     expect(templatesSection).not.toBeNull();
     expect(templatesSection).not.toBeNull();
     expect(templatesSection!.className).toContain('grid-cols-1');
     expect(templatesSection!.className).toContain('grid-cols-1');
-    expect(templatesSection!.querySelectorAll('button').length).toBe(4);
+    expect(templatesSection!.querySelectorAll('button').length).toBe(5);
 
 
     // Spool list still uses min-h-0 so it can yield further on very tight viewports.
     // Spool list still uses min-h-0 so it can yield further on very tight viewports.
     const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
     const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');

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

@@ -2326,7 +2326,7 @@ export interface SpoolmanFilamentEntry {
 
 
 // Inventory types
 // Inventory types
 // Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
 // Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
-export type SpoolLabelTemplate = 'ams_30x15' | 'box_62x29' | 'avery_5160' | 'avery_l7160';
+export type SpoolLabelTemplate = 'ams_30x15' | 'box_40x30' | 'box_62x29' | 'avery_5160' | 'avery_l7160';
 
 
 export interface InventorySpool {
 export interface InventorySpool {
   id: number;
   id: number;

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

@@ -38,6 +38,12 @@ const TEMPLATE_OPTIONS: TemplateOption[] = [
     fallbackLabel: 'AMS holder (30 × 15 mm)',
     fallbackLabel: 'AMS holder (30 × 15 mm)',
     fallbackHint: 'Single label per page; fits the popular AMS filament label holder.',
     fallbackHint: 'Single label per page; fits the popular AMS filament label holder.',
   },
   },
+  {
+    value: 'box_40x30',
+    i18nKey: 'box40x30',
+    fallbackLabel: 'Box label (40 × 30 mm)',
+    fallbackHint: 'Single label per page; common DK/Brother roll size, good for filament-bag and storage-bin labels.',
+  },
   {
   {
     value: 'box_62x29',
     value: 'box_62x29',
     i18nKey: 'box',
     i18nKey: 'box',

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

@@ -3443,6 +3443,10 @@ export default {
           label: 'AMS holder (30 × 15 mm)',
           label: 'AMS holder (30 × 15 mm)',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
         },
         },
+        box40x30: {
+          label: 'Boxetikett (40 × 30 mm)',
+          hint: 'Ein Etikett pro Seite; gängige DK/Brother-Rollengröße, ideal für Filament-Beutel und Lager-Etiketten.',
+        },
         box: {
         box: {
           label: 'Box label (62 × 29 mm)',
           label: 'Box label (62 × 29 mm)',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',

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

@@ -3446,6 +3446,10 @@ export default {
           label: 'AMS holder (30 × 15 mm)',
           label: 'AMS holder (30 × 15 mm)',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
         },
         },
+        box40x30: {
+          label: 'Box label (40 × 30 mm)',
+          hint: 'Single label per page; common DK/Brother roll size, good for filament-bag and storage-bin labels.',
+        },
         box: {
         box: {
           label: 'Box label (62 × 29 mm)',
           label: 'Box label (62 × 29 mm)',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',

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

@@ -3432,6 +3432,10 @@ export default {
           label: 'AMS holder (30 × 15 mm)',
           label: 'AMS holder (30 × 15 mm)',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
         },
         },
+        box40x30: {
+          label: 'Étiquette boîte (40 × 30 mm)',
+          hint: 'Une étiquette par page ; taille de rouleau DK/Brother courante, idéale pour étiqueter sachets de filament et bacs de stockage.',
+        },
         box: {
         box: {
           label: 'Box label (62 × 29 mm)',
           label: 'Box label (62 × 29 mm)',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',

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

@@ -3431,6 +3431,10 @@ export default {
           label: 'AMS holder (30 × 15 mm)',
           label: 'AMS holder (30 × 15 mm)',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
         },
         },
+        box40x30: {
+          label: 'Etichetta scatola (40 × 30 mm)',
+          hint: 'Una etichetta per pagina; misura comune DK/Brother, ideale per etichette di sacchetti filamento e contenitori.',
+        },
         box: {
         box: {
           label: 'Box label (62 × 29 mm)',
           label: 'Box label (62 × 29 mm)',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',

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

@@ -3443,6 +3443,10 @@ export default {
           label: 'AMS holder (30 × 15 mm)',
           label: 'AMS holder (30 × 15 mm)',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
         },
         },
+        box40x30: {
+          label: 'ボックスラベル (40 × 30 mm)',
+          hint: '1ページに1枚。DK/Brother の一般的なロールサイズで、フィラメント袋や保管箱のラベルに適しています。',
+        },
         box: {
         box: {
           label: 'Box label (62 × 29 mm)',
           label: 'Box label (62 × 29 mm)',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',

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

@@ -3431,6 +3431,10 @@ export default {
           label: 'AMS holder (30 × 15 mm)',
           label: 'AMS holder (30 × 15 mm)',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
         },
         },
+        box40x30: {
+          label: 'Etiqueta de caixa (40 × 30 mm)',
+          hint: 'Uma etiqueta por página; tamanho comum de rolo DK/Brother, ideal para etiquetar sacos de filamento e caixas de armazenamento.',
+        },
         box: {
         box: {
           label: 'Box label (62 × 29 mm)',
           label: 'Box label (62 × 29 mm)',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',

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

@@ -3431,6 +3431,10 @@ export default {
           label: 'AMS holder (30 × 15 mm)',
           label: 'AMS holder (30 × 15 mm)',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
         },
         },
+        box40x30: {
+          label: '盒标签 (40 × 30 mm)',
+          hint: '每页一张;常见的 DK/Brother 卷尺寸,适合给耗材袋和储物盒贴标签。',
+        },
         box: {
         box: {
           label: 'Box label (62 × 29 mm)',
           label: 'Box label (62 × 29 mm)',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',

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

@@ -3431,6 +3431,10 @@ export default {
           label: 'AMS holder (30 × 15 mm)',
           label: 'AMS holder (30 × 15 mm)',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
           hint: 'Single label per page; fits the popular AMS filament label holder.',
         },
         },
+        box40x30: {
+          label: '盒標籤 (40 × 30 mm)',
+          hint: '每頁一張;常見的 DK/Brother 捲尺寸,適合用於耗材袋與儲物盒標籤。',
+        },
         box: {
         box: {
           label: 'Box label (62 × 29 mm)',
           label: 'Box label (62 × 29 mm)',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
           hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CvmCjsiR.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-TQAsqf5J.js"></script>
+    <script type="module" crossorigin src="/assets/index-CvmCjsiR.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BSBzgKvT.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BSBzgKvT.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff