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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 06:11:03 +02:00
1 parent 445fb3b94b
commit 888edf7e1d
9 files changed
+280

No files matched your search

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "LabelTemplate" ADD COLUMN "cropMarks" BOOLEAN NOT NULL DEFAULT true;
+4
View File
@@ -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())
@@ -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);
});
});
@@ -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;
}
@@ -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()
@@ -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);