feat: Etikettendruck prueft auch gedrehte Anordnung fuer bessere Blattausnutzung
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:
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,
|
||||
|
||||
Reference in new issue
Block a user