feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s

- Veterinary medicine catalogue, lots and append-only movement ledger
  (rest always computed; corrections as counter-bookings; inactive
  medicines take no new stock; unit must match on lot moves).
- Documents of medicine lots, authority notices and restrictions need the
  domain right on top of documents:* (403 before any lookup).
- Label particulars: pure checkLabel with six mandatory gaps and four
  warnings, AFM font metrics, Berlin-day dates, geometric reading order
  for split addresses, new master-data fields with row-locked updates.
- Herd events ColonyCreated, ColonyMoved, ApiaryChanged and
  TskReferenceDayReached (ids only, never forwarded to webhooks); CSV
  import emits ColonyCreated with audit.
- ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 13:43:44 +02:00
1 parent 8a31c42578
commit c18860c75c
73 files changed
+7121 -309

No files matched your search

@@ -8,6 +8,7 @@ import {
IsNumber,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
@@ -91,4 +92,14 @@ export class CreateLabelTemplateDto {
@IsOptionalNotNull()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional({
description:
'Kleinste x-Höhe (mm) für Pflichtangaben; darunter warnt die Etikettenprüfung. Vorgabe 1,2.',
})
@IsOptionalNotNull()
@IsNumber()
@Min(0.5)
@Max(5)
minXHeightMm?: number;
}
@@ -0,0 +1,67 @@
import { fontMetricsOf, heightMm, pdfTextMeasurer } from './font-metrics';
import { LabelTextElement } from './label-element.types';
describe('font metrics', () => {
// Guards against a pdfkit update that changes or hides the AFM values.
it('Helvetica metrics are x-height 523 and cap height 718', () => {
expect(fontMetricsOf('sans', false, false)).toEqual({
xHeight: 523,
capHeight: 718,
});
});
it('Times and Courier return their own AFM values', () => {
expect(fontMetricsOf('serif', false, false)).toEqual({
xHeight: 450,
capHeight: 662,
});
expect(fontMetricsOf('serif', true, true)).toEqual({
xHeight: 462,
capHeight: 669,
});
expect(fontMetricsOf('mono', false, false)).toEqual({
xHeight: 426,
capHeight: 562,
});
});
it('reads the style variants of a family', () => {
expect(fontMetricsOf('sans', true, false).xHeight).toBe(532);
});
it('converts a size and a metric to millimetres', () => {
expect(heightMm(6, 523)).toBeCloseTo(1.107, 3);
expect(heightMm(7, 523)).toBeCloseTo(1.2915, 3);
expect(heightMm(72, 1000)).toBeCloseTo(25.4, 9);
});
describe('pdfTextMeasurer', () => {
const element: LabelTextElement = {
id: 'e',
type: 'text',
x: 0,
y: 0,
width: 20,
height: 10,
text: '',
fontSize: 10,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
};
it('measures more lines for a narrower box', () => {
const measurer = pdfTextMeasurer();
const text = 'Mindestens haltbar bis 05.03.2028 und noch mehr Text';
const wide = measurer.heightOfStringPt(text, { ...element, width: 120 });
const narrow = measurer.heightOfStringPt(text, { ...element, width: 20 });
expect(narrow).toBeGreaterThan(wide);
});
it('measures nothing for an empty text', () => {
expect(pdfTextMeasurer().heightOfStringPt('', element)).toBe(0);
});
});
});
@@ -0,0 +1,92 @@
import PDFDocument from 'pdfkit';
import { LabelFontFamily, LabelTextElement } from './label-element.types';
export const MM_TO_PT = 72 / 25.4;
/** Glyph metrics in AFM units (1/1000 of the font size). */
export interface FontMetrics {
xHeight: number;
capHeight: number;
}
export interface TextMeasurer {
/** Height in pt the element's text needs at the element's width and font. */
heightOfStringPt(text: string, element: LabelTextElement): number;
}
/** The pdfkit standard font a family/style combination prints with. */
export function pdfFontName(
family: LabelFontFamily,
bold: boolean,
italic: boolean,
): string {
const base =
family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica';
if (base === 'Times') {
if (bold && italic) return 'Times-BoldItalic';
if (bold) return 'Times-Bold';
if (italic) return 'Times-Italic';
return 'Times-Roman';
}
const suffix =
bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : '';
return `${base}${suffix}`;
}
/** Height in mm of a glyph class: size in pt x metric / 1000 x 25.4 / 72. */
export function heightMm(fontSizePt: number, metricPer1000: number): number {
return ((fontSizePt * metricPer1000) / 1000) * (25.4 / 72);
}
// pdfkit keeps the loaded font in the private field `_font`; this is the one
// place that reads it. font-metrics.spec.ts pins the values, so a pdfkit
// update that moves or renames the field fails there and nowhere else.
interface PdfDocumentInternals {
_font?: { xHeight?: unknown; capHeight?: unknown };
}
const metricsCache = new Map<string, FontMetrics>();
/** AFM x-height and cap height of a built-in PDF font. */
export function fontMetricsOf(
family: LabelFontFamily,
bold: boolean,
italic: boolean,
): FontMetrics {
const name = pdfFontName(family, bold, italic);
const cached = metricsCache.get(name);
if (cached) {
return cached;
}
const doc = new PDFDocument({ size: 'A4', margin: 0 });
doc.font(name);
const font = (doc as unknown as PdfDocumentInternals)._font;
const { xHeight, capHeight } = font ?? {};
if (
typeof xHeight !== 'number' ||
typeof capHeight !== 'number' ||
!(xHeight > 0) ||
!(capHeight > 0)
) {
throw new Error(`No AFM metrics for font ${name}`);
}
const metrics = { xHeight, capHeight };
metricsCache.set(name, metrics);
return metrics;
}
/** Measures text with a throw-away PDF document; nothing is written out. */
export function pdfTextMeasurer(): TextMeasurer {
const doc = new PDFDocument({ size: 'A4', margin: 0 });
return {
heightOfStringPt(text, element) {
if (text === '') {
return 0;
}
return doc
.font(pdfFontName(element.fontFamily, element.bold, element.italic))
.fontSize(element.fontSize)
.heightOfString(text, { width: element.width * MM_TO_PT });
},
};
}
@@ -0,0 +1,29 @@
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
import { UpdateProductDto } from '../products/dto/update-product.dto';
import { UpdateSiteSettingsDto } from '../site-settings/dto/update-site-settings.dto';
import { describeNullContracts } from '../common/validation/null-contract.spec-helper';
// Only the fields T6 Task 5 adds; the older fields stay in
// update-dto-null-handling.spec.ts.
describeNullContracts('Label particulars null contracts', {
UpdateSiteSettingsDto: {
dto: UpdateSiteSettingsDto,
notNull: [],
clearable: [
'foodBusinessName',
'foodBusinessAddress',
'holderName',
'holderAddress',
],
},
UpdateProductDto: {
dto: UpdateProductDto,
notNull: [],
clearable: ['legalName'],
},
UpdateLabelTemplateDto: {
dto: UpdateLabelTemplateDto,
notNull: ['minXHeightMm'],
clearable: [],
},
});
@@ -0,0 +1,117 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { buildPlaceholderValues, labelDayOf } from './label-templates.service';
const SETTINGS = {
originCountry: 'Deutschland',
foodBusinessName: null,
foodBusinessAddress: null,
};
function values(bestBeforeDate: Date, date = bestBeforeDate) {
return buildPlaceholderValues(
{
jarSize: 500,
lotNumber: '2026-0001',
date,
bestBeforeDate,
honeyBatch: { batchNumber: 'HB-1', honeyType: null },
product: null,
},
SETTINGS,
);
}
// The expected days are literal Berlin days (CET in winter, CEST in summer),
// so the cases hold whatever zone the test process runs in.
describe('label days', () => {
it.each([
['midnight UTC, winter', '2027-03-01T00:00:00.000Z', '2027-03-01'],
['midnight UTC, summer', '2027-07-01T00:00:00.000Z', '2027-07-01'],
['23:30 UTC, winter', '2027-03-01T23:30:00.000Z', '2027-03-02'],
['22:30 UTC, summer', '2027-06-30T22:30:00.000Z', '2027-07-01'],
['21:30 UTC, summer', '2027-06-30T21:30:00.000Z', '2027-06-30'],
['22:30 UTC, winter', '2027-02-28T22:30:00.000Z', '2027-02-28'],
])('%s', (_name, instant, day) => {
expect(labelDayOf(new Date(instant))).toBe(day);
});
it('prints the best before day and line from the same day', () => {
const result = values(new Date('2027-03-01T23:30:00.000Z'));
expect(result.bestBeforeDate).toBe('02.03.2027');
expect(result.bestBeforeLine).toBe('Mindestens haltbar bis 02.03.2027');
});
it('prints the bottling date the same way', () => {
const result = values(
new Date('2028-03-01T00:00:00.000Z'),
new Date('2026-03-01T23:30:00.000Z'),
);
expect(result.bottlingDate).toBe('02.03.2026');
});
it('prints and checks the same day string', () => {
const bestBefore = new Date('2027-03-01T00:00:00.000Z');
const [year, month, day] = labelDayOf(bestBefore).split('-');
expect(values(bestBefore).bestBeforeDate).toBe(`${day}.${month}.${year}`);
});
});
// A jest worker cannot change its own time zone, so the guarantee is by
// construction: the date code must not read or build local-time dates. UTC
// getters (getUTCDate, ...) and formatters with an explicit timeZone are fine.
const FORBIDDEN = new RegExp(
[
'\\.(getDate|getDay|getMonth|getFullYear|getYear|getHours|getMinutes|getSeconds|getMilliseconds|getTimezoneOffset)\\(',
'\\.set(Hours|Date|Month|FullYear|Minutes|Seconds)\\(',
'\\.toDateString\\(',
'toLocaleTimeString\\(',
'toLocaleDateString\\((?![^)]*timeZone)',
'toLocaleString\\((?![^)]*timeZone)',
'new Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
// new Date(y, m, d, ...): several arguments build a local-time date
'new Date\\((?:[^(),]|\\([^()]*\\))+,',
].join('|'),
);
describe('local-time guard', () => {
it.each([
'date.getDate()',
'date.getHours()',
'date.getTimezoneOffset()',
'date.getYear()',
'date.getMilliseconds()',
'date.setHours(0)',
'date.setDate(1)',
'date.toDateString()',
"date.toLocaleDateString('de-DE')",
"new Intl.DateTimeFormat('de-DE')",
'new Date(2027, 2, 1)',
])('flags %s', (sample) => {
expect(sample).toMatch(FORBIDDEN);
});
it.each([
"date.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' })",
"new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' })",
'date.getUTCDate()',
'date.getTime()',
'new Date(Date.UTC(2027, 2, 1))',
'new Date(`${day}T00:00:00.000Z`)',
])('allows %s', (sample) => {
expect(sample).not.toMatch(FORBIDDEN);
});
});
describe('date sources avoid local-time APIs', () => {
it.each([
['label-templates.service.ts', __dirname],
['label-particulars.ts', __dirname],
['label-text.ts', __dirname],
['font-metrics.ts', __dirname],
['dates.ts', join(__dirname, '..', 'common')],
['format-day.ts', join(__dirname, '..', 'common')],
])('%s', (file, dir) => {
expect(readFileSync(join(dir, file), 'utf8')).not.toMatch(FORBIDDEN);
});
});
@@ -10,6 +10,12 @@ export const PLACEHOLDER_KEYS = [
'bestBeforeDate',
'jarSize',
'productName',
'legalName',
'originCountry',
'foodBusinessName',
'foodBusinessAddress',
'bestBeforeLine',
'lotLine',
] as const;
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
@@ -0,0 +1,855 @@
import { heightMm, TextMeasurer } from './font-metrics';
import {
checkLabel,
LabelCheckInput,
LabelFindingCode,
NetQuantityDigitRule,
SAMPLE_BOTTLING,
} from './label-particulars';
import { LabelTextElement, PlaceholderKey } from './label-element.types';
import { buildPlaceholderValues } from './label-templates.service';
// Mirrors the default of ComplianceSettings.netQuantityDigitRules.
const RULES: NetQuantityDigitRule[] = [
{ maxGrams: 50, minMm: 2 },
{ maxGrams: 200, minMm: 3 },
{ maxGrams: 1000, minMm: 4 },
{ maxGrams: null, minMm: 6 },
];
const SETTINGS = {
originCountry: 'Deutschland',
foodBusinessName: 'Imkerei Muster',
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
};
const NO_CLIPPING: TextMeasurer = { heightOfStringPt: () => 0 };
function bottling(overrides: { jarSize?: number } = {}) {
return {
jarSize: overrides.jarSize ?? 500,
lotNumber: '2026-0001',
date: new Date('2026-03-05T00:00:00.000Z'),
bestBeforeDate: new Date('2028-03-05T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-1', honeyType: 'Sommerhonig' },
product: { name: 'Sommerhonig 500 g', legalName: 'Blütenhonig' },
};
}
function textElement(
id: string,
key: PlaceholderKey | null,
y: number,
overrides: Partial<LabelTextElement> = {},
): LabelTextElement {
return {
id,
type: 'text',
x: 2,
y,
width: 80,
height: 10,
text: '',
placeholderKey: key,
fontSize: 10,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
...overrides,
};
}
// 20 pt Helvetica caps are 5.07 mm, above every default rule up to 1000 g.
function fullLabel(): LabelTextElement[] {
return [
textElement('legal', 'legalName', 2),
textElement('net', 'jarSize', 14, { fontSize: 20 }),
textElement('bb', 'bestBeforeLine', 26),
textElement('lot', 'lotLine', 38),
textElement('origin', 'originCountry', 50),
textElement('biz-name', 'foodBusinessName', 62),
textElement('biz-address', 'foodBusinessAddress', 74),
];
}
function input(overrides: Partial<LabelCheckInput> = {}): LabelCheckInput {
const jarSize = 500;
return {
elements: fullLabel(),
minXHeightMm: 1.2,
digitRules: RULES,
values: buildPlaceholderValues(bottling({ jarSize }), SETTINGS),
expected: {
jarSizeGrams: jarSize,
bestBefore: '2028-03-05',
lotNumber: '2026-0001',
originCountry: 'Deutschland',
foodBusinessName: SETTINGS.foodBusinessName,
foodBusinessAddress: SETTINGS.foodBusinessAddress,
},
sample: false,
measurer: NO_CLIPPING,
...overrides,
};
}
function codes(findings: { code: LabelFindingCode }[]): LabelFindingCode[] {
return findings.map((finding) => finding.code);
}
function without(...ids: string[]): LabelTextElement[] {
return fullLabel().filter((element) => !ids.includes(element.id));
}
describe('checkLabel', () => {
it('passes a label with all particulars', () => {
const result = checkLabel(input());
expect(result.gaps).toEqual([]);
expect(result.warnings).toEqual([]);
expect(result.printable).toBe(true);
expect(result.sample).toBe(false);
});
it.each([
['LEGAL_NAME', 'legal'],
['NET_QUANTITY', 'net'],
['BEST_BEFORE', 'bb'],
['LOT', 'lot'],
['ORIGIN', 'origin'],
['FOOD_BUSINESS', 'biz-name'],
] as const)('reports a missing %s by its code', (code, elementId) => {
const elements =
code === 'FOOD_BUSINESS'
? without('biz-name', 'biz-address')
: without(elementId);
const result = checkLabel(input({ elements }));
expect(codes(result.gaps)).toEqual([code]);
expect(result.printable).toBe(false);
expect(result.gaps[0].title).not.toBe('');
expect(result.gaps[0].detail).not.toBe('');
});
it('matches the address across line breaks and separators', () => {
const elements = [
...without('biz-name', 'biz-address'),
textElement('biz', null, 62, {
text: 'Imkerei Muster\nBühlweg 10 · 86450 Altenmünster',
}),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
it('matches an address that sits on two lines of one element', () => {
const elements = [
...without('biz-name', 'biz-address'),
textElement('biz', null, 62, {
text: 'Imkerei Muster, Bühlweg 10\n86450 Altenmünster',
}),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
it('flags a static "500 g" on a 250 g bottling', () => {
const elements = [
...without('net'),
textElement('net', null, 14, { text: 'Inhalt 500 g', fontSize: 20 }),
];
const result = checkLabel(
input({
elements,
values: buildPlaceholderValues(bottling({ jarSize: 250 }), SETTINGS),
expected: { ...input().expected, jarSizeGrams: 250 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
it('accepts 250,5 g for a 250.5 g jar and "1 kg" for a 1000 g jar', () => {
const decimal = checkLabel(
input({
values: { ...input().values, jarSize: '250,5 g' },
expected: { ...input().expected, jarSizeGrams: 250.5 },
}),
);
expect(codes(decimal.gaps)).toEqual([]);
const dot = checkLabel(
input({
values: { ...input().values, jarSize: '250.5 g' },
expected: { ...input().expected, jarSizeGrams: 250.5 },
}),
);
expect(codes(dot.gaps)).toEqual([]);
const kilogram = checkLabel(
input({
values: { ...input().values, jarSize: '1 kg' },
expected: { ...input().expected, jarSizeGrams: 1000 },
}),
);
expect(codes(kilogram.gaps)).toEqual([]);
});
it('does not accept 1250 g for 250 g', () => {
const result = checkLabel(
input({
values: { ...input().values, jarSize: '1250 g' },
expected: { ...input().expected, jarSizeGrams: 250 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
it('does not accept "1 kg" for a jar that is not a full kilogram', () => {
const result = checkLabel(
input({
values: { ...input().values, jarSize: '1 kg' },
expected: { ...input().expected, jarSizeGrams: 1250 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
describe('best before', () => {
function withBestBefore(text: string): LabelTextElement[] {
return [...without('bb'), textElement('bb', null, 26, { text })];
}
it('needs the wording and the date, leading zeros do not matter', () => {
for (const text of [
'Mindestens haltbar bis 05.03.2028',
'Mindestens haltbar bis 5.3.2028',
'mindestens HALTBAR bis 5. 03. 2028',
]) {
expect(
checkLabel(input({ elements: withBestBefore(text) })).gaps,
).toEqual([]);
}
// wording without the date, date without the wording, a wrong date
for (const text of [
'Mindestens haltbar bis',
'MHD 05.03.2028',
'Mindestens haltbar bis 06.03.2028',
'Mindestens haltbar bis 05.03.2029',
]) {
expect(
codes(checkLabel(input({ elements: withBestBefore(text) })).gaps),
).toEqual(['BEST_BEFORE']);
}
});
it('accepts the wording and the date in separate elements', () => {
const elements = [
...without('bb'),
textElement('bb-label', null, 26, { text: 'Mindestens haltbar bis' }),
textElement('bb-date', 'bestBeforeDate', 32),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
});
it('treats clipped text as missing and warns TEXT_CLIPPED', () => {
const measurer: TextMeasurer = {
heightOfStringPt: (_text, element) => (element.id === 'lot' ? 999 : 0),
};
const result = checkLabel(input({ measurer }));
expect(codes(result.gaps)).toEqual(['LOT']);
expect(codes(result.warnings)).toEqual(['TEXT_CLIPPED']);
expect(result.warnings[0].elementIds).toEqual(['lot']);
expect(result.printable).toBe(false);
});
it('clips with the real measurer when the box is too short for the text', () => {
const elements = [
...without('lot'),
textElement('lot', 'lotLine', 38, { height: 1 }),
];
const result = checkLabel(input({ elements, measurer: undefined }));
expect(codes(result.gaps)).toEqual(['LOT']);
expect(codes(result.warnings)).toContain('TEXT_CLIPPED');
});
it('does not warn TEXT_CLIPPED for clipped text that carries nothing', () => {
const measurer: TextMeasurer = {
heightOfStringPt: (_text, element) => (element.id === 'deco' ? 999 : 0),
};
const elements = [
...fullLabel(),
textElement('deco', null, 90, { text: 'Mit Liebe gemacht' }),
];
const result = checkLabel(input({ elements, measurer }));
expect(result.warnings).toEqual([]);
});
describe('FOOD_BUSINESS', () => {
it('is a gap when either master data field is empty', () => {
for (const expected of [
{ foodBusinessName: null },
{ foodBusinessAddress: null },
{ foodBusinessName: ' ' },
{ foodBusinessAddress: '' },
]) {
const result = checkLabel(
input({ expected: { ...input().expected, ...expected } }),
);
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
}
});
it('is a gap when only the name is on the label', () => {
const result = checkLabel(input({ elements: without('biz-address') }));
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
});
});
it('a label without product legalName is complete only with a fixed text containing "honig"', () => {
const noLegalName = {
...input().values,
legalName: '',
};
expect(codes(checkLabel(input({ values: noLegalName })).gaps)).toEqual([
'LEGAL_NAME',
]);
const elements = [
...fullLabel(),
textElement('fixed', null, 90, { text: 'Echter Blütenhonig' }),
];
expect(checkLabel(input({ elements, values: noLegalName })).gaps).toEqual(
[],
);
});
describe('x-height', () => {
function withOtherSize(fontSize: number): LabelTextElement[] {
return [
...without('origin'),
textElement('origin', 'originCountry', 50, { fontSize }),
];
}
it('Helvetica 6 pt is x-height 1.11 mm (warning), 7 pt is 1.29 mm', () => {
const small = checkLabel(input({ elements: withOtherSize(6) }));
expect(small.gaps).toEqual([]);
expect(codes(small.warnings)).toEqual(['X_HEIGHT']);
expect(small.warnings[0].elementIds).toEqual(['origin']);
expect(small.warnings[0].title).toBe('Text zu klein (x-Höhe 1,1 mm)');
expect(small.printable).toBe(true);
const fine = checkLabel(input({ elements: withOtherSize(7) }));
expect(fine.warnings).toEqual([]);
});
it('x-height exactly at the limit is fine', () => {
const result = checkLabel(
input({ elements: withOtherSize(6), minXHeightMm: heightMm(6, 523) }),
);
expect(result.warnings).toEqual([]);
});
it('only checks elements that carry a particular', () => {
const elements = [
...fullLabel(),
textElement('deco', null, 90, { text: 'Mit Liebe', fontSize: 4 }),
];
expect(checkLabel(input({ elements })).warnings).toEqual([]);
});
});
describe('net quantity height', () => {
function withNetSize(fontSize: number): LabelTextElement[] {
return [
...without('net'),
textElement('net', 'jarSize', 14, { fontSize }),
];
}
it('needs 4 mm digits for 500 g with the default rules (15.8 pt)', () => {
const ok = checkLabel(input({ elements: withNetSize(15.8) }));
expect(ok.warnings).toEqual([]);
const small = checkLabel(input({ elements: withNetSize(15.7) }));
expect(codes(small.warnings)).toEqual(['NET_QUANTITY_HEIGHT']);
expect(small.warnings[0].elementIds).toEqual(['net']);
expect(small.printable).toBe(true);
});
it.each([
[50, 2],
[49.5, 2],
[200, 3],
[200.5, 4],
[1000, 4],
[1001, 6],
[5000, 6],
])(
'uses the rule of the matching weight class for %s g (%s mm)',
(grams, minMm) => {
const result = checkLabel(
input({ expected: { ...input().expected, jarSizeGrams: grams } }),
);
expect(result.requirements.netQuantityMinCapHeightMm).toBe(minMm);
},
);
it('uses the open class above the largest weight class', () => {
const result = checkLabel(
input({
values: { ...input().values, jarSize: '2 kg' },
expected: { ...input().expected, jarSizeGrams: 2000 },
}),
);
// 20 pt caps are 5.07 mm, the open class wants 6 mm
expect(codes(result.warnings)).toEqual(['NET_QUANTITY_HEIGHT']);
});
it('is satisfied when one of several mentions is large enough', () => {
const elements = [
...fullLabel(),
textElement('net-small', null, 90, {
text: 'Inhalt 500 g',
fontSize: 8,
}),
];
expect(checkLabel(input({ elements })).warnings).toEqual([]);
});
it('sorts the rules itself and has no requirement without rules', () => {
const shuffled = [RULES[3], RULES[1], RULES[0], RULES[2]];
expect(
checkLabel(input({ digitRules: shuffled })).requirements
.netQuantityMinCapHeightMm,
).toBe(4);
const none = checkLabel(input({ digitRules: [] }));
expect(none.requirements.netQuantityMinCapHeightMm).toBeNull();
expect(none.warnings).toEqual([]);
});
});
describe('lot prefix', () => {
it('warns on a lot without a leading L', () => {
const elements = [
...without('lot'),
textElement('lot', null, 38, { text: 'Los 2026-0001' }),
];
const result = checkLabel(input({ elements }));
expect(result.gaps).toEqual([]);
expect(codes(result.warnings)).toEqual(['LOT_PREFIX']);
});
it('accepts "L 2026-0001" and "L2026-0001"', () => {
for (const text of ['L 2026-0001', 'L2026-0001', 'Ch.-B. L: 2026-0001']) {
const elements = [
...without('lot'),
textElement('lot', null, 38, { text }),
];
expect(codes(checkLabel(input({ elements })).warnings)).not.toContain(
'LOT_PREFIX',
);
}
});
it('does not add LOT_PREFIX when the lot is missing altogether', () => {
const result = checkLabel(input({ elements: without('lot') }));
expect(codes(result.gaps)).toEqual(['LOT']);
expect(result.warnings).toEqual([]);
});
});
it('a warning alone leaves printable true', () => {
const elements = [
...without('origin'),
textElement('origin', 'originCountry', 50, { fontSize: 6 }),
];
const result = checkLabel(input({ elements }));
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.gaps).toEqual([]);
expect(result.printable).toBe(true);
});
it('sample result carries sample true', () => {
expect(checkLabel(input({ sample: true })).sample).toBe(true);
});
it('requirements carry minXHeightMm and the digit height of the jar size', () => {
const result = checkLabel(input({ minXHeightMm: 1.5 }));
expect(result.requirements).toEqual({
minXHeightMm: 1.5,
netQuantityMinCapHeightMm: 4,
});
});
it('an empty label reports every mandatory particular in a fixed order', () => {
const result = checkLabel(input({ elements: [] }));
expect(codes(result.gaps)).toEqual([
'LEGAL_NAME',
'NET_QUANTITY',
'BEST_BEFORE',
'LOT',
'ORIGIN',
'FOOD_BUSINESS',
]);
});
});
describe('checkLabel: robustness against odd layouts', () => {
it('does not take "1,250 g" for 250 g', () => {
const result = checkLabel(
input({
values: { ...input().values, jarSize: '1,250 g' },
expected: { ...input().expected, jarSizeGrams: 250 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
it('does not take "250,5 g" for 5 g, but "Inhalt 6 x 500 g" for 500 g', () => {
expect(
codes(
checkLabel(
input({
values: { ...input().values, jarSize: '250,5 g' },
expected: { ...input().expected, jarSizeGrams: 5 },
}),
).gaps,
),
).toEqual(['NET_QUANTITY']);
expect(
checkLabel(
input({ values: { ...input().values, jarSize: 'Inhalt 6 x 500 g' } }),
).gaps,
).toEqual([]);
});
it('does not warn LOT_PREFIX for a lot that already starts with L', () => {
const lotNumber = 'L-001';
const elements = [
...without('lot'),
textElement('lot', null, 38, { text: 'L-001' }),
];
const result = checkLabel(
input({ elements, expected: { ...input().expected, lotNumber } }),
);
expect(result.gaps).toEqual([]);
expect(codes(result.warnings)).not.toContain('LOT_PREFIX');
});
describe('an address split over elements', () => {
it('is found with another element sorted in between', () => {
const elements = [
...without('biz-name', 'biz-address'),
textElement('biz-1', null, 62, {
text: 'Imkerei Muster, Bühlweg 10',
}),
textElement('between', null, 68, { text: 'Mit Liebe gemacht' }),
textElement('biz-2', null, 74, { text: '86450 Altenmünster' }),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
it('is found when the parts differ slightly in y', () => {
const elements = [
...without('biz-name', 'biz-address'),
textElement('biz-name', 'foodBusinessName', 62),
textElement('biz-right', null, 70, {
x: 40,
text: '86450 Altenmünster',
}),
textElement('biz-left', null, 70.4, { text: 'Bühlweg 10' }),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
it('is still a gap when a part is really missing', () => {
const elements = [
...without('biz-name', 'biz-address'),
textElement('biz-1', null, 62, {
text: 'Imkerei Muster, Bühlweg 10',
}),
textElement('between', null, 68, { text: 'Mit Liebe gemacht' }),
];
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
'FOOD_BUSINESS',
]);
});
});
});
describe('checkLabel: round 2', () => {
describe('the digit-before guard is per element', () => {
it('ignores a lot number element right before the quantity', () => {
const elements = [
...without('lot', 'net'),
textElement('lot', null, 38, { x: 2, width: 30, text: 'L 2026-0001' }),
textElement('net', null, 38, {
x: 40,
width: 30,
fontSize: 20,
text: '500 g',
}),
];
const result = checkLabel(input({ elements }));
expect(codes(result.gaps)).toEqual([]);
});
it('ignores a date element right before the quantity', () => {
const elements = [
...without('bb', 'net'),
textElement('bb', null, 26, {
x: 2,
width: 50,
text: 'Mindestens haltbar bis 05.03.2028',
}),
textElement('net', null, 26, {
x: 60,
width: 20,
fontSize: 20,
text: '500 g',
}),
];
const result = checkLabel(input({ elements }));
expect(codes(result.gaps)).toEqual([]);
});
it('still rejects "1,250 g" inside one element', () => {
const elements = [
...without('net'),
textElement('net', null, 14, { fontSize: 20, text: 'Inhalt 1,250 g' }),
];
const result = checkLabel(
input({
elements,
expected: { ...input().expected, jarSizeGrams: 250 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
});
it('accepts a number glued to its unit ("250g")', () => {
const result = checkLabel(
input({
values: { ...input().values, jarSize: '250g' },
expected: { ...input().expected, jarSizeGrams: 250 },
}),
);
expect(codes(result.gaps)).toEqual([]);
});
describe('a split address is bounded', () => {
function split(
street: { y: number; fontSize?: number },
town: { y: number; fontSize?: number },
): LabelTextElement[] {
return [
...without('biz-name', 'biz-address'),
textElement('biz-name', 'foodBusinessName', 56),
textElement('street', null, street.y, {
text: 'Bühlweg 10',
fontSize: street.fontSize ?? 10,
height: 5,
}),
textElement('town', null, town.y, {
text: '86450 Altenmünster',
fontSize: town.fontSize ?? 10,
height: 5,
}),
];
}
it('is a gap when the town sits in a footer far away', () => {
const result = checkLabel(
input({ elements: split({ y: 62 }, { y: 150 }) }),
);
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
});
it('is found when the town sits directly above the street', () => {
const result = checkLabel(
input({ elements: split({ y: 70 }, { y: 62 }) }),
);
expect(result.gaps).toEqual([]);
});
it('lets the x-height warning reach the parts of the match', () => {
const result = checkLabel(
input({ elements: split({ y: 62 }, { y: 68, fontSize: 6 }) }),
);
expect(result.gaps).toEqual([]);
const warning = result.warnings.find((w) => w.code === 'X_HEIGHT');
expect(warning?.elementIds).toEqual(['town']);
});
it('names a clipped part in the gap and in TEXT_CLIPPED', () => {
const measurer: TextMeasurer = {
heightOfStringPt: (_text, element) => (element.id === 'town' ? 999 : 0),
};
const result = checkLabel(
input({ elements: split({ y: 62 }, { y: 68 }), measurer }),
);
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
expect(result.gaps[0].elementIds).toEqual(['town']);
const clipped = result.warnings.find((w) => w.code === 'TEXT_CLIPPED');
expect(clipped?.elementIds).toEqual(['town']);
});
});
});
describe('checkLabel: round 3', () => {
function addressParts(
street: { y: number; x?: number; text?: string },
town: { y: number; x?: number },
): LabelTextElement[] {
return [
textElement('street', null, street.y, {
x: street.x ?? 2,
text: street.text ?? 'Bühlweg 10',
height: 5,
}),
textElement('town', null, town.y, {
x: town.x ?? 2,
text: '86450 Altenmünster',
height: 5,
}),
];
}
const base = () => [
...without('biz-name', 'biz-address'),
textElement('biz-name', 'foodBusinessName', 56),
];
it('reading order comes from the geometry, not from the array order', () => {
// a far away footer town FIRST in the array is still a gap
const far = [...addressParts({ y: 62 }, { y: 150 }).reverse(), ...base()];
expect(codes(checkLabel(input({ elements: far })).gaps)).toEqual([
'FOOD_BUSINESS',
]);
// street/town in reverse array order but adjacent on the label
const adjacent = [
...addressParts({ y: 62 }, { y: 68 }).reverse(),
...base(),
];
expect(checkLabel(input({ elements: adjacent })).gaps).toEqual([]);
});
it('measures the distance against the whole group, not the last element', () => {
// a tall street box, an empty box below it, the town further down: the
// empty box alone would be 24 mm from the town, the street box is not
const elements = [
...base(),
textElement('street', null, 62, {
text: 'Bühlweg 10',
height: 40,
}),
textElement('empty', null, 64, { text: '', height: 2 }),
textElement('town', null, 90, {
text: '86450 Altenmünster',
height: 5,
}),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
describe('number and unit in two elements', () => {
function split(numberSize: number, unitSize: number): LabelTextElement[] {
return [
...without('net'),
textElement('num', null, 14, {
x: 2,
width: 20,
fontSize: numberSize,
text: '500',
}),
textElement('unit', null, 14, {
x: 24,
width: 10,
fontSize: unitSize,
text: 'g',
}),
];
}
it('is found; the height warning looks at the parts', () => {
const big = checkLabel(input({ elements: split(20, 20) }));
expect(big.gaps).toEqual([]);
expect(codes(big.warnings)).toEqual([]);
const small = checkLabel(input({ elements: split(8, 8) }));
expect(small.gaps).toEqual([]);
const warning = small.warnings.find(
(w) => w.code === 'NET_QUANTITY_HEIGHT',
);
expect(warning?.elementIds).toEqual(['num', 'unit']);
});
it('a big number next to a small unit counts the unit for x-height', () => {
const result = checkLabel(input({ elements: split(20, 6) }));
expect(result.gaps).toEqual([]);
const xHeight = result.warnings.find((w) => w.code === 'X_HEIGHT');
expect(xHeight?.elementIds).toEqual(['unit']);
expect(codes(result.warnings)).not.toContain('NET_QUANTITY_HEIGHT');
});
it('does not join across rows or onto a bigger number', () => {
const otherRow = [
...without('net'),
textElement('num', null, 14, { text: '500' }),
textElement('unit', null, 30, { text: 'g' }),
];
expect(codes(checkLabel(input({ elements: otherRow })).gaps)).toEqual([
'NET_QUANTITY',
]);
const bigger = [
...without('net'),
textElement('num', null, 14, { width: 20, text: '1,500' }),
textElement('unit', null, 14, { x: 24, width: 10, text: 'g' }),
];
const result = checkLabel(
input({
elements: bigger,
expected: { ...input().expected, jarSizeGrams: 500 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
});
it('gives only the elements of the match to the address carriers', () => {
const elements = [
...base(),
textElement('lot-ref', null, 62, {
x: 2,
width: 20,
fontSize: 5,
text: 'Lot 10',
}),
textElement('street', null, 62, {
x: 30,
width: 25,
text: 'Bühlweg 10',
height: 5,
}),
textElement('town', null, 62, {
x: 60,
width: 25,
text: '86450 Altenmünster',
height: 5,
}),
];
const result = checkLabel(input({ elements }));
expect(result.gaps).toEqual([]);
expect(result.warnings.filter((w) => w.code === 'X_HEIGHT')).toEqual([]);
});
});
describe('SAMPLE_BOTTLING', () => {
it('carries example values only for the preview', () => {
expect(SAMPLE_BOTTLING.jarSize).toBe(500);
expect(SAMPLE_BOTTLING.lotNumber).toBe('2026-0001');
const values = buildPlaceholderValues(SAMPLE_BOTTLING, SETTINGS);
expect(values.jarSize).toBe('500 g');
expect(values.lotLine).toBe('L 2026-0001');
});
});
@@ -0,0 +1,638 @@
import {
fontMetricsOf,
heightMm,
MM_TO_PT,
pdfTextMeasurer,
TextMeasurer,
} from './font-metrics';
import { LabelTextElement, PlaceholderKey } from './label-element.types';
import {
lotStartsWithL,
normalizeForCheck,
resolvedTexts,
ROW_TOLERANCE_MM,
} from './label-text';
export type LabelFindingCode =
| 'LEGAL_NAME'
| 'NET_QUANTITY'
| 'BEST_BEFORE'
| 'LOT'
| 'ORIGIN'
| 'FOOD_BUSINESS'
| 'LOT_PREFIX'
| 'X_HEIGHT'
| 'NET_QUANTITY_HEIGHT'
| 'TEXT_CLIPPED';
export interface LabelFinding {
code: LabelFindingCode;
title: string;
detail: string;
elementIds: string[];
}
export interface LabelCheckResult {
printable: boolean;
gaps: LabelFinding[];
warnings: LabelFinding[];
sample: boolean;
/** The legal values the check used, so the frontend needs no own copy. */
requirements: {
minXHeightMm: number;
netQuantityMinCapHeightMm: number | null;
};
}
export interface NetQuantityDigitRule {
maxGrams: number | null;
minMm: number;
}
export interface LabelCheckInput {
elements: readonly LabelTextElement[];
minXHeightMm: number;
digitRules: readonly NetQuantityDigitRule[];
values: Record<PlaceholderKey, string>;
expected: {
jarSizeGrams: number;
/** YYYY-MM-DD */
bestBefore: string;
lotNumber: string;
originCountry: string;
foodBusinessName: string | null;
foodBusinessAddress: string | null;
};
sample: boolean;
measurer?: TextMeasurer;
}
/**
* Example values for the preview when no bottling is chosen. They only fill
* the template so it can be shown; they are not legal values.
*/
export const SAMPLE_BOTTLING = Object.freeze({
jarSize: 500 as const,
lotNumber: '2026-0001' as const,
date: new Date('2026-07-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-07-01T00:00:00.000Z'),
honeyBatch: Object.freeze({
batchNumber: 'HB-2026-001',
honeyType: 'Sommerhonig',
}),
product: Object.freeze({
name: 'Sommerhonig 500 g',
legalName: 'Sommerhonig',
}),
});
// A line that is at most half a point too tall still prints (spec 5.4).
const CLIP_TOLERANCE_PT = 0.5;
// Floating point slack for the "at least" comparisons.
const HEIGHT_EPSILON_MM = 1e-9;
const BEST_BEFORE_WORDING = 'mindestens haltbar bis';
const LEGAL_NAME_WORD = 'honig';
interface Candidate {
element: LabelTextElement;
normalized: string;
clipped: boolean;
}
interface Particular {
code: Extract<
LabelFindingCode,
| 'LEGAL_NAME'
| 'NET_QUANTITY'
| 'BEST_BEFORE'
| 'LOT'
| 'ORIGIN'
| 'FOOD_BUSINESS'
>;
/** Is the particular on the label, judged on the whole joined text? */
isOnLabel(views: TextViews): boolean;
/** Phrases that may be spread over several elements. */
phrases?: string[];
/** Matches spread over two elements, e.g. "500" | "g" (number first). */
pairs?(views: TextViews): Candidate[][];
/** Does one element's text hold (a part of) the particular? */
isCarriedBy(normalized: string): boolean;
missing: { title: string; detail: string };
}
/**
* The label text in the ways a particular may be spread over elements: every
* element alone, all in reading order, row by row, and chains of nearby
* elements that share a word with the phrase looked for.
*/
interface TextViews {
visible: Candidate[];
/** Normalized text of each visible element on its own. */
elements: string[];
full: string;
/** Reading order, cut where two neighbours are far apart. */
blocks: Candidate[][];
rows: Candidate[][];
chains(normalizedPhrase: string): Candidate[][];
}
// Parts of a split phrase must be at most this far apart (edge to edge).
const MAX_JOIN_GAP_MM = 10;
function hasPhrase(
normalizedText: string,
normalizedPhrase: string,
notAfterDigit = false,
): boolean {
if (normalizedPhrase === '') {
return false;
}
const padded = ` ${normalizedText} `;
const needle = ` ${normalizedPhrase} `;
let index = padded.indexOf(needle);
while (index !== -1) {
if (!notAfterDigit) {
return true;
}
// "1,250 g" is not "250 g": a number token right before the amount means
// the amount is only the tail of a bigger number. Only ever applied to
// one element's text: a digit from a neighbouring element (lot, date)
// says nothing about the amount.
const before = padded.slice(0, index).split(' ').pop() ?? '';
if (!/^\d+$/.test(before)) {
return true;
}
index = padded.indexOf(needle, index + 1);
}
return false;
}
function joined(candidates: readonly Candidate[]): string {
return candidates
.map((candidate) => candidate.normalized)
.filter((text) => text !== '')
.join(' ');
}
function sharesWord(normalizedText: string, normalizedPhrase: string): boolean {
const words = new Set(normalizedPhrase.split(' '));
return normalizedText.split(' ').some((word) => words.has(word));
}
interface Span {
top: number;
bottom: number;
}
// Symmetric vertical distance of an element to a span of elements: 0 when
// they overlap vertically, whichever lies above.
function distanceTo(span: Span, candidate: Candidate): number {
const { y, height } = candidate.element;
return Math.max(0, y - span.bottom, span.top - (y + height));
}
/**
* Cuts a list (already in reading order) wherever the next element is more
* than MAX_JOIN_GAP_MM away from everything gathered so far in the group
* (the running top and bottom of the group, not only the previous element).
*
* Known limits, accepted: horizontal distance is ignored, and an unrelated
* element that happens to share a word with the phrase can sit inside a chain.
*/
function groupByDistance(list: Candidate[]): Candidate[][] {
const groups: Candidate[][] = [];
let span: Span = { top: 0, bottom: 0 };
for (const candidate of list) {
const group = groups[groups.length - 1];
if (group && distanceTo(span, candidate) <= MAX_JOIN_GAP_MM) {
group.push(candidate);
span = {
top: Math.min(span.top, candidate.element.y),
bottom: Math.max(
span.bottom,
candidate.element.y + candidate.element.height,
),
};
} else {
groups.push([candidate]);
span = {
top: candidate.element.y,
bottom: candidate.element.y + candidate.element.height,
};
}
}
return groups;
}
function textViews(visible: Candidate[]): TextViews {
const rows: Candidate[][] = [];
let rowTop = Number.NEGATIVE_INFINITY;
for (const candidate of visible) {
if (candidate.element.y - rowTop > ROW_TOLERANCE_MM) {
rows.push([]);
rowTop = candidate.element.y;
}
rows[rows.length - 1].push(candidate);
}
return {
visible,
elements: visible.map((candidate) => candidate.normalized),
full: joined(visible),
blocks: groupByDistance(visible),
rows,
chains: (phrase) =>
groupByDistance(
visible.filter((candidate) => sharesWord(candidate.normalized, phrase)),
),
};
}
/**
* The elements of `group` that make up the first occurrence of the phrase in
* their joined text, or null. Only elements overlapping the occurrence
* count, not neighbours that merely sit in the same group.
*/
function matchParts(group: Candidate[], phrase: string): Candidate[] | null {
const parts = group.filter((candidate) => candidate.normalized !== '');
const text = parts.map((candidate) => candidate.normalized).join(' ');
const start = ` ${text} `.indexOf(` ${phrase} `);
if (phrase === '' || start === -1) {
return null;
}
const end = start + phrase.length;
let offset = 0;
const hit: Candidate[] = [];
for (const part of parts) {
const partEnd = offset + part.normalized.length;
if (offset < end && partEnd > start) {
hit.push(part);
}
offset = partEnd + 1;
}
return hit;
}
/**
* The parts of every group whose joined text holds the phrase: a block of
* neighbours in reading order, one row, or a chain of nearby related elements
* (a chain also in reverse order: a short address printed town-first is still
* printed together).
*/
function groupsHolding(views: TextViews, phrase: string): Candidate[][] {
const forward = [...views.blocks, ...views.rows, ...views.chains(phrase)];
const reversed = views.chains(phrase).map((chain) => [...chain].reverse());
return [...forward, ...reversed]
.map((group) => matchParts(group, phrase))
.filter((parts): parts is Candidate[] => parts !== null);
}
/**
* "500" | "g": an element ending in the amount (not glued to a bigger
* number) followed, in the same row, by an element starting with the unit.
*/
function quantityPairs(views: TextViews, needles: string[]): Candidate[][] {
const pairs: Candidate[][] = [];
views.visible.forEach((first, index) => {
const second = views.visible[index + 1];
if (
!second ||
Math.abs(second.element.y - first.element.y) > ROW_TOLERANCE_MM
) {
return;
}
const tokens = first.normalized.split(' ');
const unitStart = second.normalized.split(' ')[0];
for (const needle of needles) {
const words = needle.split(' ');
const unit = words.pop();
const amount = tokens.slice(-words.length);
const before = tokens[tokens.length - words.length - 1] ?? '';
if (
words.length > 0 &&
unit === unitStart &&
amount.join(' ') === words.join(' ') &&
!/^\d+$/.test(before)
) {
pairs.push([first, second]);
return;
}
}
});
return pairs;
}
function formatDecimal(value: number): string {
return value.toFixed(1).replace('.', ',');
}
function bestBeforeDatePattern(bestBefore: string): RegExp {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(bestBefore);
if (!match) {
throw new Error('bestBefore must be a YYYY-MM-DD date');
}
const [, year, month, day] = match;
return new RegExp(`(^| )0?${Number(day)} 0?${Number(month)} ${year}( |$)`);
}
function netQuantityNeedles(jarSizeGrams: number): string[] {
const needles = [normalizeForCheck(`${jarSizeGrams} g`)];
if (jarSizeGrams > 0 && jarSizeGrams % 1000 === 0) {
needles.push(normalizeForCheck(`${jarSizeGrams / 1000} kg`));
}
return needles;
}
function particulars(expected: LabelCheckInput['expected']): Particular[] {
const netNeedles = netQuantityNeedles(expected.jarSizeGrams);
const datePattern = bestBeforeDatePattern(expected.bestBefore);
const lot = normalizeForCheck(expected.lotNumber);
const origin = normalizeForCheck(expected.originCountry);
const businessName = normalizeForCheck(expected.foodBusinessName ?? '');
const businessAddress = normalizeForCheck(expected.foodBusinessAddress ?? '');
const hasBusinessData = businessName !== '' && businessAddress !== '';
const hasHoneyWord = (text: string) =>
text.split(' ').some((word) => word.includes(LEGAL_NAME_WORD));
return [
{
code: 'LEGAL_NAME',
isOnLabel: (views) => hasHoneyWord(views.full),
isCarriedBy: hasHoneyWord,
missing: {
title: 'Verkehrsbezeichnung fehlt',
detail:
'Die Bezeichnung des Lebensmittels fehlt. Sie muss das Wort „Honig“ enthalten, zum Beispiel „Blütenhonig“. Tragen Sie sie am Artikel ein oder setzen Sie einen festen Text.',
},
},
{
code: 'NET_QUANTITY',
pairs: (views) => quantityPairs(views, netNeedles),
isOnLabel: (views) =>
views.elements.some((text) =>
netNeedles.some((needle) => hasPhrase(text, needle, true)),
) || quantityPairs(views, netNeedles).length > 0,
isCarriedBy: (text) =>
netNeedles.some((needle) => hasPhrase(text, needle, true)),
missing: {
title: 'Füllmenge fehlt',
detail: `Die Füllmenge dieser Abfüllung (${String(expected.jarSizeGrams).replace('.', ',')} g) steht nicht auf dem Etikett. Ein fester Text mit einer anderen Menge zählt nicht.`,
},
},
{
code: 'BEST_BEFORE',
isOnLabel: ({ full }) =>
hasPhrase(full, BEST_BEFORE_WORDING) && datePattern.test(full),
isCarriedBy: (text) =>
hasPhrase(text, BEST_BEFORE_WORDING) || datePattern.test(text),
missing: {
title: 'Mindesthaltbarkeitsdatum fehlt',
detail:
'Es braucht den Text „Mindestens haltbar bis“ und das Datum dieser Abfüllung.',
},
},
{
code: 'LOT',
isOnLabel: (views) => hasPhrase(views.full, lot),
isCarriedBy: (text) => hasPhrase(text, lot),
missing: {
title: 'Losnummer fehlt',
detail: 'Die Losnummer dieser Abfüllung steht nicht auf dem Etikett.',
},
},
{
code: 'ORIGIN',
isOnLabel: (views) => hasPhrase(views.full, origin),
isCarriedBy: (text) => hasPhrase(text, origin),
missing: {
title: 'Herkunftsland fehlt',
detail:
'Das Herkunftsland aus den Einstellungen steht nicht auf dem Etikett.',
},
},
{
code: 'FOOD_BUSINESS',
phrases: [businessName, businessAddress],
isOnLabel: (views) =>
hasBusinessData &&
groupsHolding(views, businessName).length > 0 &&
groupsHolding(views, businessAddress).length > 0,
isCarriedBy: (text) =>
hasPhrase(text, businessName) || hasPhrase(text, businessAddress),
missing: {
title: 'Name und Anschrift fehlen',
detail: hasBusinessData
? 'Name und Anschrift des Lebensmittelunternehmers aus den Einstellungen stehen nicht vollständig auf dem Etikett.'
: 'Name und Anschrift des Lebensmittelunternehmers sind in den Einstellungen nicht eingetragen.',
},
},
];
}
function candidatesOf(
input: LabelCheckInput,
measurer: TextMeasurer,
): Candidate[] {
return resolvedTexts(input.elements, input.values).map(
({ element, text }) => {
const normalized = normalizeForCheck(text);
const clipped =
normalized !== '' &&
measurer.heightOfStringPt(text, element) >
element.height * MM_TO_PT + CLIP_TOLERANCE_PT;
return { element, normalized, clipped };
},
);
}
function ruleFor(
rules: readonly NetQuantityDigitRule[],
grams: number,
): NetQuantityDigitRule | null {
const ordered = [...rules].sort(
(a, b) =>
(a.maxGrams ?? Number.POSITIVE_INFINITY) -
(b.maxGrams ?? Number.POSITIVE_INFINITY),
);
return (
ordered.find((rule) => rule.maxGrams === null || grams <= rule.maxGrams) ??
null
);
}
function unique(ids: string[]): string[] {
return [...new Set(ids)];
}
function xHeightFinding(
carriers: Candidate[],
minXHeightMm: number,
): LabelFinding | null {
const measured = carriers.map(({ element }) => ({
id: element.id,
xHeightMm: heightMm(
element.fontSize,
fontMetricsOf(element.fontFamily, element.bold, element.italic).xHeight,
),
}));
const tooSmall = measured.filter(
(entry) => entry.xHeightMm < minXHeightMm - HEIGHT_EPSILON_MM,
);
if (tooSmall.length === 0) {
return null;
}
const smallest = Math.min(...tooSmall.map((entry) => entry.xHeightMm));
return {
code: 'X_HEIGHT',
title: `Text zu klein (x-Höhe ${formatDecimal(smallest)} mm)`,
detail: `Pflichtangaben brauchen eine x-Höhe von mindestens ${formatDecimal(minXHeightMm)} mm. Schrift vergrößern.`,
elementIds: unique(tooSmall.map((entry) => entry.id)),
};
}
function netQuantityHeightFinding(
carriers: Candidate[],
minMm: number | null,
): LabelFinding | null {
if (minMm === null || carriers.length === 0) {
return null;
}
const tallest = Math.max(
...carriers.map(({ element }) =>
heightMm(
element.fontSize,
fontMetricsOf(element.fontFamily, element.bold, element.italic)
.capHeight,
),
),
);
if (tallest >= minMm - HEIGHT_EPSILON_MM) {
return null;
}
return {
code: 'NET_QUANTITY_HEIGHT',
title: `Füllmenge zu klein (Ziffernhöhe ${formatDecimal(tallest)} mm)`,
detail: `Für diese Füllmenge sind Ziffern von mindestens ${formatDecimal(minMm)} mm Höhe nötig. Schrift der Füllmenge vergrößern.`,
elementIds: unique(carriers.map(({ element }) => element.id)),
};
}
/**
* Checks the label's text elements against the mandatory particulars for one
* bottling. Pure: the caller resolves the legal values (settings, digit
* rules) and the expected facts. Text that does not fit its box counts as
* missing; the check says so with TEXT_CLIPPED.
*/
export function checkLabel(input: LabelCheckInput): LabelCheckResult {
const measurer = input.measurer ?? pdfTextMeasurer();
const candidates = candidatesOf(input, measurer);
const visible = candidates.filter((candidate) => !candidate.clipped);
const views = textViews(visible);
const combined = views.full;
const gaps: LabelFinding[] = [];
const carriers = new Set<Candidate>();
const clippedCarriers = new Set<Candidate>();
let netQuantityCarriers: Candidate[] = [];
let netQuantityFound = false;
let lotFound = false;
let lotCarriers: Candidate[] = [];
for (const particular of particulars(input.expected)) {
const isOnLabel = particular.isOnLabel(views);
// Phrases no single element holds are spread over several: the parts that
// make up the match carry the particular, and so do clipped parts.
const splitPhrases = (particular.phrases ?? []).filter(
(phrase) =>
phrase !== '' &&
!visible.some((candidate) => hasPhrase(candidate.normalized, phrase)),
);
const splitParts = isOnLabel
? splitPhrases.flatMap((phrase) => groupsHolding(views, phrase).flat())
: [];
const pairs = particular.pairs?.(views) ?? [];
const singleHolders = visible.filter((candidate) =>
particular.isCarriedBy(candidate.normalized),
);
const holders = [
...new Set([...singleHolders, ...splitParts, ...pairs.flat()]),
];
holders.forEach((candidate) => carriers.add(candidate));
const clippedHolders = candidates.filter(
(candidate) =>
candidate.clipped &&
(particular.isCarriedBy(candidate.normalized) ||
splitPhrases.some((phrase) =>
sharesWord(candidate.normalized, phrase),
)),
);
clippedHolders.forEach((candidate) => clippedCarriers.add(candidate));
if (!isOnLabel) {
gaps.push({
code: particular.code,
...particular.missing,
elementIds: unique(clippedHolders.map(({ element }) => element.id)),
});
}
if (particular.code === 'NET_QUANTITY') {
netQuantityFound = isOnLabel;
netQuantityCarriers = [...new Set([...singleHolders, ...pairs.flat()])];
}
if (particular.code === 'LOT') {
lotFound = isOnLabel;
lotCarriers = holders;
}
}
const warnings: LabelFinding[] = [];
if (lotFound) {
const lot = normalizeForCheck(input.expected.lotNumber);
if (
!lotStartsWithL(input.expected.lotNumber) &&
!new RegExp(`(^| )l ?${lot}( |$)`).test(combined)
) {
warnings.push({
code: 'LOT_PREFIX',
title: 'Losnummer ohne „L“',
detail:
'Vor der Losnummer sollte der Buchstabe „L“ stehen, zum Beispiel „L 2026-0001“.',
elementIds: unique(lotCarriers.map(({ element }) => element.id)),
});
}
}
const xHeight = xHeightFinding([...carriers], input.minXHeightMm);
if (xHeight) {
warnings.push(xHeight);
}
const digitRule = ruleFor(input.digitRules, input.expected.jarSizeGrams);
const netQuantityHeight = netQuantityFound
? netQuantityHeightFinding(netQuantityCarriers, digitRule?.minMm ?? null)
: null;
if (netQuantityHeight) {
warnings.push(netQuantityHeight);
}
if (clippedCarriers.size > 0) {
warnings.push({
code: 'TEXT_CLIPPED',
title: 'Text wird abgeschnitten',
detail:
'Der Text passt nicht in sein Feld und zählt deshalb nicht. Feld vergrößern oder Schrift verkleinern.',
elementIds: unique([...clippedCarriers].map(({ element }) => element.id)),
});
}
return {
printable: gaps.length === 0,
gaps,
warnings,
sample: input.sample,
requirements: {
minXHeightMm: input.minXHeightMm,
netQuantityMinCapHeightMm: digitRule?.minMm ?? null,
},
};
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { SiteSettingsModule } from '../site-settings/site-settings.module';
import { LabelTemplatesController } from './label-templates.controller';
import { LabelTemplatesService } from './label-templates.service';
@Module({
imports: [SiteSettingsModule],
controllers: [LabelTemplatesController],
providers: [LabelTemplatesService],
exports: [LabelTemplatesService],
@@ -5,8 +5,20 @@ import {
} from './label-templates.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { SiteSettingsService } from '../site-settings/site-settings.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { LabelElement } from './label-element.types';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const SETTINGS = {
originCountry: 'Deutschland',
foodBusinessName: 'Imkerei Muster' as string | null,
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster' as string | null,
};
describe('LabelTemplatesService', () => {
let service: LabelTemplatesService;
let tx: {
@@ -14,8 +26,11 @@ describe('LabelTemplatesService', () => {
updateMany: jest.Mock;
create: jest.Mock;
update: jest.Mock;
findUnique: jest.Mock;
};
};
let siteSettings: { get: jest.Mock };
const order: string[] = [];
let prisma: {
labelTemplate: {
findMany: jest.Mock;
@@ -78,11 +93,21 @@ describe('LabelTemplatesService', () => {
};
beforeEach(() => {
order.length = 0;
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
order.push('lock');
return Promise.resolve(true);
});
siteSettings = { get: jest.fn().mockResolvedValue(SETTINGS) };
tx = {
labelTemplate: {
updateMany: jest.fn().mockResolvedValue({}),
create: jest.fn(),
update: jest.fn(),
findUnique: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve(template);
}),
},
};
prisma = {
@@ -100,6 +125,7 @@ describe('LabelTemplatesService', () => {
service = new LabelTemplatesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
siteSettings as unknown as SiteSettingsService,
);
});
@@ -140,6 +166,52 @@ describe('LabelTemplatesService', () => {
});
});
describe('update', () => {
it('reads before after locking the LabelTemplate row', async () => {
tx.labelTemplate.update.mockImplementation(() => {
order.push('update');
return Promise.resolve({ ...template, name: 'Neu' });
});
await service.update('template-1', { name: 'Neu' }, 'user-1');
expect(lockRowForUpdate).toHaveBeenCalledWith(
tx,
'LabelTemplate',
'template-1',
);
expect(order).toEqual(['lock', 'read', 'update']);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: template,
afterData: { ...template, name: 'Neu' },
}),
tx,
);
});
it('throws NotFoundException for a missing template', async () => {
(lockRowForUpdate as jest.Mock).mockResolvedValueOnce(false);
await expect(
service.update('missing', { name: 'Neu' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.labelTemplate.update).not.toHaveBeenCalled();
});
it('clears other defaults when isDefault is set', async () => {
tx.labelTemplate.update.mockResolvedValue(template);
await service.update('template-1', { isDefault: true }, 'user-1');
expect(tx.labelTemplate.updateMany).toHaveBeenCalledWith({
where: { id: { not: 'template-1' } },
data: { isDefault: false },
});
});
});
describe('deactivate', () => {
it('sets active and isDefault to false, records DEACTIVATE', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(template);
@@ -426,28 +498,62 @@ describe('LabelTemplatesService', () => {
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' },
product: {
name: 'Glas 500g',
legalName: 'Blütenhonig' as string | null,
},
};
it('prints the jar size in grams, the unit honey is sold by', () => {
expect(buildPlaceholderValues(bottlingBatch).jarSize).toBe('500 g');
});
it('never labels the jar size as a volume', () => {
expect(buildPlaceholderValues(bottlingBatch).jarSize).not.toMatch(
/\b(ml|l|cl)\b/,
expect(buildPlaceholderValues(bottlingBatch, SETTINGS).jarSize).toBe(
'500 g',
);
});
it('never labels the jar size as a volume', () => {
expect(
buildPlaceholderValues(bottlingBatch, SETTINGS).jarSize,
).not.toMatch(/\b(ml|l|cl)\b/);
});
it('leaves an unset honey type and product as empty text, not "null"', () => {
const values = buildPlaceholderValues({
...bottlingBatch,
honeyBatch: { batchNumber: 'HB-001', honeyType: null },
product: null,
});
const values = buildPlaceholderValues(
{
...bottlingBatch,
honeyBatch: { batchNumber: 'HB-001', honeyType: null },
product: null,
},
SETTINGS,
);
expect(values.honeyType).toBe('');
expect(values.productName).toBe('');
expect(values.legalName).toBe('');
});
it('fills the particulars from the product and the settings', () => {
const values = buildPlaceholderValues(bottlingBatch, SETTINGS);
expect(values.legalName).toBe('Blütenhonig');
expect(values.originCountry).toBe('Deutschland');
expect(values.foodBusinessName).toBe('Imkerei Muster');
expect(values.foodBusinessAddress).toBe('Bühlweg 10, 86450 Altenmünster');
expect(values.bestBeforeLine).toBe('Mindestens haltbar bis 01.06.2028');
expect(values.lotLine).toBe('L-001');
});
it('leaves unset master data as empty text, not "null"', () => {
const values = buildPlaceholderValues(
{
...bottlingBatch,
product: { name: 'Glas 500g', legalName: null },
},
{ ...SETTINGS, foodBusinessName: null, foodBusinessAddress: null },
);
expect(values.legalName).toBe('');
expect(values.foodBusinessName).toBe('');
expect(values.foodBusinessAddress).toBe('');
});
});
});
@@ -2,19 +2,20 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import * as QRCode from 'qrcode';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import { SiteSettingsService } from '../site-settings/site-settings.service';
import { matchesFileSignature } from '../common/file-signature';
import { LabelTemplate } from '../../generated/prisma';
import { computeCropMarks } from './crop-marks';
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
import {
LabelElement,
LabelFontFamily,
PlaceholderKey,
} from './label-element.types';
import { berlinDayOf } from '../common/dates';
import { formatDayDe } from '../common/format-day';
import { MM_TO_PT, pdfFontName } from './font-metrics';
import { lotStartsWithL } from './label-text';
import { LabelElement, PlaceholderKey } from './label-element.types';
const MM_TO_PT = 72 / 25.4;
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const QR_SOURCE_SIZE_PX = 400;
@@ -35,15 +36,18 @@ interface SheetLayout {
// not to show if the blade lands a fraction beside it.
const CROP_MARK_LINE_WIDTH_PT = 0.3;
// '2-digit' forces a leading zero for day/month (e.g. "05.03.2026") - the
// default 'numeric' formatting drops it ("5.3.2026"), which is what the MHD
// on a printed label was showing.
/**
* The calendar day a stored date prints and is checked as (YYYY-MM-DD). Dates
* are midnight-UTC columns or instants; both map to their Europe/Berlin day,
* independent of the process time zone. The label text and the label check
* (`expected.bestBefore`) must both come from this function.
*/
export function labelDayOf(date: Date): string {
return berlinDayOf(date);
}
function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
return formatDayDe(labelDayOf(date), true);
}
// Prisma stores `elements` as a plain Json column; the shape is only ever
@@ -53,24 +57,6 @@ function elementsOf(template: LabelTemplate): LabelElement[] {
return template.elements as unknown as LabelElement[];
}
function pdfFontName(
family: LabelFontFamily,
bold: boolean,
italic: boolean,
): string {
const base =
family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica';
if (base === 'Times') {
if (bold && italic) return 'Times-BoldItalic';
if (bold) return 'Times-Bold';
if (italic) return 'Times-Italic';
return 'Times-Roman';
}
const suffix =
bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : '';
return `${base}${suffix}`;
}
interface GridFit {
columns: number;
rows: number;
@@ -119,22 +105,38 @@ function dataUrlToBuffer(dataUrl: string): Buffer | null {
* grams everywhere else in the app, and honey is a food sold by mass, so a
* label stating millilitres declares the wrong net quantity.
*/
export function buildPlaceholderValues(bottlingBatch: {
jarSize: number;
lotNumber: string;
date: Date;
bestBeforeDate: Date;
honeyBatch: { batchNumber: string; honeyType: string | null };
product: { name: string } | null;
}): Record<PlaceholderKey, string> {
export function buildPlaceholderValues(
bottlingBatch: {
jarSize: number;
lotNumber: string;
date: Date;
bestBeforeDate: Date;
honeyBatch: { batchNumber: string; honeyType: string | null };
product: { name: string; legalName: string | null } | null;
},
settings: {
originCountry: string;
foodBusinessName: string | null;
foodBusinessAddress: string | null;
},
): Record<PlaceholderKey, string> {
const bestBeforeDate = formatDate(bottlingBatch.bestBeforeDate);
return {
batchNumber: bottlingBatch.honeyBatch.batchNumber,
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
lotNumber: bottlingBatch.lotNumber,
bottlingDate: formatDate(bottlingBatch.date),
bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate),
bestBeforeDate,
jarSize: `${bottlingBatch.jarSize} g`,
productName: bottlingBatch.product?.name ?? '',
legalName: bottlingBatch.product?.legalName ?? '',
originCountry: settings.originCountry,
foodBusinessName: settings.foodBusinessName ?? '',
foodBusinessAddress: settings.foodBusinessAddress ?? '',
bestBeforeLine: `Mindestens haltbar bis ${bestBeforeDate}`,
lotLine: lotStartsWithL(bottlingBatch.lotNumber)
? bottlingBatch.lotNumber
: `L ${bottlingBatch.lotNumber}`,
};
}
@@ -143,6 +145,7 @@ export class LabelTemplatesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly siteSettings: SiteSettingsService,
) {}
async findAll(): Promise<LabelTemplate[]> {
@@ -189,28 +192,39 @@ export class LabelTemplatesService {
dto: UpdateLabelTemplateDto,
actingUserId: string,
): Promise<LabelTemplate> {
const before = await this.findById(id);
const template = await this.prisma.$transaction(async (tx) => {
return this.prisma.$transaction(async (tx) => {
// "before" is read after the lock: two concurrent edits queue up and the
// audit entry shows the state this edit really replaced.
if (!(await lockRowForUpdate(tx, 'LabelTemplate', id))) {
throw new NotFoundException('Label template not found');
}
const before = await tx.labelTemplate.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('Label template not found');
}
if (dto.isDefault) {
await tx.labelTemplate.updateMany({
where: { id: { not: id } },
data: { isDefault: false },
});
}
return tx.labelTemplate.update({
const template = await tx.labelTemplate.update({
where: { id },
data: { ...dto, elements: dto.elements as never },
});
await this.auditLog.record(
{
entityType: 'LabelTemplate',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: template,
},
tx,
);
return template;
});
await this.auditLog.record({
entityType: 'LabelTemplate',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: template,
});
return template;
}
async deactivate(id: string, actingUserId: string): Promise<LabelTemplate> {
@@ -244,7 +258,10 @@ export class LabelTemplatesService {
throw new NotFoundException('Bottling batch not found');
}
const values = buildPlaceholderValues(bottlingBatch);
const values = buildPlaceholderValues(
bottlingBatch,
await this.siteSettings.get(),
);
const elements = elementsOf(template).map((element) =>
element.type === 'text' && element.placeholderKey
@@ -0,0 +1,92 @@
import { LabelTextElement } from './label-element.types';
import { normalizeForCheck, resolvedTexts } from './label-text';
import { PLACEHOLDER_KEYS, PlaceholderKey } from './label-element.types';
function textElement(
id: string,
x: number,
y: number,
overrides: Partial<LabelTextElement> = {},
): LabelTextElement {
return {
id,
type: 'text',
x,
y,
width: 50,
height: 10,
text: id,
fontSize: 10,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
...overrides,
};
}
const values = Object.fromEntries(
PLACEHOLDER_KEYS.map((key) => [key, `value-${key}`]),
) as Record<PlaceholderKey, string>;
describe('normalizeForCheck', () => {
it('normalizes umlauts, NFKC compatibility characters and punctuation', () => {
expect(normalizeForCheck('Bühlweg 10, 86450 Altenmünster')).toBe(
'bühlweg 10 86450 altenmünster',
);
expect(normalizeForCheck('Bühlweg 10 · 86450\nAltenmünster')).toBe(
'bühlweg 10 86450 altenmünster',
);
// decomposed u + combining diaeresis equals the precomposed letter
expect(normalizeForCheck('Bu\u0308hlweg')).toBe('bühlweg');
// full-width digits, ligature, non-breaking space
expect(normalizeForCheck('500\u00a0g fix')).toBe('500 g fix');
});
it('splits a number glued to its unit', () => {
expect(normalizeForCheck('250g')).toBe('250 g');
expect(normalizeForCheck('L2026-0001')).toBe('l2026 0001');
});
it('collapses runs of separators and trims', () => {
expect(normalizeForCheck(' --L 2026-0001.. ')).toBe('l 2026 0001');
expect(normalizeForCheck('...')).toBe('');
});
});
describe('resolvedTexts', () => {
it('sorts elements top first, then left', () => {
const result = resolvedTexts(
[
textElement('c', 30, 20),
textElement('b', 30, 5),
textElement('a', 5, 5),
textElement('d', 5, 20),
],
values,
);
expect(result.map((entry) => entry.element.id)).toEqual([
'a',
'b',
'd',
'c',
]);
});
it('substitutes the placeholder value and keeps fixed text', () => {
const result = resolvedTexts(
[
textElement('p', 0, 0, { placeholderKey: 'lotLine', text: 'ignored' }),
textElement('f', 0, 10, { text: 'Honig' }),
textElement('n', 0, 20, { placeholderKey: null, text: 'Fix' }),
],
values,
);
expect(result.map((entry) => entry.text)).toEqual([
'value-lotLine',
'Honig',
'Fix',
]);
});
});
@@ -0,0 +1,65 @@
import { LabelTextElement, PlaceholderKey } from './label-element.types';
/**
* Text as the label check compares it: NFKC, lower case, every character that
* is not a letter or digit becomes a space, runs of spaces collapse. So
* "Bühlweg 10, 86450 Altenmünster" also matches "Bühlweg 10 · 86450\nAltenmünster".
*/
export function normalizeForCheck(text: string): string {
return (
text
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
// "250g" -> "250 g": a number glued to its unit
.replace(/(\p{N})(?=\p{L})/gu, '$1 ')
.trim()
);
}
// Elements whose tops are at most this far apart count as one row.
export const ROW_TOLERANCE_MM = 1;
export interface ResolvedText {
element: LabelTextElement;
text: string;
}
/**
* The text each element prints for one bottling (placeholders filled in),
* top first, then left.
*/
export function resolvedTexts(
elements: readonly LabelTextElement[],
values: Record<PlaceholderKey, string>,
): ResolvedText[] {
const resolved = elements
.map((element) => ({
element,
text: element.placeholderKey
? values[element.placeholderKey]
: element.text,
}))
.sort((a, b) => a.element.y - b.element.y);
// Reading order comes from the geometry only, never from the order of the
// elements array: rows (tops within the tolerance of the row's first top),
// each row from left to right.
const rows: ResolvedText[][] = [];
let rowTop = Number.NEGATIVE_INFINITY;
for (const entry of resolved) {
if (entry.element.y - rowTop > ROW_TOLERANCE_MM) {
rows.push([]);
rowTop = entry.element.y;
}
rows[rows.length - 1].push(entry);
}
return rows.flatMap((row) => row.sort((a, b) => a.element.x - b.element.x));
}
/**
* Does the lot number already start with the lot marker "L" (as its own token
* or glued to digits, "L-001", "L2026")? Then no further "L" is needed.
*/
export function lotStartsWithL(lotNumber: string): boolean {
return /^l( |\d|$)/.test(normalizeForCheck(lotNumber));
}
@@ -0,0 +1,16 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { PLACEHOLDER_KEYS } from './label-element.types';
describe('PLACEHOLDER_KEYS', () => {
// The frontend compares its own list with this file.
it('the fixture file equals PLACEHOLDER_KEYS', () => {
const fixture: unknown = JSON.parse(
readFileSync(
join(__dirname, '../../test/fixtures/placeholder-keys.json'),
'utf8',
),
);
expect(fixture).toEqual([...PLACEHOLDER_KEYS]);
});
});