feat: Etiketten-Seitenlayout auf dem A4-Bogen frei waehlbar
Backend Image / build-and-push (push) Successful in 2m16s
Frontend Image / build-and-push (push) Successful in 2m0s

- LabelTemplate bekommt columns/rows (optional, leer = automatisch
  passend), plus konfigurierbare Raender/Abstaende - damit ein Etikett
  exakt auf das vorgedruckte Raster eines physischen Etikettenbogens
  (z.B. Avery) gelegt werden kann statt nur automatisch berechnet
- Startposition beim Drucken waehlbar, fuer bereits angebrochene Boegen
  (erste Seite startet ab der gewaehlten Position, Folgeseiten wieder
  von vorne)
This commit is contained in:
Nick Szaunig committed 2026-09-01 19:43:01 +02:00
1 parent 9659b8d266
commit c6941a797f
11 files changed
+357 -27

No files matched your search

@@ -0,0 +1,9 @@
-- Explicit A4 sheet layout per LabelTemplate: columns/rows (nullable, null =
-- auto-fit) plus configurable page margins and gaps, so a template can be
-- made to match a specific physical label sheet's exact printed grid.
ALTER TABLE "LabelTemplate" ADD COLUMN "columns" INTEGER;
ALTER TABLE "LabelTemplate" ADD COLUMN "rows" INTEGER;
ALTER TABLE "LabelTemplate" ADD COLUMN "marginTopMm" DOUBLE PRECISION NOT NULL DEFAULT 5;
ALTER TABLE "LabelTemplate" ADD COLUMN "marginLeftMm" DOUBLE PRECISION NOT NULL DEFAULT 5;
ALTER TABLE "LabelTemplate" ADD COLUMN "gapXMm" DOUBLE PRECISION NOT NULL DEFAULT 2;
ALTER TABLE "LabelTemplate" ADD COLUMN "gapYMm" DOUBLE PRECISION NOT NULL DEFAULT 2;
+11
View File
@@ -864,6 +864,17 @@ model LabelTemplate {
elements Json @default("[]")
widthMm Float @default(90)
heightMm Float @default(35)
// A4 sheet layout. columns/rows are nullable: null means "auto-fit as many
// as fit" (the original behaviour); a real label sheet product (e.g. a
// specific Avery SKU) publishes an exact columns/rows/margin/gap grid, so
// setting these lets a template's printed positions match the physical,
// pre-die-cut sheet instead of just whatever the naive fit computes.
columns Int?
rows Int?
marginTopMm Float @default(5)
marginLeftMm Float @default(5)
gapXMm Float @default(2)
gapYMm Float @default(2)
isDefault Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
@@ -4,6 +4,7 @@ import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsString,
@@ -36,6 +37,47 @@ export class CreateLabelTemplateDto {
@Min(10)
heightMm?: number;
@ApiPropertyOptional({
description:
'Spalten pro A4-Bogen. Leer = automatisch so viele wie passen.',
})
@IsOptional()
@IsInt()
@Min(1)
columns?: number;
@ApiPropertyOptional({
description: 'Zeilen pro A4-Bogen. Leer = automatisch so viele wie passen.',
})
@IsOptional()
@IsInt()
@Min(1)
rows?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
marginTopMm?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
marginLeftMm?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
gapXMm?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
gapYMm?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
@@ -4,8 +4,10 @@ import {
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@@ -65,11 +67,14 @@ export class LabelTemplatesController {
async print(
@Param('id') id: string,
@Param('bottlingBatchId') bottlingBatchId: string,
@Query('startPosition', new ParseIntPipe({ optional: true }))
startPosition: number | undefined,
@Res() res: Response,
): Promise<void> {
const pdf = await this.labelTemplatesService.renderLabelsPdf(
id,
bottlingBatchId,
startPosition,
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
@@ -64,6 +64,12 @@ describe('LabelTemplatesService', () => {
elements,
widthMm: 90,
heightMm: 35,
columns: null,
rows: null,
marginTopMm: 5,
marginLeftMm: 5,
gapXMm: 2,
gapYMm: 2,
isDefault: false,
active: true,
};
@@ -213,5 +219,56 @@ describe('LabelTemplatesService', () => {
// A PDF with an embedded raster image contains an XObject /Image entry.
expect(pdf.toString('latin1')).toContain('/Image');
});
function countPages(pdf: Buffer): number {
const matches = pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g);
return matches ? matches.length : 0;
}
it('uses an explicit columns/rows override instead of auto-fitting', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue({
...template,
columns: 1,
rows: 1,
});
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1',
jarSize: 500,
jarCount: 3,
lotNumber: 'L-001',
date: new Date('2026-06-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
expect(countPages(pdf)).toBe(3);
});
it('skips already-used slots on the first sheet via startPosition, but not later sheets', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue({
...template,
columns: 2,
rows: 1,
});
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1',
jarSize: 500,
jarCount: 3,
lotNumber: 'L-001',
date: new Date('2026-06-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1', 2);
// perPage=2, skip=1: label 1 fills the remaining slot on page 1,
// labels 2-3 fill both slots of page 2.
expect(countPages(pdf)).toBe(2);
});
});
});
@@ -13,12 +13,21 @@ import {
} from './label-element.types';
const MM_TO_PT = 72 / 25.4;
const PAGE_MARGIN_MM = 5;
const LABEL_GAP_MM = 2;
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const QR_SOURCE_SIZE_PX = 400;
interface SheetLayout {
widthMm: number;
heightMm: number;
columns: number | null;
rows: number | null;
marginTopMm: number;
marginLeftMm: number;
gapXMm: number;
gapYMm: number;
}
function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE');
}
@@ -151,6 +160,7 @@ export class LabelTemplatesService {
async renderLabelsPdf(
templateId: string,
bottlingBatchId: string,
startPosition = 1,
): Promise<Buffer> {
const template = await this.findById(templateId);
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
@@ -193,19 +203,28 @@ export class LabelTemplatesService {
return this.renderPdf(
elements,
template.widthMm,
template.heightMm,
{
widthMm: template.widthMm,
heightMm: template.heightMm,
columns: template.columns,
rows: template.rows,
marginTopMm: template.marginTopMm,
marginLeftMm: template.marginLeftMm,
gapXMm: template.gapXMm,
gapYMm: template.gapYMm,
},
bottlingBatch.jarCount,
qrCodeBuffer,
startPosition,
);
}
private renderPdf(
elements: LabelElement[],
widthMm: number,
heightMm: number,
layout: SheetLayout,
copies: number,
qrCodeBuffer: Buffer | null,
startPosition: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 0 });
@@ -214,27 +233,34 @@ export class LabelTemplatesService {
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', (error: Error) => reject(error));
const usableWidthMm = A4_WIDTH_MM - 2 * PAGE_MARGIN_MM;
const usableHeightMm = A4_HEIGHT_MM - 2 * PAGE_MARGIN_MM;
const columns = Math.max(
1,
Math.floor((usableWidthMm + LABEL_GAP_MM) / (widthMm + LABEL_GAP_MM)),
);
const rows = Math.max(
1,
Math.floor((usableHeightMm + LABEL_GAP_MM) / (heightMm + LABEL_GAP_MM)),
);
const { widthMm, heightMm, marginTopMm, marginLeftMm, gapXMm, gapYMm } =
layout;
const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm;
const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm;
const columns =
layout.columns ??
Math.max(1, Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm)));
const rows =
layout.rows ??
Math.max(
1,
Math.floor((usableHeightMm + gapYMm) / (heightMm + gapYMm)),
);
const perPage = columns * rows;
// startPosition only skips ahead on the first sheet (partially-used
// physical sheet, e.g. a few labels already peeled off) - every sheet
// after that always starts fresh at the top-left slot.
const skip = Math.min(Math.max(0, startPosition - 1), perPage - 1);
for (let index = 0; index < copies; index += 1) {
const onPage = index % perPage;
if (index > 0 && onPage === 0) {
const slotOnPage = (skip + index) % perPage;
if (index > 0 && slotOnPage === 0) {
doc.addPage({ size: 'A4', margin: 0 });
}
const column = onPage % columns;
const row = Math.floor(onPage / columns);
const xMm = PAGE_MARGIN_MM + column * (widthMm + LABEL_GAP_MM);
const yMm = PAGE_MARGIN_MM + row * (heightMm + LABEL_GAP_MM);
const column = slotOnPage % columns;
const row = Math.floor(slotOnPage / columns);
const xMm = marginLeftMm + column * (widthMm + gapXMm);
const yMm = marginTopMm + row * (heightMm + gapYMm);
const originX = xMm * MM_TO_PT;
const originY = yMm * MM_TO_PT;
const w = widthMm * MM_TO_PT;