feat: Etikettendruck prueft auch gedrehte Anordnung fuer bessere Blattausnutzung
Backend Image / build-and-push (push) Successful in 2m25s
Frontend Image / build-and-push (push) Successful in 2m5s

Im automatischen Modus (Spalten/Zeilen leer) wird jetzt zusaetzlich
berechnet, wie viele Etiketten pro Blatt passen, wenn man sie um 90
Grad dreht (Breite/Hoehe vertauscht). Passt die gedrehte Anordnung mehr
drauf, wird sie verwendet - die Etiketten selbst bleiben inhaltlich
unveraendert (nur auf dem Blatt gedreht), jedes einzelne Etikett bleibt
beim Abziehen normal lesbar.

Bei explizit gesetzten Spalten/Zeilen (z.B. fuer ein vorgedrucktes
Avery-Raster) greift die Drehung nicht - ein reales Blatt hat feste,
nicht drehbare Felder.

Rotation erfolgt via pdfkit-Rotation um die Mitte des Feldes: ein
Etikett, exakt 90 Grad um seinen eigenen Mittelpunkt gedreht, ergibt
immer eine vertauschte Bounding-Box an derselben Stelle - unabhaengig
von der Drehrichtungskonvention. 2 neue Tests belegen ueber die
Seitenzahl, dass die Drehung greift, wenn sie mehr Etiketten
unterbringt, und dass ein explizites Raster unangetastet bleibt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-03 08:14:51 +02:00
1 parent 1e53758753
commit 390ec32f50
2 files changed
+169 -21

No files matched your search

@@ -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,
@@ -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,