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.
This commit is contained in:
maziggy
2026-05-09 10:10:20 +02:00
parent a193145522
commit 83a83ed724
17 changed files with 202 additions and 39 deletions
+2
View File
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.4b4] - Unreleased
### Added
- **Spool labels: new 40×30 mm template, hex colour code, bolder brand line** ([#809](https://github.com/maziggy/bambuddy/issues/809) follow-up, requested by @oliboehm) — Three small enhancements to the spool-label printer rolled into one change. **(1) New `box_40x30` template** — 40×30 mm single label, common DK/Brother roll size. Added to `_SINGLE_LABEL_SIZES_MM` in `backend/app/services/label_renderer.py` and to the request body's `Literal[...]` enum in `backend/app/api/routes/labels.py`; height is ≥ 20 mm so it routes through the existing roomy layout (swatch + QR + full text column). **(2) Colour hex code on every label** — new `_hex_code_label()` helper formats `data.rgba` as `#RRGGBB` (alpha-stripped, uppercased to match the inventory UI's colour-picker convention) and returns `""` for missing/malformed input so the caller skips drawing instead of throwing. Rendered as a small line under the material/subtype line in the roomy layout, and as a third line above the spool ID in the tight (AMS) layout — useful when several near-identical material/colour spools sit next to each other in the AMS or on a shelf. **(3) Brand line bigger + bold** — the brand on every label now renders in `Helvetica-Bold` instead of `Helvetica` regular, with size bumped 5.5pt → 6.5pt on the tight layout and 7pt → 8pt on the roomy layout, so it's the most legible non-ID field at arm's length. **Wiring:** `SpoolLabelTemplate` union in `frontend/src/api/client.ts` extended with `'box_40x30'`; `LabelTemplatePickerModal` gets a new `TEMPLATE_OPTIONS` entry for it; `inventory.labels.templates.box40x30.{label,hint}` keys added across all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native, with the existing per-key fallback in the modal as a safety net). The 5-template grid still wraps to 2 columns on small viewports per #1230's fix; modal regression test was widened from `4` to `5` template buttons. **Tests:** `ALL_TEMPLATES` parametrize tuple in `test_label_renderer.py` extended with `box_40x30` so all 7 generic invariants (PDF header, empty-input, multi-colour, missing-fields, malformed-rgba, long strings, sheet pagination) cover the new template; new `test_hex_color_code_rendered_when_rgba_set` (asserts `#F5E6D3` appears in the uncompressed PDF for both 40×30 and 62×29), `test_hex_color_code_skipped_when_rgba_invalid` (regex pin: no `#RRGGBB` shape on the label when rgba is malformed, except the spool ID's `#42`), and `test_brand_rendered_in_bold_per_809_followup` (asserts `Helvetica-Bold` font reference is in the PDF — caught a regression if the brand line ever reverts to regular weight). All 33 backend tests + 15 frontend modal tests pass; ruff clean.
- **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:** `SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head).
### Fixed
+2 -1
View File
@@ -38,6 +38,7 @@ router = APIRouter(tags=["labels"])
_VALID_TEMPLATES: tuple[TemplateName, ...] = (
"ams_30x15",
"box_40x30",
"box_62x29",
"avery_5160",
"avery_l7160",
@@ -50,7 +51,7 @@ MAX_LABELS_PER_REQUEST = 500
class LabelRequest(BaseModel):
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:
+59 -13
View File
@@ -1,9 +1,12 @@
"""PDF spool label rendering.
Four fixed templates:
Five fixed templates:
- ``ams_30x15`` — 30×15 mm single label, fits the popular Makerworld AMS
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
generic small labels. One label per page.
- ``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.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
@@ -83,6 +86,25 @@ def _luminance(color: Color) -> float:
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 ────────────────────────────────────────────────────────────
@@ -201,7 +223,7 @@ def _draw_label_tight(
pad: float,
data: LabelData,
) -> 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_y = inner_y + (inner_h - swatch_w) / 2
_draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
@@ -213,21 +235,33 @@ def _draw_label_tight(
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:
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)
# Second line: material + subtype, small
sub_size = 5.5
sub_size = 5
sub_line = " ".join(filter(None, [data.material, data.subtype]))
sub_y_baseline = y + h - pad - brand_size - 0.6 - sub_size
if sub_line:
c.setFont("Helvetica", sub_size)
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.
id_size = 13
@@ -274,14 +308,16 @@ def _draw_label_roomy(
line1 = data.brand or ""
line2 = " · ".join(filter(None, [data.material, data.subtype]))
name = data.name or ""
hex_code = _hex_code_label(data.rgba)
# Layout from the top of the text column.
cursor_y = y + h - pad
# Brand — bumped to bold + larger per the #809 follow-up.
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
c.drawString(text_x, cursor_y, text)
cursor_y -= 1.2
@@ -294,6 +330,15 @@ def _draw_label_roomy(
c.drawString(text_x, cursor_y, text)
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:
size = 9
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.
_SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
"ams_30x15": (30.0, 15.0),
"box_40x30": (40.0, 30.0),
"box_62x29": (62.0, 29.0),
}
@@ -6,7 +6,7 @@ import pytest
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:
@@ -121,8 +121,12 @@ def _render_uncompressed(template, data):
from backend.app.services.label_renderer import _draw_label # noqa: PLC0415
# 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]
page_w, page_h = w_mm * _mm, h_mm * _mm
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():
"""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
@@ -193,7 +193,9 @@ describe('LabelTemplatePickerModal', () => {
);
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(() => {
expect(api.printSpoolLabels).toHaveBeenCalledWith({
@@ -275,15 +277,15 @@ describe('LabelTemplatePickerModal', () => {
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
// 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(
<LabelTemplatePickerModal
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(/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 5160/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');
expect(templatesSection).not.toBeNull();
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.
const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
+1 -1
View File
@@ -2326,7 +2326,7 @@ export interface SpoolmanFilamentEntry {
// Inventory types
// 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 {
id: number;
@@ -38,6 +38,12 @@ const TEMPLATE_OPTIONS: TemplateOption[] = [
fallbackLabel: 'AMS holder (30 × 15 mm)',
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',
i18nKey: 'box',
+4
View File
@@ -3443,6 +3443,10 @@ export default {
label: 'AMS holder (30 × 15 mm)',
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: {
label: 'Box label (62 × 29 mm)',
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+4
View File
@@ -3446,6 +3446,10 @@ export default {
label: 'AMS holder (30 × 15 mm)',
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: {
label: 'Box label (62 × 29 mm)',
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+4
View File
@@ -3432,6 +3432,10 @@ export default {
label: 'AMS holder (30 × 15 mm)',
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: {
label: 'Box label (62 × 29 mm)',
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+4
View File
@@ -3431,6 +3431,10 @@ export default {
label: 'AMS holder (30 × 15 mm)',
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: {
label: 'Box label (62 × 29 mm)',
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+4
View File
@@ -3443,6 +3443,10 @@ export default {
label: 'AMS holder (30 × 15 mm)',
hint: 'Single label per page; fits the popular AMS filament label holder.',
},
box40x30: {
label: 'ボックスラベル (40 × 30 mm)',
hint: '1ページに1枚。DK/Brother の一般的なロールサイズで、フィラメント袋や保管箱のラベルに適しています。',
},
box: {
label: 'Box label (62 × 29 mm)',
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+4
View File
@@ -3431,6 +3431,10 @@ export default {
label: 'AMS holder (30 × 15 mm)',
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: {
label: 'Box label (62 × 29 mm)',
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+4
View File
@@ -3431,6 +3431,10 @@ export default {
label: 'AMS holder (30 × 15 mm)',
hint: 'Single label per page; fits the popular AMS filament label holder.',
},
box40x30: {
label: '盒标签 (40 × 30 mm)',
hint: '每页一张;常见的 DK/Brother 卷尺寸,适合给耗材袋和储物盒贴标签。',
},
box: {
label: 'Box label (62 × 29 mm)',
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+4
View File
@@ -3431,6 +3431,10 @@ export default {
label: 'AMS holder (30 × 15 mm)',
hint: 'Single label per page; fits the popular AMS filament label holder.',
},
box40x30: {
label: '盒標籤 (40 × 30 mm)',
hint: '每頁一張;常見的 DK/Brother 捲尺寸,適合用於耗材袋與儲物盒標籤。',
},
box: {
label: 'Box label (62 × 29 mm)',
hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-TQAsqf5J.js"></script>
<script type="module" crossorigin src="/assets/index-CvmCjsiR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSBzgKvT.css">
</head>
<body>