diff --git a/frontend/lib/reports/label-generator.test.ts b/frontend/lib/reports/label-generator.test.ts new file mode 100644 index 000000000..cf64a59e7 --- /dev/null +++ b/frontend/lib/reports/label-generator.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "vitest"; +import { + buildPageCss, + buildRotateCss, + calculateGridData, + calculateMakerGrid, + fmtAssetID, + makerPageSize, + MAKER_PRESET, + presetFor, + SHEET_PRESET, + type LabelMakerInput, + type LabelOptionInput, +} from "./label-generator"; + +const averyInput: LabelOptionInput = { + measure: "in", + page: { + width: 8.5, + height: 11, + pageTopPadding: 0.52, + pageBottomPadding: 0.42, + pageLeftPadding: 0.25, + pageRightPadding: 0.1, + }, + cardWidth: 2.63, + cardHeight: 1, +}; + +const makerInput: LabelMakerInput = { + measure: "mm", + labelWidth: 90, + labelHeight: 62, + labelsPerRow: 1, + labelGap: 0, +}; + +describe("fmtAssetID", () => { + test("pads and hyphenates a small number", () => { + expect(fmtAssetID(1)).toBe("000-001"); + }); + + test("formats a full six digit value", () => { + expect(fmtAssetID(123456)).toBe("123-456"); + }); + + test("does not truncate values longer than six digits", () => { + expect(fmtAssetID(1234567)).toBe("123-4567"); + }); + + test("accepts string input", () => { + expect(fmtAssetID("42")).toBe("000-042"); + }); +}); + +describe("calculateGridData", () => { + test("lays out the Avery 5260 sheet", () => { + const res = calculateGridData(averyInput); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.data.cols).toBe(3); + expect(res.data.rows).toBe(10); + expect(res.data.measure).toBe("in"); + expect(res.data.card).toEqual({ width: 2.63, height: 1 }); + }); + + test("errors when the page is too small for the card", () => { + const res = calculateGridData({ ...averyInput, cardWidth: 100 }); + expect(res).toEqual({ ok: false, error: "page_too_small_card" }); + }); + + test("single column yields gapX of 0, not NaN", () => { + const res = calculateGridData({ ...averyInput, cardWidth: 7 }); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.data.cols).toBe(1); + expect(res.data.gapX).toBe(0); + }); + + test("single row yields gapY of 0, not NaN", () => { + const res = calculateGridData({ ...averyInput, cardHeight: 9 }); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.data.rows).toBe(1); + expect(res.data.gapY).toBe(0); + }); + + test("falls back to inches for an invalid measure", () => { + const res = calculateGridData({ ...averyInput, measure: "furlong" }); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.data.measure).toBe("in"); + }); +}); + +describe("makerPageSize", () => { + test("single label width equals the label width", () => { + expect(makerPageSize(makerInput)).toEqual({ measure: "mm", width: 90, height: 62 }); + }); + + test("row of three includes the gaps between labels", () => { + const size = makerPageSize({ ...makerInput, labelsPerRow: 3, labelGap: 2 }); + expect(size.width).toBe(3 * 90 + 2 * 2); + expect(size.height).toBe(62); + }); + + test("normalizes the measure", () => { + expect(makerPageSize({ ...makerInput, measure: "bogus" }).measure).toBe("in"); + }); +}); + +describe("calculateMakerGrid", () => { + test("single label is a one-by-one grid with no gaps", () => { + const grid = calculateMakerGrid(makerInput); + expect(grid.cols).toBe(1); + expect(grid.rows).toBe(1); + expect(grid.gapX).toBe(0); + expect(grid.gapY).toBe(0); + expect(grid.page).toEqual({ width: 90, height: 62, pt: 0, pb: 0, pl: 0, pr: 0 }); + }); + + test("row of three uses the label gap for gapX", () => { + const grid = calculateMakerGrid({ ...makerInput, labelsPerRow: 3, labelGap: 2 }); + expect(grid.cols).toBe(3); + expect(grid.rows).toBe(1); + expect(grid.gapX).toBe(2); + expect(grid.page.width).toBe(3 * 90 + 2 * 2); + }); +}); + +describe("buildPageCss", () => { + const size = { measure: "mm" as const, width: 90, height: 62 }; + + test("maker mode emits a sized, margin-free page rule", () => { + expect(buildPageCss("maker", size)).toBe("@page { size: 90mm 62mm; margin: 0; }"); + }); + + test("sheet mode emits no rule", () => { + expect(buildPageCss("sheet", size)).toBe(""); + }); + + test("custom mode emits no rule", () => { + expect(buildPageCss("custom", size)).toBe(""); + }); + + test("180 rotation keeps the page dimensions", () => { + expect(buildPageCss("maker", size, 180)).toBe("@page { size: 90mm 62mm; margin: 0; }"); + }); + + test("90 rotation swaps the page dimensions", () => { + expect(buildPageCss("maker", size, 90)).toBe("@page { size: 62mm 90mm; margin: 0; }"); + }); + + test("270 rotation swaps the page dimensions", () => { + expect(buildPageCss("maker", size, 270)).toBe("@page { size: 62mm 90mm; margin: 0; }"); + }); +}); + +describe("buildRotateCss", () => { + const size = { measure: "mm" as const, width: 90, height: 62 }; + + test("no rotation emits no rule", () => { + expect(buildRotateCss("maker", size, 0)).toBe(""); + }); + + test("sheet mode emits no rule", () => { + expect(buildRotateCss("sheet", size, 90)).toBe(""); + }); + + test("180 rotation flips in place without resizing", () => { + expect(buildRotateCss("maker", size, 180)).toBe( + "@media print { .maker-label { transform: rotate(180deg); transform-origin: center center; } }" + ); + }); + + test("90 rotation sizes and re-centers the label onto the swapped page", () => { + expect(buildRotateCss("maker", size, 90)).toBe( + "@media print { .maker-label { width: 90mm; height: 62mm; transform: translate(-14mm, 14mm) rotate(90deg); transform-origin: center center; } }" + ); + }); + + test("270 rotation sizes and re-centers the label onto the swapped page", () => { + // Re-centering moves the box center onto the swapped page center, which is independent of rotation direction, + // so 270 shares the 90 translate offsets. + expect(buildRotateCss("maker", size, 270)).toBe( + "@media print { .maker-label { width: 90mm; height: 62mm; transform: translate(-14mm, 14mm) rotate(270deg); transform-origin: center center; } }" + ); + }); +}); + +describe("presetFor", () => { + test("returns the sheet preset", () => { + expect(presetFor("sheet")).toBe(SHEET_PRESET); + }); + + test("returns the maker preset", () => { + expect(presetFor("maker")).toBe(MAKER_PRESET); + }); + + test("returns null for custom", () => { + expect(presetFor("custom")).toBeNull(); + }); +}); diff --git a/frontend/lib/reports/label-generator.ts b/frontend/lib/reports/label-generator.ts new file mode 100644 index 000000000..8f8c2d46e --- /dev/null +++ b/frontend/lib/reports/label-generator.ts @@ -0,0 +1,230 @@ +// Pure logic for the label generator page (frontend/pages/reports/label-generator.vue). +// Kept free of Vue/i18n/toast so it can be unit tested in isolation. + +export type Measure = "in" | "cm" | "mm"; + +export type LabelMode = "sheet" | "maker" | "custom"; + +export type LabelOptionInput = { + measure: string; + page: { + height: number; + width: number; + pageTopPadding: number; + pageBottomPadding: number; + pageLeftPadding: number; + pageRightPadding: number; + }; + cardHeight: number; + cardWidth: number; +}; + +export type GridData = { + measure: Measure; + cols: number; + rows: number; + gapY: number; + gapX: number; + card: { + width: number; + height: number; + }; + page: { + width: number; + height: number; + pt: number; + pb: number; + pl: number; + pr: number; + }; +}; + +export type GridResult = { ok: true; data: GridData } | { ok: false; error: "page_too_small_card" }; + +export type LabelMakerInput = { + measure: string; + labelWidth: number; + labelHeight: number; + labelsPerRow: number; + labelGap: number; +}; + +export type MakerPageSize = { + measure: Measure; + width: number; + height: number; +}; + +export type LabelPreset = { + measure: Measure; + cardHeight: number; + cardWidth: number; + pageWidth: number; + pageHeight: number; + pageTopPadding: number; + pageBottomPadding: number; + pageLeftPadding: number; + pageRightPadding: number; + labelsPerRow: number; + labelGap: number; +}; + +export const DEFAULT_MEASURE: Measure = "in"; + +// Avery 5260 sheet of labels (the historical default). +export const SHEET_PRESET: LabelPreset = { + measure: "in", + cardHeight: 1, + cardWidth: 2.63, + pageWidth: 8.5, + pageHeight: 11, + pageTopPadding: 0.52, + pageBottomPadding: 0.42, + pageLeftPadding: 0.25, + pageRightPadding: 0.1, + labelsPerRow: 1, + labelGap: 0, +}; + +// Brother QL DK-2205 62mm continuous tape: 2.4" tape width, default 1" length. Dimensions are editable in the UI. +export const MAKER_PRESET: LabelPreset = { + measure: "in", + cardHeight: 1, + cardWidth: 2.4, + pageWidth: 2.4, + pageHeight: 1, + pageTopPadding: 0, + pageBottomPadding: 0, + pageLeftPadding: 0, + pageRightPadding: 0, + labelsPerRow: 1, + labelGap: 0, +}; + +export function normalizeMeasure(measure: string): Measure { + return /^(in|cm|mm)$/.test(measure) ? (measure as Measure) : DEFAULT_MEASURE; +} + +// Returns the geometry preset for a mode, or null when the mode owns no preset (custom). +export function presetFor(mode: LabelMode): LabelPreset | null { + if (mode === "sheet") { + return SHEET_PRESET; + } + if (mode === "maker") { + return MAKER_PRESET; + } + return null; +} + +export function fmtAssetID(aid: number | string): string { + let aidStr = aid.toString().padStart(6, "0"); + aidStr = aidStr.slice(0, 3) + "-" + aidStr.slice(3); + return aidStr; +} + +// Lays a sheet out into a grid of cards based on the available page area. +export function calculateGridData(input: LabelOptionInput): GridResult { + const { page, cardHeight, cardWidth } = input; + const measure = normalizeMeasure(input.measure); + + const availablePageWidth = page.width - page.pageLeftPadding - page.pageRightPadding; + const availablePageHeight = page.height - page.pageTopPadding - page.pageBottomPadding; + + if (availablePageWidth < cardWidth || availablePageHeight < cardHeight) { + return { ok: false, error: "page_too_small_card" }; + } + + const cols = Math.floor(availablePageWidth / cardWidth); + const rows = Math.floor(availablePageHeight / cardHeight); + // Guard single-column / single-row layouts so the gap stays 0 instead of NaN/Infinity. + const gapX = cols > 1 ? (availablePageWidth - cols * cardWidth) / (cols - 1) : 0; + const gapY = rows > 1 ? (availablePageHeight - rows * cardHeight) / (rows - 1) : 0; + + return { + ok: true, + data: { + measure, + cols, + rows, + gapX, + gapY, + card: { + width: cardWidth, + height: cardHeight, + }, + page: { + width: page.width, + height: page.height, + pt: page.pageTopPadding, + pb: page.pageBottomPadding, + pl: page.pageLeftPadding, + pr: page.pageRightPadding, + }, + }, + }; +} + +// Width (and height) of a single label-maker segment: one row of N labels. +export function makerPageSize(input: LabelMakerInput): MakerPageSize { + const perRow = Math.max(1, Math.floor(input.labelsPerRow)); + return { + measure: normalizeMeasure(input.measure), + width: perRow * input.labelWidth + (perRow - 1) * input.labelGap, + height: input.labelHeight, + }; +} + +// A label maker prints one row of labels per tape segment: a single-row, zero-padding grid. +export function calculateMakerGrid(input: LabelMakerInput): GridData { + const cols = Math.max(1, Math.floor(input.labelsPerRow)); + const size = makerPageSize(input); + + return { + measure: size.measure, + cols, + rows: 1, + gapX: cols > 1 ? input.labelGap : 0, + gapY: 0, + card: { + width: input.labelWidth, + height: input.labelHeight, + }, + page: { + width: size.width, + height: size.height, + pt: 0, + pb: 0, + pl: 0, + pr: 0, + }, + }; +} + +// Rotation applied to maker labels when printing, to match how the printer feeds the tape. +export type PrintRotation = 0 | 90 | 180 | 270; + +// CSS @page rule. Sheet/custom keep the historical behavior (no rule, user sets printer margins). +// Label maker sizes each printed page to one tape segment so labels feed correctly. +// 90/270 swap the page to portrait since the rotated label is taller than wide. +export function buildPageCss(mode: LabelMode, size: MakerPageSize, rotation: PrintRotation = 0): string { + if (mode !== "maker") { + return ""; + } + const swap = rotation === 90 || rotation === 270; + const w = swap ? size.height : size.width; + const h = swap ? size.width : size.height; + return `@page { size: ${w}${size.measure} ${h}${size.measure}; margin: 0; }`; +} + +// Print-only transform that rotates each maker label to match the chosen rotation, re-centering it on the page. +export function buildRotateCss(mode: LabelMode, size: MakerPageSize, rotation: PrintRotation): string { + if (mode !== "maker" || rotation === 0) { + return ""; + } + if (rotation === 180) { + return `@media print { .maker-label { transform: rotate(180deg); transform-origin: center center; } }`; + } + const m = size.measure; + const shift = (size.width - size.height) / 2; + return `@media print { .maker-label { width: ${size.width}${m}; height: ${size.height}${m}; transform: translate(${-shift}${m}, ${shift}${m}) rotate(${rotation}deg); transform-origin: center center; } }`; +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 85435aff3..1e89d3b5b 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -818,13 +818,23 @@ "base_url": "Base URL", "bordered_labels": "Bordered Labels", "generate_page": "Generate Page", + "print_page": "Print", + "rotate_print": "Print rotation", + "rotate_none": "None", "input_placeholder": "Type here", "instruction_1": "The HomeBox Label Generator is a tool to help you print labels for your HomeBox inventory. These are intended to\n be print-ahead labels so you can print many labels and have them ready to apply", "instruction_2": "As such, these labels work by printing a URL QR Code and AssetID information on a label. If you've disabled\n AssetID's in your HomeBox settings, you can still use this tool, but the AssetID's won't reference any item", "instruction_3": "This feature is in early development stages and may change in future releases, if you have feedback please\n provide it in the ''GitHub Discussion''", + "label_gap": "Label Gap", "label_height": "Label Height", "label_width": "Label Width", + "labels_per_row": "Labels Per Row", + "maker_instruction": "Each printed page is one tape segment sized to your labels. In the print dialog, choose your label maker and disable extra margins. If the label prints sideways, click \"Print using system dialog\" and set the printer orientation to match your roll (Chrome's quick print dialog may not expose it).", "measure_type": "Measure Type", + "mode": "Output Type", + "mode_custom": "Custom", + "mode_label_maker": "Label Maker", + "mode_label_sheet": "Label Sheet", "page_bottom_padding": "Page Bottom Padding", "page_height": "Page Height", "page_left_padding": "Page Left Padding", diff --git a/frontend/pages/reports/label-generator.vue b/frontend/pages/reports/label-generator.vue index c454a3932..43107da37 100644 --- a/frontend/pages/reports/label-generator.vue +++ b/frontend/pages/reports/label-generator.vue @@ -2,6 +2,18 @@ import { useI18n } from "vue-i18n"; import DOMPurify from "dompurify"; import { route } from "../../lib/api/base"; + import { + buildPageCss, + buildRotateCss, + calculateGridData, + calculateMakerGrid, + fmtAssetID, + makerPageSize, + presetFor, + type GridData, + type LabelMode, + type PrintRotation, + } from "../../lib/reports/label-generator"; import { Toaster, toast } from "@/components/ui/sonner"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; @@ -24,6 +36,9 @@ const bordered = ref(false); const printLocationRow = ref(true); + // Rotation applied to maker labels when printing, for printers (e.g. Brother QL) that rotate the page onto the tape. + const printRotation = ref(0); + const PRINT_ROTATIONS: PrintRotation[] = [0, 90, 180, 270]; const labelBlankLine = "_______________"; // Behavior constants for HomeBox text replacement @@ -36,6 +51,12 @@ const replaceHomeboxBehavior = ref(BEHAVIOR_SHOW); const replaceHomeboxText = ref(labelBlankLine); + // Output target: a sheet of labels, a Brother-style label maker (continuous tape), or fully custom. + const MODE_SHEET: LabelMode = "sheet"; + const MODE_MAKER: LabelMode = "maker"; + const MODE_CUSTOM: LabelMode = "custom"; + const mode = ref(MODE_SHEET); + const displayProperties = reactive({ baseURL: window.location.origin, assetRange: 1, @@ -46,6 +67,8 @@ columns: 3, cardHeight: 1, cardWidth: 2.63, + labelsPerRow: 1, + labelGap: 0, pageWidth: 8.5, pageHeight: 11, pageTopPadding: 0.52, @@ -54,90 +77,17 @@ pageRightPadding: 0.1, }); - type LabelOptionInput = { - measure: string; - page: { - height: number; - width: number; - pageTopPadding: number; - pageBottomPadding: number; - pageLeftPadding: number; - pageRightPadding: number; - }; - cardHeight: number; - cardWidth: number; - }; - - type Output = { - measure: string; - cols: number; - rows: number; - gapY: number; - gapX: number; - card: { - width: number; - height: number; - }; - page: { - width: number; - height: number; - pt: number; - pb: number; - pl: number; - pr: number; - }; - }; - - function calculateGridData(input: LabelOptionInput): Output { - const { page, cardHeight, cardWidth } = input; - - const measureRegex = /in|cm|mm/; - const measure = measureRegex.test(input.measure) ? input.measure : "in"; - - const availablePageWidth = page.width - page.pageLeftPadding - page.pageRightPadding; - const availablePageHeight = page.height - page.pageTopPadding - page.pageBottomPadding; - - if (availablePageWidth < cardWidth || availablePageHeight < cardHeight) { - toast.error(t("reports.label_generator.toast.page_too_small_card")); - return out.value; - } - - const cols = Math.floor(availablePageWidth / cardWidth); - const rows = Math.floor(availablePageHeight / cardHeight); - const gapX = (availablePageWidth - cols * cardWidth) / (cols - 1); - const gapY = (page.height - rows * cardHeight) / (rows - 1); - - return { - measure, - cols, - rows, - gapX, - gapY, - card: { - width: cardWidth, - height: cardHeight, - }, - page: { - width: page.width, - height: page.height, - pt: page.pageTopPadding, - pb: page.pageBottomPadding, - pl: page.pageLeftPadding, - pr: page.pageRightPadding, - }, - }; - } - interface InputDef { label: string; ref: keyof typeof displayProperties; type?: "number" | "text"; min?: number; step?: number; + modes?: LabelMode[]; } const propertyInputs = computed(() => { - return [ + const inputs: InputDef[] = [ { label: t("reports.label_generator.asset_start"), ref: "assetRange", @@ -165,29 +115,48 @@ label: t("reports.label_generator.label_width"), ref: "cardWidth", }, + { + label: t("reports.label_generator.labels_per_row"), + ref: "labelsPerRow", + min: 1, + step: 1, + modes: [MODE_MAKER], + }, + { + label: t("reports.label_generator.label_gap"), + ref: "labelGap", + min: 0, + modes: [MODE_MAKER], + }, { label: t("reports.label_generator.page_width"), ref: "pageWidth", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.page_height"), ref: "pageHeight", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.page_top_padding"), ref: "pageTopPadding", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.page_bottom_padding"), ref: "pageBottomPadding", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.page_left_padding"), ref: "pageLeftPadding", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.page_right_padding"), ref: "pageRightPadding", + modes: [MODE_SHEET, MODE_CUSTOM], }, { label: t("reports.label_generator.base_url"), @@ -195,6 +164,8 @@ type: "text", }, ]; + + return inputs.filter(input => !input.modes || input.modes.includes(mode.value)); }); type LabelData = { @@ -204,14 +175,6 @@ location: string; }; - function fmtAssetID(aid: number | string) { - aid = aid.toString(); - - let aidStr = aid.toString().padStart(6, "0"); - aidStr = aidStr.slice(0, 3) + "-" + aidStr.slice(3); - return aidStr; - } - function getQRCodeUrl(assetID: string): string { let origin = displayProperties.baseURL.trim(); @@ -307,7 +270,7 @@ const pages = ref([]); - const out = ref({ + const out = ref({ measure: "in", cols: 0, rows: 0, @@ -329,19 +292,37 @@ function calcPages() { // Set Out Dimensions - out.value = calculateGridData({ - measure: displayProperties.measure, - page: { - height: displayProperties.pageHeight, - width: displayProperties.pageWidth, - pageTopPadding: displayProperties.pageTopPadding, - pageBottomPadding: displayProperties.pageBottomPadding, - pageLeftPadding: displayProperties.pageLeftPadding, - pageRightPadding: displayProperties.pageRightPadding, - }, - cardHeight: displayProperties.cardHeight, - cardWidth: displayProperties.cardWidth, - }); + if (mode.value === MODE_MAKER) { + out.value = calculateMakerGrid({ + measure: displayProperties.measure, + labelWidth: displayProperties.cardWidth, + labelHeight: displayProperties.cardHeight, + labelsPerRow: displayProperties.labelsPerRow, + labelGap: displayProperties.labelGap, + }); + } else { + const result = calculateGridData({ + measure: displayProperties.measure, + page: { + height: displayProperties.pageHeight, + width: displayProperties.pageWidth, + pageTopPadding: displayProperties.pageTopPadding, + pageBottomPadding: displayProperties.pageBottomPadding, + pageLeftPadding: displayProperties.pageLeftPadding, + pageRightPadding: displayProperties.pageRightPadding, + }, + cardHeight: displayProperties.cardHeight, + cardWidth: displayProperties.cardWidth, + }); + + if (!result.ok) { + toast.error(t(`reports.label_generator.toast.${result.error}`)); + pages.value = []; + return; + } + + out.value = result.data; + } const calc: Page[] = []; @@ -393,6 +374,47 @@ pages.value = calc; } + // Regenerate so the print reflects current settings, then open the print dialog (the form is print:hidden). + async function printLabels() { + calcPages(); + await nextTick(); + // A failed geometry recalculation clears pages (and toasts); don't open the print dialog on stale/empty output. + if (pages.value.length === 0) { + return; + } + window.print(); + } + + // Seed the dimension fields with the preset for the chosen mode (custom keeps the current values). + watch(mode, newMode => { + const preset = presetFor(newMode); + if (preset) { + Object.assign(displayProperties, preset); + } + calcPages(); + }); + + // Size each printed page to a single tape segment so label-maker output feeds correctly. + const makerSize = computed(() => + makerPageSize({ + measure: displayProperties.measure, + labelWidth: displayProperties.cardWidth, + labelHeight: displayProperties.cardHeight, + labelsPerRow: displayProperties.labelsPerRow, + labelGap: displayProperties.labelGap, + }) + ); + + useHead(() => ({ + style: [ + { + innerHTML: + buildPageCss(mode.value, makerSize.value, printRotation.value) + + buildRotateCss(mode.value, makerSize.value, printRotation.value), + }, + ], + })); + onMounted(() => { calcPages(); }); @@ -423,8 +445,32 @@
+
+ + +

+ {{ $t("reports.label_generator.maker_instruction") }} +

+
-
+
@@ -492,21 +538,49 @@ {{ $t("reports.label_generator.print_location_row") }}
+
+ + +

{{ $t("reports.label_generator.qr_code_example") }} {{ displayProperties.baseURL }}/a/{asset_id}

- +
+ + +
-
+