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
+164 -16

No files matched your search

@@ -343,6 +343,54 @@ describe('LabelTemplatesService', () => {
expect(countPages(pdf)).toBe(2); 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 () => { it('skips already-used slots on the first sheet via startPosition, but not later sheets', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue({ prisma.labelTemplate.findUnique.mockResolvedValue({
...template, ...template,
@@ -58,6 +58,31 @@ function pdfFontName(
return `${base}${suffix}`; 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 { function dataUrlToBuffer(dataUrl: string): Buffer | null {
const match = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(dataUrl); const match = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(dataUrl);
if (!match) { if (!match) {
@@ -244,22 +269,52 @@ export class LabelTemplatesService {
layout; layout;
const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm; const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm;
const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm; const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm;
const maxColumns = Math.max( const normalFit = gridFit(
1, widthMm,
Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm)), heightMm,
); usableWidthMm,
const maxRows = Math.max( usableHeightMm,
1, gapXMm,
Math.floor((usableHeightMm + gapYMm) / (heightMm + gapYMm)), 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 // An explicit columns/rows value from the sheet-layout designer is
// clamped to what actually fits the A4 page - without this, a value // 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 // 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 // place labels past the page's right/bottom edge, where printers just
// clip them instead of printing anything. // clip them instead of printing anything.
const columns = Math.min(layout.columns ?? maxColumns, maxColumns); const columns = useRotated
const rows = Math.min(layout.rows ?? maxRows, maxRows); ? 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; 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 // startPosition only skips ahead on the first sheet (partially-used
// physical sheet, e.g. a few labels already peeled off) - every sheet // physical sheet, e.g. a few labels already peeled off) - every sheet
// after that always starts fresh at the top-left slot. // after that always starts fresh at the top-left slot.
@@ -272,15 +327,64 @@ export class LabelTemplatesService {
} }
const column = slotOnPage % columns; const column = slotOnPage % columns;
const row = Math.floor(slotOnPage / columns); const row = Math.floor(slotOnPage / columns);
const xMm = marginLeftMm + column * (widthMm + gapXMm); const xMm = marginLeftMm + column * (slotWidthMm + gapXMm);
const yMm = marginTopMm + row * (heightMm + gapYMm); const yMm = marginTopMm + row * (slotHeightMm + gapYMm);
const originX = xMm * MM_TO_PT; const originX = xMm * MM_TO_PT;
const originY = yMm * 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 w = widthMm * MM_TO_PT;
const h = heightMm * 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,
);
}
}
doc.end();
});
}
private drawLabel(
doc: PDFKit.PDFDocument,
elements: LabelElement[],
originX: number,
originY: number,
widthPt: number,
heightPt: number,
qrCodeBuffer: Buffer | null,
): void {
doc doc
.rect(originX, originY, w, h) .rect(originX, originY, widthPt, heightPt)
.dash(2, { space: 2 }) .dash(2, { space: 2 })
.stroke('#999999') .stroke('#999999')
.undash(); .undash();
@@ -290,10 +394,6 @@ export class LabelTemplatesService {
} }
} }
doc.end();
});
}
private drawElement( private drawElement(
doc: PDFKit.PDFDocument, doc: PDFKit.PDFDocument,
element: LabelElement, element: LabelElement,