import { Injectable, NotFoundException } from '@nestjs/common'; import PDFDocument from 'pdfkit'; import * as QRCode from 'qrcode'; 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 { CreateLabelTemplateDto } from './dto/create-label-template.dto'; import { UpdateLabelTemplateDto } from './dto/update-label-template.dto'; import { LabelElement, LabelFontFamily, PlaceholderKey, } from './label-element.types'; const MM_TO_PT = 72 / 25.4; 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; } // '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. function formatDate(date: Date): string { return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', }); } // Prisma stores `elements` as a plain Json column; the shape is only ever // written through CreateLabelTemplateDto/UpdateLabelTemplateDto, so casting // back to the app-level type here is safe. function elementsOf(template: LabelTemplate): LabelElement[] { return template.elements as unknown as LabelElement[]; } function pdfFontName( family: LabelFontFamily, bold: boolean, italic: boolean, ): string { const base = family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica'; if (base === 'Times') { if (bold && italic) return 'Times-BoldItalic'; if (bold) return 'Times-Bold'; if (italic) return 'Times-Italic'; return 'Times-Roman'; } const suffix = bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : ''; return `${base}${suffix}`; } interface GridFit { columns: number; rows: number; } function gridFit( cellWidthMm: number, cellHeightMm: number, usableWidthMm: number, usableHeightMm: number, gapXMm: number, gapYMm: number, ): GridFit { return { columns: Math.max( 1, Math.floor((usableWidthMm + gapXMm) / (cellWidthMm + gapXMm)), ), rows: Math.max( 1, Math.floor((usableHeightMm + gapYMm) / (cellHeightMm + gapYMm)), ), }; } function dataUrlToBuffer(dataUrl: string): Buffer | null { const match = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(dataUrl); if (!match) { return null; } const mimeType = match[1] === 'png' ? 'image/png' : 'image/jpeg'; const buffer = Buffer.from(match[2], 'base64'); // Same magic-byte check as every other upload path (avatar, documents) - // a template's embedded image is only ever entered by a label-template // editor (label-templates:create/update), but there's no reason to hand // pdfkit's image parser bytes that don't match what they claim to be. return matchesFileSignature(buffer, mimeType) ? buffer : null; } /** * The text every placeholder resolves to on a printed label. * * Extracted from the render path so the wording is unit-testable: the render * output is a PDF buffer whose text is not readable by a test, which is how * the jar size once shipped printed as "500 ml". jarSize is a fill weight in * grams everywhere else in the app, and honey is a food sold by mass, so a * label stating millilitres declares the wrong net quantity. */ export function buildPlaceholderValues(bottlingBatch: { jarSize: number; lotNumber: string; date: Date; bestBeforeDate: Date; honeyBatch: { batchNumber: string; honeyType: string | null }; product: { name: string } | null; }): Record { return { batchNumber: bottlingBatch.honeyBatch.batchNumber, honeyType: bottlingBatch.honeyBatch.honeyType ?? '', lotNumber: bottlingBatch.lotNumber, bottlingDate: formatDate(bottlingBatch.date), bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate), jarSize: `${bottlingBatch.jarSize} g`, productName: bottlingBatch.product?.name ?? '', }; } @Injectable() export class LabelTemplatesService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, ) {} async findAll(): Promise { return this.prisma.labelTemplate.findMany({ orderBy: { name: 'asc' } }); } async findById(id: string): Promise { const template = await this.prisma.labelTemplate.findUnique({ where: { id }, }); if (!template) { throw new NotFoundException('Label template not found'); } return template; } async create( dto: CreateLabelTemplateDto, actingUserId: string, ): Promise { const template = await this.prisma.$transaction(async (tx) => { if (dto.isDefault) { await tx.labelTemplate.updateMany({ where: {}, data: { isDefault: false }, }); } return tx.labelTemplate.create({ data: { ...dto, elements: dto.elements as never }, }); }); await this.auditLog.record({ entityType: 'LabelTemplate', entityId: template.id, action: 'CREATE', userId: actingUserId, afterData: template, }); return template; } async update( id: string, dto: UpdateLabelTemplateDto, actingUserId: string, ): Promise { const before = await this.findById(id); const template = await this.prisma.$transaction(async (tx) => { if (dto.isDefault) { await tx.labelTemplate.updateMany({ where: { id: { not: id } }, data: { isDefault: false }, }); } return tx.labelTemplate.update({ where: { id }, data: { ...dto, elements: dto.elements as never }, }); }); await this.auditLog.record({ entityType: 'LabelTemplate', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: template, }); return template; } async deactivate(id: string, actingUserId: string): Promise { const before = await this.findById(id); const template = await this.prisma.labelTemplate.update({ where: { id }, data: { active: false, isDefault: false }, }); await this.auditLog.record({ entityType: 'LabelTemplate', entityId: id, action: 'DEACTIVATE', userId: actingUserId, beforeData: before, afterData: template, }); return template; } async renderLabelsPdf( templateId: string, bottlingBatchId: string, startPosition = 1, ): Promise { const template = await this.findById(templateId); const bottlingBatch = await this.prisma.bottlingBatch.findUnique({ where: { id: bottlingBatchId }, include: { honeyBatch: true, product: true }, }); if (!bottlingBatch) { throw new NotFoundException('Bottling batch not found'); } const values = buildPlaceholderValues(bottlingBatch); const elements = elementsOf(template).map((element) => element.type === 'text' && element.placeholderKey ? { ...element, text: values[element.placeholderKey] } : element, ); // The QR code always points at the public Herkunft/traceability page for // this batch (honey type, dates, origin already shown there), generated // once and reused across every copy and every qrCode element. let qrCodeBuffer: Buffer | null = null; if (elements.some((element) => element.type === 'qrCode')) { const frontendOrigin = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; const herkunftUrl = `${frontendOrigin}/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`; qrCodeBuffer = await QRCode.toBuffer(herkunftUrl, { margin: 1, width: QR_SOURCE_SIZE_PX, }); } return this.renderPdf( elements, { 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[], layout: SheetLayout, copies: number, qrCodeBuffer: Buffer | null, startPosition: number, ): Promise { return new Promise((resolve, reject) => { const doc = new PDFDocument({ size: 'A4', margin: 0 }); const chunks: Buffer[] = []; doc.on('data', (chunk: Buffer) => chunks.push(chunk)); doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('error', (error: Error) => reject(error)); const { widthMm, heightMm, marginTopMm, marginLeftMm, gapXMm, gapYMm } = layout; const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm; const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm; const normalFit = gridFit( widthMm, heightMm, usableWidthMm, usableHeightMm, gapXMm, gapYMm, ); // Only considered when fully automatic (neither columns nor rows set // explicitly): an explicit grid describes a real pre-printed sheet // (e.g. an Avery template) whose physical slots have a fixed size and // orientation - rotating the content wouldn't change how many fit and // would just misalign every label against the sheet. const isFullyAutomatic = layout.columns === null && layout.rows === null; const rotatedFit = isFullyAutomatic ? gridFit( heightMm, widthMm, usableWidthMm, usableHeightMm, gapXMm, gapYMm, ) : null; const useRotated = rotatedFit !== null && rotatedFit.columns * rotatedFit.rows > normalFit.columns * normalFit.rows; // An explicit columns/rows value from the sheet-layout designer is // clamped to what actually fits the A4 page - without this, a value // typed in by the user (or already saved from before this fix) would // place labels past the page's right/bottom edge, where printers just // clip them instead of printing anything. const columns = useRotated ? rotatedFit.columns : Math.min(layout.columns ?? normalFit.columns, normalFit.columns); const rows = useRotated ? rotatedFit.rows : Math.min(layout.rows ?? normalFit.rows, normalFit.rows); const perPage = columns * rows; // The grid slot's own footprint on the page - swapped from the // label's natural widthMm/heightMm when printing rotated, since a // rotated label takes up a heightMm-wide by widthMm-tall slot. const slotWidthMm = useRotated ? heightMm : widthMm; const slotHeightMm = useRotated ? widthMm : heightMm; // 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 slotOnPage = (skip + index) % perPage; if (index > 0 && slotOnPage === 0) { doc.addPage({ size: 'A4', margin: 0 }); } const column = slotOnPage % columns; const row = Math.floor(slotOnPage / columns); const xMm = marginLeftMm + column * (slotWidthMm + gapXMm); const yMm = marginTopMm + row * (slotHeightMm + gapYMm); const originX = xMm * MM_TO_PT; const originY = yMm * MM_TO_PT; const slotW = slotWidthMm * MM_TO_PT; const slotH = slotHeightMm * MM_TO_PT; if (useRotated) { // Rotating a widthMm x heightMm label by exactly 90 degrees around // its own center always produces a heightMm x widthMm box centred // on that same point - true regardless of pdfkit's rotation // direction convention, so the label's own content (still laid // out in its normal, unrotated widthMm x heightMm coordinates) // just needs to be drawn centered in the rotated slot. doc.save(); const centerX = originX + slotW / 2; const centerY = originY + slotH / 2; doc.rotate(90, { origin: [centerX, centerY] }); const w = widthMm * MM_TO_PT; const h = heightMm * MM_TO_PT; this.drawLabel( doc, elements, centerX - w / 2, centerY - h / 2, qrCodeBuffer, ); doc.restore(); } else { this.drawLabel(doc, elements, originX, originY, qrCodeBuffer); } } doc.end(); }); } private drawLabel( doc: PDFKit.PDFDocument, elements: LabelElement[], originX: number, originY: number, qrCodeBuffer: Buffer | null, ): void { for (const element of elements) { this.drawElement(doc, element, originX, originY, qrCodeBuffer); } } private drawElement( doc: PDFKit.PDFDocument, element: LabelElement, originX: number, originY: number, qrCodeBuffer: Buffer | null, ): void { const x = originX + element.x * MM_TO_PT; const y = originY + element.y * MM_TO_PT; switch (element.type) { case 'text': { doc .font(pdfFontName(element.fontFamily, element.bold, element.italic)) .fontSize(element.fontSize) .fillColor(element.color) .text(element.text, x, y, { width: element.width * MM_TO_PT, height: element.height * MM_TO_PT, align: element.align, }); return; } case 'qrCode': { if (!qrCodeBuffer) { return; } const side = element.size * MM_TO_PT; doc.image(qrCodeBuffer, x, y, { width: side, height: side }); return; } case 'image': { const buffer = dataUrlToBuffer(element.src); if (!buffer) { return; } doc.image(buffer, x, y, { width: element.width * MM_TO_PT, height: element.height * MM_TO_PT, }); return; } case 'line': { const endX = element.orientation === 'horizontal' ? x + element.length * MM_TO_PT : x; const endY = element.orientation === 'vertical' ? y + element.length * MM_TO_PT : y; doc .moveTo(x, y) .lineTo(endX, endY) .lineWidth(element.thickness) .strokeColor(element.color) .stroke(); return; } } } }