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
+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,
|
||||||
|
|||||||
Reference in new issue
Block a user