diff --git a/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts b/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts index 4db5356..f19ae51 100644 --- a/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts +++ b/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts @@ -343,6 +343,54 @@ describe('LabelTemplatesService', () => { expect(countPages(pdf)).toBe(2); }); + it('rotates labels 90 degrees when that fits more per sheet than the upright orientation', async () => { + // widthMm:90, heightMm:35, marginTopMm/Left:5, gapX/Y:2 (the default + // fixture): upright fits 2 columns x 7 rows = 14 per page, but rotated + // (35mm-wide x 90mm-tall slots) fits 5 columns x 3 rows = 15 per page. + prisma.labelTemplate.findUnique.mockResolvedValue(template); + prisma.bottlingBatch.findUnique.mockResolvedValue({ + id: 'batch-1', + jarSize: 500, + jarCount: 15, + 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'); + + // Only fits on a single page if the rotated 15-per-page layout was + // actually used - upright would need a second page for the 15th copy. + expect(countPages(pdf)).toBe(1); + }); + + it('does not rotate when columns/rows are explicitly set, even if rotating would fit more', async () => { + prisma.labelTemplate.findUnique.mockResolvedValue({ + ...template, + columns: 2, + rows: 7, + }); + prisma.bottlingBatch.findUnique.mockResolvedValue({ + id: 'batch-1', + jarSize: 500, + jarCount: 15, + 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'); + + // An explicit grid describes a real pre-printed sheet - it must be + // honoured as-is (14 per page), needing 2 pages for 15 copies, even + // though the auto-fit rotation logic would otherwise fit all 15 on one. + expect(countPages(pdf)).toBe(2); + }); + it('skips already-used slots on the first sheet via startPosition, but not later sheets', async () => { prisma.labelTemplate.findUnique.mockResolvedValue({ ...template, diff --git a/ApiCore-Backend/src/label-templates/label-templates.service.ts b/ApiCore-Backend/src/label-templates/label-templates.service.ts index 1cb3d20..021b00d 100644 --- a/ApiCore-Backend/src/label-templates/label-templates.service.ts +++ b/ApiCore-Backend/src/label-templates/label-templates.service.ts @@ -58,6 +58,31 @@ function pdfFontName( 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) { @@ -244,22 +269,52 @@ export class LabelTemplatesService { layout; const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm; const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm; - const maxColumns = Math.max( - 1, - Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm)), - ); - const maxRows = Math.max( - 1, - Math.floor((usableHeightMm + gapYMm) / (heightMm + gapYMm)), + 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 = Math.min(layout.columns ?? maxColumns, maxColumns); - const rows = Math.min(layout.rows ?? maxRows, maxRows); + 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. @@ -272,21 +327,46 @@ export class LabelTemplatesService { } const column = slotOnPage % columns; const row = Math.floor(slotOnPage / columns); - const xMm = marginLeftMm + column * (widthMm + gapXMm); - const yMm = marginTopMm + row * (heightMm + gapYMm); + 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 w = widthMm * MM_TO_PT; - const h = heightMm * MM_TO_PT; + const slotW = slotWidthMm * MM_TO_PT; + const slotH = slotHeightMm * MM_TO_PT; - doc - .rect(originX, originY, w, h) - .dash(2, { space: 2 }) - .stroke('#999999') - .undash(); - - for (const element of elements) { - this.drawElement(doc, element, originX, originY, qrCodeBuffer); + 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, + w, + h, + qrCodeBuffer, + ); + doc.restore(); + } else { + this.drawLabel( + doc, + elements, + originX, + originY, + slotW, + slotH, + qrCodeBuffer, + ); } } @@ -294,6 +374,26 @@ export class LabelTemplatesService { }); } + private drawLabel( + doc: PDFKit.PDFDocument, + elements: LabelElement[], + originX: number, + originY: number, + widthPt: number, + heightPt: number, + qrCodeBuffer: Buffer | null, + ): void { + doc + .rect(originX, originY, widthPt, heightPt) + .dash(2, { space: 2 }) + .stroke('#999999') + .undash(); + + for (const element of elements) { + this.drawElement(doc, element, originX, originY, qrCodeBuffer); + } + } + private drawElement( doc: PDFKit.PDFDocument, element: LabelElement,