feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- 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:
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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user