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:
1 parent
445fb3b94b
commit
888edf7e1d
9 files changed
+280
No files matched your search
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LabelTemplate" ADD COLUMN "cropMarks" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<LabelElement[]>(initial?.elements ?? []);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border border-border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 size-4 accent-[var(--primary)]"
|
||||
checked={cropMarks}
|
||||
onChange={(event) => setCropMarks(event.target.checked)}
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="font-medium">Schnittmarken am Blattrand</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Kleine Striche im Rand an jeder Schnittkante – zum Anlegen am Papierschneider.
|
||||
Ausschalten bei vorgestanzten Etikettenbögen. Rand mindestens 5 mm, damit die
|
||||
Striche gedruckt werden.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user