From 888edf7e1d5640fd3a1711bc314526864a421617 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 28 Sep 2026 06:11:03 +0200 Subject: [PATCH] feat(labels): cut marks in the sheet margin for trimming with a paper cutter Label sheets printed on plain A4 are cut with a lever paper cutter. Each template can now print small hairline cut marks in the sheet margin: one at both ends of every cut line (left/right edge of each column, top/bottom edge of each row), 1 mm away from the label and never closer than 2 mm to the paper edge, so they print on normal printers and never touch a label. Labels without a gap share one mark. Marks are drawn on every sheet for the full grid, since the cutter cuts across the whole sheet. On by default (new column cropMarks, default true); switch it off in the template designer for pre-die-cut label sheets. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../migration.sql | 3 + ApiCore-Backend/prisma/schema.prisma | 4 + .../src/label-templates/crop-marks.spec.ts | 78 +++++++++++ .../src/label-templates/crop-marks.ts | 123 ++++++++++++++++++ .../dto/create-label-template.dto.ts | 8 ++ .../label-templates.service.ts | 39 ++++++ .../settings/label-templates/[id]/page.tsx | 1 + .../designer/label-designer.tsx | 21 +++ .../src/lib/label-templates/types.ts | 3 + 9 files changed, 280 insertions(+) create mode 100644 ApiCore-Backend/prisma/migrations/20260928040804_label_template_crop_marks/migration.sql create mode 100644 ApiCore-Backend/src/label-templates/crop-marks.spec.ts create mode 100644 ApiCore-Backend/src/label-templates/crop-marks.ts diff --git a/ApiCore-Backend/prisma/migrations/20260928040804_label_template_crop_marks/migration.sql b/ApiCore-Backend/prisma/migrations/20260928040804_label_template_crop_marks/migration.sql new file mode 100644 index 0000000..28eca6c --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260928040804_label_template_crop_marks/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "LabelTemplate" ADD COLUMN "cropMarks" BOOLEAN NOT NULL DEFAULT true; + diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 04c2d28..1687d23 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -929,6 +929,10 @@ model LabelTemplate { marginLeftMm Float @default(5) gapXMm Float @default(2) gapYMm Float @default(2) + // Kleine Schnittmarken im Blattrand, eine an jedem Ende jeder + // Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten + // Etikettenbögen, die nicht geschnitten werden. + cropMarks Boolean @default(true) isDefault Boolean @default(false) active Boolean @default(true) createdAt DateTime @default(now()) diff --git a/ApiCore-Backend/src/label-templates/crop-marks.spec.ts b/ApiCore-Backend/src/label-templates/crop-marks.spec.ts new file mode 100644 index 0000000..fc6a408 --- /dev/null +++ b/ApiCore-Backend/src/label-templates/crop-marks.spec.ts @@ -0,0 +1,78 @@ +import { computeCropMarks, CropMarkGrid, CropMarkLine } from './crop-marks'; + +// 2 x 2 labels of 90 x 35 mm, 10 mm margin, 2 mm gaps on A4. +const GRID: CropMarkGrid = { + pageWidthMm: 210, + pageHeightMm: 297, + columns: 2, + rows: 2, + slotWidthMm: 90, + slotHeightMm: 35, + marginLeftMm: 10, + marginTopMm: 10, + gapXMm: 2, + gapYMm: 2, +}; + +const vertical = (lines: CropMarkLine[]) => + lines.filter((line) => line.x1 === line.x2); +const horizontal = (lines: CropMarkLine[]) => + lines.filter((line) => line.y1 === line.y2); + +describe('computeCropMarks', () => { + it('marks both edges of every column and row, at both ends of each cut', () => { + const lines = computeCropMarks(GRID); + + // Columns: 10|100 and 102|192 -> 4 cuts, marked at top and bottom. + const xs = [...new Set(vertical(lines).map((line) => line.x1))]; + expect(xs).toEqual([10, 100, 102, 192]); + expect(vertical(lines)).toHaveLength(8); + // Rows: 10|45 and 47|82 -> 4 cuts, marked left and right. + const ys = [...new Set(horizontal(lines).map((line) => line.y1))]; + expect(ys).toEqual([10, 45, 47, 82]); + expect(horizontal(lines)).toHaveLength(8); + }); + + it('keeps every mark in the margin, never on a label', () => { + const lines = computeCropMarks(GRID); + const gridRight = 192; + const gridBottom = 82; + + for (const line of vertical(lines)) { + const inTopMargin = line.y2 <= GRID.marginTopMm - 1; + const inBottomMargin = line.y1 >= gridBottom + 1; + expect(inTopMargin || inBottomMargin).toBe(true); + } + for (const line of horizontal(lines)) { + const inLeftMargin = line.x2 <= GRID.marginLeftMm - 1; + const inRightMargin = line.x1 >= gridRight + 1; + expect(inLeftMargin || inRightMargin).toBe(true); + } + }); + + it('stays clear of the unprintable paper edge', () => { + const lines = computeCropMarks({ + ...GRID, + marginLeftMm: 5, + marginTopMm: 5, + }); + for (const line of lines) { + expect( + Math.min(line.x1, line.x2, line.y1, line.y2), + ).toBeGreaterThanOrEqual(2); + } + }); + + it('draws one cut, not two, where labels touch without a gap', () => { + const lines = computeCropMarks({ ...GRID, gapXMm: 0 }); + const xs = [...new Set(vertical(lines).map((line) => line.x1))]; + expect(xs).toEqual([10, 100, 190]); + }); + + it('leaves out marks when the margin is too narrow to print them', () => { + const lines = computeCropMarks({ ...GRID, marginTopMm: 2 }); + // No room above the grid: top marks disappear, bottom marks remain. + expect(vertical(lines).every((line) => line.y1 > 10)).toBe(true); + expect(vertical(lines)).toHaveLength(4); + }); +}); diff --git a/ApiCore-Backend/src/label-templates/crop-marks.ts b/ApiCore-Backend/src/label-templates/crop-marks.ts new file mode 100644 index 0000000..f91ae00 --- /dev/null +++ b/ApiCore-Backend/src/label-templates/crop-marks.ts @@ -0,0 +1,123 @@ +/** + * Cut marks for trimming a printed label sheet with a guillotine / lever + * paper cutter: short hairlines in the sheet margin, outside every label, + * one at each end of every cut line. Laying both ends of a mark against the + * cutter's ruler lines the blade up with the label edge. + * + * All values in millimetres on an A4 page (0,0 = top left). + */ + +/** How far a mark stays away from the label edge it points at. */ +export const CROP_MARK_GAP_MM = 1; +/** Mark length. Short on purpose - it lives in the margin only. */ +export const CROP_MARK_LENGTH_MM = 4; +/** Most printers cannot print closer to the paper edge than this. */ +export const CROP_MARK_PAGE_INSET_MM = 2; +/** A mark shorter than this is not worth printing (too little margin). */ +const MIN_MARK_LENGTH_MM = 1.5; + +export interface CropMarkGrid { + pageWidthMm: number; + pageHeightMm: number; + columns: number; + rows: number; + slotWidthMm: number; + slotHeightMm: number; + marginLeftMm: number; + marginTopMm: number; + gapXMm: number; + gapYMm: number; +} + +export interface CropMarkLine { + x1: number; + y1: number; + x2: number; + y2: number; +} + +/** Cut positions along one axis; edges closer than 0.05 mm are one cut. */ +function cutPositions( + start: number, + count: number, + size: number, + gap: number, +): number[] { + const positions: number[] = []; + for (let i = 0; i < count; i++) { + const from = start + i * (size + gap); + positions.push(from, from + size); + } + return positions + .sort((a, b) => a - b) + .filter( + (value, index, all) => index === 0 || value - all[index - 1] > 0.05, + ); +} + +/** + * The segment of a mark in the margin between the page edge and the grid, + * or null when the margin is too narrow to print one. + */ +function marginSegment( + gridEdge: number, + pageEdge: number, + towardsPageStart: boolean, +): [number, number] | null { + if (towardsPageStart) { + const end = gridEdge - CROP_MARK_GAP_MM; + const start = Math.max(end - CROP_MARK_LENGTH_MM, CROP_MARK_PAGE_INSET_MM); + return end - start >= MIN_MARK_LENGTH_MM ? [start, end] : null; + } + const start = gridEdge + CROP_MARK_GAP_MM; + const end = Math.min( + start + CROP_MARK_LENGTH_MM, + pageEdge - CROP_MARK_PAGE_INSET_MM, + ); + return end - start >= MIN_MARK_LENGTH_MM ? [start, end] : null; +} + +export function computeCropMarks(grid: CropMarkGrid): CropMarkLine[] { + if (grid.columns < 1 || grid.rows < 1) { + return []; + } + const gridLeft = grid.marginLeftMm; + const gridTop = grid.marginTopMm; + const gridRight = + gridLeft + + grid.columns * grid.slotWidthMm + + (grid.columns - 1) * grid.gapXMm; + const gridBottom = + gridTop + grid.rows * grid.slotHeightMm + (grid.rows - 1) * grid.gapYMm; + + const verticalCuts = cutPositions( + gridLeft, + grid.columns, + grid.slotWidthMm, + grid.gapXMm, + ); + const horizontalCuts = cutPositions( + gridTop, + grid.rows, + grid.slotHeightMm, + grid.gapYMm, + ); + + const top = marginSegment(gridTop, grid.pageHeightMm, true); + const bottom = marginSegment(gridBottom, grid.pageHeightMm, false); + const left = marginSegment(gridLeft, grid.pageWidthMm, true); + const right = marginSegment(gridRight, grid.pageWidthMm, false); + + const lines: CropMarkLine[] = []; + for (const x of verticalCuts) { + for (const segment of [top, bottom]) { + if (segment) lines.push({ x1: x, y1: segment[0], x2: x, y2: segment[1] }); + } + } + for (const y of horizontalCuts) { + for (const segment of [left, right]) { + if (segment) lines.push({ x1: segment[0], y1: y, x2: segment[1], y2: y }); + } + } + return lines; +} diff --git a/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts b/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts index 1de64af..d08c52c 100644 --- a/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts +++ b/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts @@ -78,6 +78,14 @@ export class CreateLabelTemplateDto { @Min(0) gapYMm?: number; + @ApiPropertyOptional({ + description: + 'Schnittmarken im Blattrand drucken (zum Schneiden mit dem Papierschneider)', + }) + @IsOptional() + @IsBoolean() + cropMarks?: boolean; + @ApiPropertyOptional() @IsOptional() @IsBoolean() diff --git a/ApiCore-Backend/src/label-templates/label-templates.service.ts b/ApiCore-Backend/src/label-templates/label-templates.service.ts index 1509449..f7713a1 100644 --- a/ApiCore-Backend/src/label-templates/label-templates.service.ts +++ b/ApiCore-Backend/src/label-templates/label-templates.service.ts @@ -5,6 +5,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { matchesFileSignature } from '../common/file-signature'; import { LabelTemplate } from '../../generated/prisma'; +import { computeCropMarks } from './crop-marks'; import { CreateLabelTemplateDto } from './dto/create-label-template.dto'; import { UpdateLabelTemplateDto } from './dto/update-label-template.dto'; import { @@ -27,8 +28,13 @@ interface SheetLayout { marginLeftMm: number; gapXMm: number; gapYMm: number; + cropMarks: boolean; } +// A hairline: visible enough to lay against the cutter's ruler, thin enough +// not to show if the blade lands a fraction beside it. +const CROP_MARK_LINE_WIDTH_PT = 0.3; + // '2-digit' forces a leading zero for day/month (e.g. "05.03.2026") - the // default 'numeric' formatting drops it ("5.3.2026"), which is what the MHD // on a printed label was showing. @@ -271,6 +277,7 @@ export class LabelTemplatesService { marginLeftMm: template.marginLeftMm, gapXMm: template.gapXMm, gapYMm: template.gapYMm, + cropMarks: template.cropMarks, }, bottlingBatch.jarCount, qrCodeBuffer, @@ -347,10 +354,42 @@ export class LabelTemplatesService { // after that always starts fresh at the top-left slot. const skip = Math.min(Math.max(0, startPosition - 1), perPage - 1); + // Same grid on every page, so the marks are computed once and drawn + // on each new sheet - always for the full grid, also for slots left + // empty, because the cutter cuts across the whole sheet anyway. + const cropMarkLines = layout.cropMarks + ? computeCropMarks({ + pageWidthMm: A4_WIDTH_MM, + pageHeightMm: A4_HEIGHT_MM, + columns, + rows, + slotWidthMm, + slotHeightMm, + marginLeftMm, + marginTopMm, + gapXMm, + gapYMm, + }) + : []; + const drawCropMarks = () => { + if (cropMarkLines.length === 0) return; + doc.save(); + doc.lineWidth(CROP_MARK_LINE_WIDTH_PT).strokeColor('#000000'); + for (const line of cropMarkLines) { + doc + .moveTo(line.x1 * MM_TO_PT, line.y1 * MM_TO_PT) + .lineTo(line.x2 * MM_TO_PT, line.y2 * MM_TO_PT) + .stroke(); + } + doc.restore(); + }; + drawCropMarks(); + for (let index = 0; index < copies; index += 1) { const slotOnPage = (skip + index) % perPage; if (index > 0 && slotOnPage === 0) { doc.addPage({ size: 'A4', margin: 0 }); + drawCropMarks(); } const column = slotOnPage % columns; const row = Math.floor(slotOnPage / columns); diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx index 1e94877..22b0374 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx @@ -46,6 +46,7 @@ export default function EditLabelTemplatePage() { marginLeftMm: template.marginLeftMm, gapXMm: template.gapXMm, gapYMm: template.gapYMm, + cropMarks: template.cropMarks, isDefault: template.isDefault, }} onSubmit={handleSubmit} diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx index 6a40d89..9178e01 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx @@ -82,6 +82,7 @@ export interface LabelDesignerValue { marginLeftMm: number; gapXMm: number; gapYMm: number; + cropMarks: boolean; isDefault: boolean; } @@ -146,6 +147,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP const [marginLeftMm, setMarginLeftMm] = useState(initial?.marginLeftMm ?? 5); const [gapXMm, setGapXMm] = useState(initial?.gapXMm ?? 2); const [gapYMm, setGapYMm] = useState(initial?.gapYMm ?? 2); + const [cropMarks, setCropMarks] = useState(initial?.cropMarks ?? true); const [isDefault, setIsDefault] = useState(initial?.isDefault ?? false); const [elements, setElements] = useState(initial?.elements ?? []); const [selectedId, setSelectedId] = useState(null); @@ -245,6 +247,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP marginLeftMm, gapXMm, gapYMm, + cropMarks, isDefault, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); @@ -287,6 +290,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP if (typeof value.marginLeftMm === "number") setMarginLeftMm(value.marginLeftMm); if (typeof value.gapXMm === "number") setGapXMm(value.gapXMm); if (typeof value.gapYMm === "number") setGapYMm(value.gapYMm); + if (typeof value.cropMarks === "boolean") setCropMarks(value.cropMarks); } setSelectedId(null); toast.success("Vorlage importiert."); @@ -315,6 +319,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP marginLeftMm, gapXMm, gapYMm, + cropMarks, isDefault, }); router.push("/settings/automation?tab=label-templates"); @@ -470,6 +475,22 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP /> + diff --git a/ApiCore-Frontend/src/lib/label-templates/types.ts b/ApiCore-Frontend/src/lib/label-templates/types.ts index 2151787..77c5be7 100644 --- a/ApiCore-Frontend/src/lib/label-templates/types.ts +++ b/ApiCore-Frontend/src/lib/label-templates/types.ts @@ -81,6 +81,7 @@ export interface LabelTemplate { marginLeftMm: number; gapXMm: number; gapYMm: number; + cropMarks: boolean; isDefault: boolean; active: boolean; createdAt: string; @@ -98,6 +99,7 @@ export interface CreateLabelTemplateInput { marginLeftMm?: number; gapXMm?: number; gapYMm?: number; + cropMarks?: boolean; isDefault?: boolean; } @@ -112,5 +114,6 @@ export interface UpdateLabelTemplateInput { marginLeftMm?: number; gapXMm?: number; gapYMm?: number; + cropMarks?: boolean; isDefault?: boolean; }