From df8261b69bb35747c1cfe289785130a8052c2f32 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Sat, 26 Sep 2026 07:49:04 +0200 Subject: [PATCH 01/41] fix(vouchers): share text fitting rules --- src/features/vouchers/lib/voucher-text-fit.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/features/vouchers/lib/voucher-text-fit.ts diff --git a/src/features/vouchers/lib/voucher-text-fit.ts b/src/features/vouchers/lib/voucher-text-fit.ts new file mode 100644 index 00000000..118cbd91 --- /dev/null +++ b/src/features/vouchers/lib/voucher-text-fit.ts @@ -0,0 +1,138 @@ +export type VoucherTextFitArea = { + widthMm: number; + heightMm: number; + maxLines: number; + typography: { + preferredFontSizePt: number; + minFontSizePt: number; + lineHeightMm: number; + }; +}; + +export type VoucherTextFitResult = { + fontSizePt: number; + lines: string[]; + overflowed: boolean; + lineHeightMm: number; + maxWidthMm: number; +}; + +export type VoucherTextWidthMeasurer = (text: string, fontSizePt: number) => number; + +export const VOUCHER_TEXT_HORIZONTAL_INSET_MM = { + valueArea: 1, + serviceArea: 1, + validityArea: 1, + codeArea: 2, +} as const; + +const PT_TO_MM = 25.4 / 72; +const FIT_STEP_PT = 0.25; + +export function getVoucherTextLineHeightMm(configuredLineHeightMm: number, fontSizePt: number) { + return configuredLineHeightMm > 0 ? configuredLineHeightMm : fontSizePt * PT_TO_MM * 1.2; +} + +export function fitVoucherTextToArea( + text: string, + area: VoucherTextFitArea, + measureTextWidthMm: VoucherTextWidthMeasurer, + horizontalInsetMm = 1, +): VoucherTextFitResult { + const maxWidthMm = Math.max(1, area.widthMm - horizontalInsetMm * 2); + const maxLines = Math.max(1, Math.floor(area.maxLines)); + const minimumFontSizePt = Math.max(0.1, area.typography.minFontSizePt); + const preferredFontSizePt = Math.max(minimumFontSizePt, area.typography.preferredFontSizePt); + + for (let size = preferredFontSizePt; size >= minimumFontSizePt - 0.001; size = roundSize(size - FIT_STEP_PT)) { + const fontSizePt = roundSize(size); + const lines = wrapVoucherText(text, fontSizePt, maxWidthMm, measureTextWidthMm); + const lineHeightMm = getVoucherTextLineHeightMm(area.typography.lineHeightMm, fontSizePt); + + if ( + lines.length <= maxLines + && lines.every((line) => measureTextWidthMm(line, fontSizePt) <= maxWidthMm + 0.001) + && lines.length * lineHeightMm <= area.heightMm + 0.001 + ) { + return { fontSizePt, lines, overflowed: false, lineHeightMm, maxWidthMm }; + } + + if (fontSizePt === minimumFontSizePt) break; + } + + const fontSizePt = minimumFontSizePt; + const lineHeightMm = getVoucherTextLineHeightMm(area.typography.lineHeightMm, fontSizePt); + const maxLinesByHeight = Math.max(1, Math.floor((area.heightMm + 0.001) / lineHeightMm)); + const visibleLineLimit = Math.max(1, Math.min(maxLines, maxLinesByHeight)); + const lines = wrapVoucherText(text, fontSizePt, maxWidthMm, measureTextWidthMm).slice(0, visibleLineLimit); + const lastLineIndex = lines.length - 1; + + if (lastLineIndex >= 0) { + lines[lastLineIndex] = addVoucherEllipsis(lines[lastLineIndex], fontSizePt, maxWidthMm, measureTextWidthMm); + } + + return { fontSizePt, lines, overflowed: true, lineHeightMm, maxWidthMm }; +} + +function wrapVoucherText(text: string, fontSizePt: number, maxWidthMm: number, measureTextWidthMm: VoucherTextWidthMeasurer) { + const lines: string[] = []; + + for (const paragraph of text.split(/\r?\n/)) { + const words = paragraph.split(/\s+/).filter(Boolean); + let currentLine = ""; + + for (const word of words) { + const candidate = currentLine ? `${currentLine} ${word}` : word; + + if (!currentLine && measureTextWidthMm(word, fontSizePt) > maxWidthMm) { + const chunks = splitVoucherWord(word, fontSizePt, maxWidthMm, measureTextWidthMm); + lines.push(...chunks.slice(0, -1)); + currentLine = chunks.at(-1) ?? ""; + } else if (!currentLine || measureTextWidthMm(candidate, fontSizePt) <= maxWidthMm) { + currentLine = candidate; + } else { + lines.push(currentLine); + currentLine = word; + } + } + + if (currentLine) lines.push(currentLine); + } + + return lines; +} + +function splitVoucherWord(word: string, fontSizePt: number, maxWidthMm: number, measureTextWidthMm: VoucherTextWidthMeasurer) { + const chunks: string[] = []; + let current = ""; + + for (const character of Array.from(word)) { + const candidate = `${current}${character}`; + + if (current && measureTextWidthMm(candidate, fontSizePt) > maxWidthMm) { + chunks.push(current); + current = character; + } else { + current = candidate; + } + } + + if (current) chunks.push(current); + return chunks; +} + +function addVoucherEllipsis(text: string, fontSizePt: number, maxWidthMm: number, measureTextWidthMm: VoucherTextWidthMeasurer) { + const ellipsis = "…"; + const characters = Array.from(text.trimEnd()); + + for (let length = characters.length; length >= 0; length -= 1) { + const candidate = `${characters.slice(0, length).join("").trimEnd()}${ellipsis}`; + if (measureTextWidthMm(candidate, fontSizePt) <= maxWidthMm) return candidate; + } + + return ellipsis; +} + +function roundSize(value: number) { + return Number(value.toFixed(2)); +} From 38575ba32c747159e1a59d31458c3c606ac12911 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Sat, 26 Sep 2026 07:49:21 +0200 Subject: [PATCH 02/41] fix(vouchers): align editor text fitting with renderer --- .../voucher-template-layout-preview.ts | 109 +++--------------- 1 file changed, 14 insertions(+), 95 deletions(-) diff --git a/src/features/admin/components/voucher-template-layout-preview.ts b/src/features/admin/components/voucher-template-layout-preview.ts index 10543ee7..9fdc3bcb 100644 --- a/src/features/admin/components/voucher-template-layout-preview.ts +++ b/src/features/admin/components/voucher-template-layout-preview.ts @@ -1,4 +1,5 @@ import type { VoucherTemplateTextAreaKey } from "@/features/vouchers/lib/voucher-template-layout"; +import { fitVoucherTextToArea } from "@/features/vouchers/lib/voucher-text-fit"; /** * UI-only fixture data for the layout editor. It must never be added to the @@ -91,35 +92,20 @@ export function getVoucherTemplatePreviewText( * relies on server-only font and filesystem APIs. */ export function fitVoucherTemplatePreviewText(text: string, area: VoucherTemplatePreviewArea, textMeasurer?: VoucherTemplatePreviewTextMeasurer | null): VoucherTemplatePreviewFit { - const maxWidthMm = Math.max(1, area.widthMm - PREVIEW_HORIZONTAL_PADDING_MM); - const maxLines = Math.max(1, Math.floor(area.maxLines)); - const minimumFontSizePt = Math.max(0.1, area.typography.minFontSizePt); - const preferredFontSizePt = Math.max(minimumFontSizePt, area.typography.preferredFontSizePt); - - for (let size = preferredFontSizePt; size >= minimumFontSizePt - 0.001; size = roundSize(size - FIT_STEP_PT)) { - const fontSizePt = roundSize(size); - const lines = wrapPreviewText(text, fontSizePt, maxWidthMm, area.typography, textMeasurer); - const lineHeightMm = getPreviewLineHeightMm(area.typography.lineHeightMm, fontSizePt); - - if (lines.length <= maxLines && lines.every((line) => measurePreviewTextWidthMm(line, fontSizePt, area.typography, textMeasurer) <= maxWidthMm) && lines.length * lineHeightMm <= area.heightMm + 0.001) { - return { fontSizePt, lines, overflowed: false, lineHeightMm }; - } - - if (fontSizePt === minimumFontSizePt) break; - } - - const fontSizePt = minimumFontSizePt; - const lineHeightMm = getPreviewLineHeightMm(area.typography.lineHeightMm, fontSizePt); - const lines = wrapPreviewText(text, fontSizePt, maxWidthMm, area.typography, textMeasurer).slice(0, maxLines); - const lastLineIndex = lines.length - 1; - - if (lastLineIndex >= 0) { - lines[lastLineIndex] = addPreviewEllipsis(lines[lastLineIndex], fontSizePt, maxWidthMm, area.typography, textMeasurer); - } - - return { fontSizePt, lines, overflowed: true, lineHeightMm }; + const fit = fitVoucherTextToArea( + text, + area, + (value, fontSizePt) => measurePreviewTextWidthMm(value, fontSizePt, area.typography, textMeasurer), + VOUCHER_TEMPLATE_PREVIEW_HORIZONTAL_PADDING_MM, + ); + + return { + fontSizePt: fit.fontSizePt, + lines: fit.lines, + overflowed: fit.overflowed, + lineHeightMm: fit.lineHeightMm, + }; } - export function createVoucherTemplatePreviewTextMeasurer(scale = 1): VoucherTemplatePreviewTextMeasurer | null { if (typeof document === "undefined" || typeof document.createElement !== "function") return null; @@ -172,69 +158,6 @@ export function getVoucherTemplatePreviewLineBaselinePx(baselinePx: number, line return baselinePx - Math.max(0, lineCount - 1 - lineIndex) * lineHeightPx; } -function getPreviewLineHeightMm(lineHeightMm: number, fontSizePt: number) { - return lineHeightMm > 0 ? lineHeightMm : fontSizePt * PT_TO_MM * 1.2; -} - -function wrapPreviewText(text: string, fontSizePt: number, maxWidthMm: number, typography: PreviewTypography, textMeasurer?: VoucherTemplatePreviewTextMeasurer | null) { - const lines: string[] = []; - - for (const paragraph of text.split(/\r?\n/)) { - const words = paragraph.split(/\s+/).filter(Boolean); - let currentLine = ""; - - for (const word of words) { - const candidate = currentLine ? `${currentLine} ${word}` : word; - - if (!currentLine && measurePreviewTextWidthMm(word, fontSizePt, typography, textMeasurer) > maxWidthMm) { - const chunks = splitPreviewWord(word, fontSizePt, maxWidthMm, typography, textMeasurer); - lines.push(...chunks.slice(0, -1)); - currentLine = chunks.at(-1) ?? ""; - } else if (!currentLine || measurePreviewTextWidthMm(candidate, fontSizePt, typography, textMeasurer) <= maxWidthMm) { - currentLine = candidate; - } else { - lines.push(currentLine); - currentLine = word; - } - } - - if (currentLine) lines.push(currentLine); - } - - return lines; -} - -function splitPreviewWord(word: string, fontSizePt: number, maxWidthMm: number, typography: PreviewTypography, textMeasurer?: VoucherTemplatePreviewTextMeasurer | null) { - const chunks: string[] = []; - let current = ""; - - for (const character of Array.from(word)) { - const candidate = `${current}${character}`; - - if (current && measurePreviewTextWidthMm(candidate, fontSizePt, typography, textMeasurer) > maxWidthMm) { - chunks.push(current); - current = character; - } else { - current = candidate; - } - } - - if (current) chunks.push(current); - return chunks; -} - -function addPreviewEllipsis(text: string, fontSizePt: number, maxWidthMm: number, typography: PreviewTypography, textMeasurer?: VoucherTemplatePreviewTextMeasurer | null) { - const ellipsis = "…"; - const characters = Array.from(text.trimEnd()); - - for (let length = characters.length; length >= 0; length -= 1) { - const candidate = `${characters.slice(0, length).join("").trimEnd()}${ellipsis}`; - if (measurePreviewTextWidthMm(candidate, fontSizePt, typography, textMeasurer) <= maxWidthMm) return candidate; - } - - return ellipsis; -} - function measurePreviewTextWidthMm(text: string, fontSizePt: number, typography: PreviewTypography, textMeasurer?: VoucherTemplatePreviewTextMeasurer | null) { const measured = textMeasurer?.(text, fontSizePt, typography); if (measured) return measured.widthMm; @@ -252,7 +175,3 @@ function getCharacterWidthInEm(character: string) { if (/[-–—/()[\]{}]/.test(character)) return 0.38; return 0.56; } - -function roundSize(value: number) { - return Number(value.toFixed(2)); -} From 5379681ae72d7b2bf473a8f9efb0c9d0cd0cba91 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Sat, 26 Sep 2026 07:49:39 +0200 Subject: [PATCH 03/41] fix(vouchers): clean preview fitting wrapper --- .../components/voucher-template-layout-preview.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/features/admin/components/voucher-template-layout-preview.ts b/src/features/admin/components/voucher-template-layout-preview.ts index 9fdc3bcb..acfa5f09 100644 --- a/src/features/admin/components/voucher-template-layout-preview.ts +++ b/src/features/admin/components/voucher-template-layout-preview.ts @@ -58,9 +58,7 @@ export type VoucherTemplatePreviewTextMeasurer = ( ) => VoucherTemplatePreviewTextMetrics | null; const PT_TO_MM = 25.4 / 72; -const FIT_STEP_PT = 0.25; export const VOUCHER_TEMPLATE_PREVIEW_HORIZONTAL_PADDING_MM = 1; -const PREVIEW_HORIZONTAL_PADDING_MM = VOUCHER_TEMPLATE_PREVIEW_HORIZONTAL_PADDING_MM * 2; export const VOUCHER_TEMPLATE_PREVIEW_FONT_FAMILIES: Readonly> = { "noto-sans": '"Noto Sans", sans-serif', @@ -87,16 +85,15 @@ export function getVoucherTemplatePreviewText( } /** - * Browser-safe approximation of the PDF renderer's fitText helper. It is - * intentionally kept in the admin UI layer because the PDF implementation - * relies on server-only font and filesystem APIs. + * Browser preview wrapper over the same pure fitting rules used by the PDF renderer. + * Only font measurement remains environment-specific. */ -export function fitVoucherTemplatePreviewText(text: string, area: VoucherTemplatePreviewArea, textMeasurer?: VoucherTemplatePreviewTextMeasurer | null): VoucherTemplatePreviewFit { +export function fitVoucherTemplatePreviewText(text: string, area: VoucherTemplatePreviewArea, textMeasurer?: VoucherTemplatePreviewTextMeasurer | null, horizontalInsetMm = VOUCHER_TEMPLATE_PREVIEW_HORIZONTAL_PADDING_MM): VoucherTemplatePreviewFit { const fit = fitVoucherTextToArea( text, area, (value, fontSizePt) => measurePreviewTextWidthMm(value, fontSizePt, area.typography, textMeasurer), - VOUCHER_TEMPLATE_PREVIEW_HORIZONTAL_PADDING_MM, + horizontalInsetMm, ); return { From 876a7167550b56137b809c585be31a5ffaef1111 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Sat, 26 Sep 2026 07:50:18 +0200 Subject: [PATCH 04/41] fix(vouchers): classify template text overflow --- src/features/vouchers/lib/voucher-template-error.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/vouchers/lib/voucher-template-error.ts b/src/features/vouchers/lib/voucher-template-error.ts index 499b6225..3b8e6bac 100644 --- a/src/features/vouchers/lib/voucher-template-error.ts +++ b/src/features/vouchers/lib/voucher-template-error.ts @@ -1,9 +1,9 @@ export class VoucherTemplateError extends Error { - readonly code: "unknown_template" | "invalid_master_page_size"; + readonly code: "unknown_template" | "invalid_master_page_size" | "text_overflow"; constructor( readonly templateKey: string | null | undefined, - options: { code?: "unknown_template" | "invalid_master_page_size"; message?: string } = {}, + options: { code?: "unknown_template" | "invalid_master_page_size" | "text_overflow"; message?: string } = {}, ) { super(options.message ?? `Voucher template "${templateKey ?? ""}" is not available.`); this.code = options.code ?? "unknown_template"; From 144ae40ad5f9991cfab71cea806ebb543c63c99b Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Sat, 26 Sep 2026 07:50:46 +0200 Subject: [PATCH 05/41] fix(vouchers): make PDF text fitting match editor --- src/features/vouchers/lib/voucher-pdf-core.ts | 134 +++++++++++------- 1 file changed, 86 insertions(+), 48 deletions(-) diff --git a/src/features/vouchers/lib/voucher-pdf-core.ts b/src/features/vouchers/lib/voucher-pdf-core.ts index 91056437..524ef33a 100644 --- a/src/features/vouchers/lib/voucher-pdf-core.ts +++ b/src/features/vouchers/lib/voucher-pdf-core.ts @@ -13,10 +13,12 @@ import { siteConfig } from "@/config/site"; import { type ResolvedVoucherTemplate } from "@/features/vouchers/lib/voucher-template-repository"; import { requireVoucherTemplateById, resolveVoucherTemplate } from "@/features/vouchers/lib/voucher-template-repository"; import { VOUCHER_PRINT_GEOMETRY } from "@/features/vouchers/lib/voucher-template-layout"; +import { fitVoucherTextToArea, VOUCHER_TEXT_HORIZONTAL_INSET_MM } from "@/features/vouchers/lib/voucher-text-fit"; type VoucherPdfData = NonNullable>>; type VoucherStockBatchPdfItem = Pick; type VoucherTemplateRenderDefinition = Pick; +type VoucherRenderOptions = { failOnTextOverflow?: boolean }; export const MM_TO_PT = 72 / 25.4; const MASTER_PAGE_SIZE_TOLERANCE_MM = 0.35; @@ -109,7 +111,7 @@ export async function generatePersistedVoucherDigitalPdf(voucher: VoucherPdfData } /** Pure runtime renderer: receives an already resolved DB template, never Prisma or filesystem paths. */ -export async function generateResolvedVoucherPrintPdf(voucher: VoucherPdfData, template: ResolvedVoucherTemplate) { +export async function generateResolvedVoucherPrintPdf(voucher: VoucherPdfData, template: ResolvedVoucherTemplate, options: VoucherRenderOptions = {}) { const masterBytes = template.masterBytes; const pdf = await PDFDocument.load(masterBytes); validateMasterPageSize(pdf, template); @@ -139,7 +141,7 @@ export async function generateResolvedVoucherPrintPdf(voucher: VoucherPdfData, t ); const qrCode = QRCode.create(buildVoucherVerificationUrl(voucher.code), { errorCorrectionLevel: "M" }); - drawVoucherOverlay(page, voucher, template, regularFont, boldFont, qrCode); + drawVoucherOverlay(page, voucher, template, regularFont, boldFont, qrCode, options); setPrintPageBoxes(page, template); return pdf.save(); @@ -154,6 +156,7 @@ export function buildVoucherStockPdfFilename(batchNumber: string) { export async function generateResolvedVoucherBatchPrintPdf( batch: { batchNumber: string; items: readonly VoucherStockBatchPdfItem[] }, template: ResolvedVoucherTemplate, + options: VoucherRenderOptions = {}, ) { const [regularLatinBytes, regularLatinExtBytes, boldLatinBytes, boldLatinExtBytes] = await Promise.all([ readFile(fontRegularLatinPath), readFile(fontRegularLatinExtPath), readFile(fontBoldLatinPath), readFile(fontBoldLatinExtPath), @@ -165,7 +168,7 @@ export async function generateResolvedVoucherBatchPrintPdf( const boldFont = createFontPair(await pdf.embedFont(boldLatinBytes, { subset: true }), await pdf.embedFont(boldLatinExtBytes, { subset: true })); for (const item of batch.items) { const [page] = await pdf.copyPages(masterPdf, [0]); pdf.addPage(page); - drawVoucherCodeAndQrOverlay(page, item.code, template, regularFont, boldFont, QRCode.create(buildVoucherVerificationUrl(item.code), { errorCorrectionLevel: "M" })); + drawVoucherCodeAndQrOverlay(page, item.code, template, regularFont, boldFont, QRCode.create(buildVoucherVerificationUrl(item.code), { errorCorrectionLevel: "M" }), options); setPrintPageBoxes(page, template); } pdf.setTitle(`Předtištěné vouchery ${batch.batchNumber}`); pdf.setAuthor("PP Studio"); pdf.setSubject(`Tisková série voucherů PP Studio · ${template.label}`); pdf.setCreator("PP Studio administrace"); pdf.setProducer("PP Studio administrace"); @@ -203,7 +206,7 @@ function formatMillimeters(value: number) { return Number(value.toFixed(2)).toString(); } -export async function generateResolvedVoucherDigitalPdf(voucher: VoucherPdfData, template: ResolvedVoucherTemplate) { +export async function generateResolvedVoucherDigitalPdf(voucher: VoucherPdfData, template: ResolvedVoucherTemplate, options: VoucherRenderOptions = {}) { const trim = template.layout.trim; const printWidthPt = mm(template.layout.printPage.widthMm); const printHeightPt = mm(template.layout.printPage.heightMm); @@ -211,7 +214,7 @@ export async function generateResolvedVoucherDigitalPdf(voucher: VoucherPdfData, const trimYPt = mm(trim.yMm); const trimWidthPt = mm(trim.widthMm); const trimHeightPt = mm(trim.heightMm); - const printBytes = await generateResolvedVoucherPrintPdf(voucher, template); + const printBytes = await generateResolvedVoucherPrintPdf(voucher, template, options); const pdf = await PDFDocument.create(); const [printPage] = await pdf.embedPdf(printBytes, [0]); const page = pdf.addPage([trimWidthPt, trimHeightPt]); @@ -233,53 +236,40 @@ function drawVoucherOverlay( regularFont: FontPair, boldFont: FontPair, qrCode: ReturnType, + options: VoucherRenderOptions, ) { const layout = template.layout; const overlay = buildVoucherPdfOverlayData(voucher); const value = overlay.value; - const valueArea = voucher.type === VoucherType.VALUE ? layout.valueArea : layout.serviceArea; + const valueAreaKey = voucher.type === VoucherType.VALUE ? "valueArea" : "serviceArea"; + const valueArea = layout[valueAreaKey]; const valueTypography = valueArea.typography; const valueFont = valueTypography.fontWeight === "bold" ? boldFont : regularFont; - const textColor = cmyk(0, 0.2, 0.2, 0.9); - const valueFit = fitText( - value, - valueFont, - mm(valueArea.widthMm), - valueTypography.preferredFontSizePt, - valueTypography.minFontSizePt, - valueArea.maxLines, - ); - const valueLineHeight = mm(valueTypography.lineHeightMm); + const valueInsetMm = VOUCHER_TEXT_HORIZONTAL_INSET_MM[valueAreaKey]; + const valueFit = fitTextForArea(value, valueFont, valueArea, valueInsetMm); + assertTextFitsTemplate(valueFit, template, valueAreaKey, options); + + const valueLineHeight = mm(valueFit.lineHeightMm); const valueBottomY = mm(valueArea.baselineMm); const valueStartY = valueBottomY + (valueFit.lines.length - 1) * valueLineHeight; valueFit.lines.forEach((line, index) => { - drawTextLine(page, line, getTextX(line, valueArea, valueFont, valueFit.size), valueStartY - index * valueLineHeight, { + drawTextLine(page, line, getTextX(line, valueArea, valueFont, valueFit.fontSizePt, valueInsetMm), valueStartY - index * valueLineHeight, { fontPair: valueFont, - size: valueFit.size, - color: textColor, + size: valueFit.fontSizePt, + color: voucherTextColor(valueTypography.color), }); }); const dateValue = overlay.validUntil; const validityArea = layout.validityArea; const validityFont = validityArea.typography.fontWeight === "bold" ? boldFont : regularFont; - const validitySize = fitSingleLine( - dateValue, - validityFont, - mm(validityArea.widthMm), - validityArea.typography.preferredFontSizePt, - validityArea.typography.minFontSizePt, - ); - drawTextLine( - page, - dateValue, - getTextX(dateValue, validityArea, validityFont, validitySize), - mm(validityArea.baselineMm), - { fontPair: validityFont, size: validitySize, color: textColor }, - ); + const validityInsetMm = VOUCHER_TEXT_HORIZONTAL_INSET_MM.validityArea; + const validityFit = fitTextForArea(dateValue, validityFont, { ...validityArea, maxLines: 1 }, validityInsetMm); + assertTextFitsTemplate(validityFit, template, "validityArea", options); + drawFittedTextLines(page, validityFit, validityArea, validityFont, validityInsetMm); - drawVoucherCodeAndQrOverlay(page, voucher.code, template, regularFont, boldFont, qrCode); + drawVoucherCodeAndQrOverlay(page, voucher.code, template, regularFont, boldFont, qrCode, options); } function drawVoucherCodeAndQrOverlay( @@ -289,27 +279,75 @@ function drawVoucherCodeAndQrOverlay( regularFont: FontPair, boldFont: FontPair, qrCode: ReturnType, + options: VoucherRenderOptions, ) { const codeArea = template.layout.codeArea; const codeFont = codeArea.typography.fontWeight === "bold" ? boldFont : regularFont; - const codeSize = fitSingleLine( - code, - codeFont, - mm(codeArea.widthMm - 4), - codeArea.typography.preferredFontSizePt, - codeArea.typography.minFontSizePt, - ); - drawTextLine( - page, - code, - getTextX(code, codeArea, codeFont, codeSize, 2), - mm(codeArea.baselineMm), - { fontPair: codeFont, size: codeSize, color: cmyk(0, 0.2, 0.2, 0.9) }, - ); + const codeInsetMm = VOUCHER_TEXT_HORIZONTAL_INSET_MM.codeArea; + const codeFit = fitTextForArea(code, codeFont, { ...codeArea, maxLines: 1 }, codeInsetMm); + assertTextFitsTemplate(codeFit, template, "codeArea", options); + drawFittedTextLines(page, codeFit, codeArea, codeFont, codeInsetMm); drawVoucherQr(page, qrCode, template); } +function fitTextForArea( + text: string, + fontPair: FontPair, + area: VoucherTemplateRenderDefinition["layout"]["valueArea"], + horizontalInsetMm: number, +) { + return fitVoucherTextToArea( + text, + area, + (value, size) => measureText(value, fontPair, size) / MM_TO_PT, + horizontalInsetMm, + ); +} + +function drawFittedTextLines( + page: PDFPage, + fit: ReturnType, + area: VoucherTemplateRenderDefinition["layout"]["valueArea"], + fontPair: FontPair, + horizontalInsetMm: number, +) { + const lineHeight = mm(fit.lineHeightMm); + const startY = mm(area.baselineMm) + (fit.lines.length - 1) * lineHeight; + + fit.lines.forEach((line, index) => { + drawTextLine( + page, + line, + getTextX(line, area, fontPair, fit.fontSizePt, horizontalInsetMm), + startY - index * lineHeight, + { + fontPair, + size: fit.fontSizePt, + color: voucherTextColor(area.typography.color), + }, + ); + }); +} + +function assertTextFitsTemplate( + fit: ReturnType, + template: VoucherTemplateRenderDefinition, + areaKey: "valueArea" | "serviceArea" | "validityArea" | "codeArea", + options: VoucherRenderOptions, +) { + if (!options.failOnTextOverflow || !fit.overflowed) return; + + throw new VoucherTemplateError(template.key, { + code: "text_overflow", + message: `Voucher template "${template.key}" overflows dynamic text area "${areaKey}".`, + }); +} + +function voucherTextColor(color: { c: number; m: number; y: number; k: number }) { + return cmyk(color.c, color.m, color.y, color.k); +} + function drawVoucherQr(page: PDFPage, qrCode: ReturnType, template: VoucherTemplateRenderDefinition) { const area = template.layout.qrArea; const x = mm(area.xMm); From fcc2bbb9c191580f2e363dd90e824916eb6f85af Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Sat, 26 Sep 2026 07:51:12 +0200 Subject: [PATCH 06/41] refactor(vouchers): remove duplicate PDF fitting helpers --- src/features/vouchers/lib/voucher-pdf-core.ts | 74 ------------------- 1 file changed, 74 deletions(-) diff --git a/src/features/vouchers/lib/voucher-pdf-core.ts b/src/features/vouchers/lib/voucher-pdf-core.ts index 524ef33a..1b1cde4a 100644 --- a/src/features/vouchers/lib/voucher-pdf-core.ts +++ b/src/features/vouchers/lib/voucher-pdf-core.ts @@ -409,18 +409,6 @@ function setPrintPageBoxes(page: PDFPage, template: VoucherTemplateRenderDefinit page.setTrimBox(mm(layout.trim.xMm), mm(layout.trim.yMm), mm(layout.trim.widthMm), mm(layout.trim.heightMm)); } -function fitText(text: string, fontPair: FontPair, maxWidth: number, preferredSize: number, minimumSize: number, maxLines: number) { - return fitVoucherText( - text, - (value, size) => measureText(value, fontPair, size), - (value, size, width) => wrapText(value, fontPair, size, width), - maxWidth, - preferredSize, - minimumSize, - maxLines, - ); -} - export function fitVoucherText( text: string, measure: (value: string, size: number) => number, @@ -463,68 +451,6 @@ function addEllipsis(text: string, measure: (value: string, size: number) => num return ellipsis; } -function fitSingleLine(text: string, fontPair: FontPair, maxWidth: number, preferredSize: number, minimumSize: number) { - for (let size = preferredSize; size >= minimumSize; size -= 0.25) { - if (measureText(text, fontPair, size) <= maxWidth) { - return size; - } - } - - return minimumSize; -} - -function wrapText(text: string, fontPair: FontPair, size: number, maxWidth: number) { - const words = text.split(/\s+/).filter(Boolean); - const lines: string[] = []; - let currentLine = ""; - - for (const word of words) { - const candidate = currentLine ? `${currentLine} ${word}` : word; - - if (!currentLine && measureText(word, fontPair, size) > maxWidth) { - const chunks = splitWord(word, fontPair, size, maxWidth); - lines.push(...chunks.slice(0, -1)); - currentLine = chunks.at(-1) ?? ""; - continue; - } - - if (measureText(candidate, fontPair, size) <= maxWidth || !currentLine) { - currentLine = candidate; - } else { - lines.push(currentLine); - currentLine = word; - } - } - - if (currentLine) { - lines.push(currentLine); - } - - return lines; -} - -function splitWord(word: string, fontPair: FontPair, size: number, maxWidth: number) { - const chunks: string[] = []; - let current = ""; - - for (const character of word) { - const candidate = `${current}${character}`; - - if (current && measureText(candidate, fontPair, size) > maxWidth) { - chunks.push(current); - current = character; - } else { - current = candidate; - } - } - - if (current) { - chunks.push(current); - } - - return chunks; -} - function createFontPair(primary: PDFFont, fallback: PDFFont): FontPair { return { primary, From c82b29e729623565762d7f5c906dfa552a0e20f1 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Sat, 26 Sep 2026 07:51:27 +0200 Subject: [PATCH 07/41] fix(vouchers): fail publish preflight on text overflow --- .../lib/voucher-template-publish-preflight.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/features/vouchers/lib/voucher-template-publish-preflight.ts b/src/features/vouchers/lib/voucher-template-publish-preflight.ts index cf8df426..6e449e79 100644 --- a/src/features/vouchers/lib/voucher-template-publish-preflight.ts +++ b/src/features/vouchers/lib/voucher-template-publish-preflight.ts @@ -8,6 +8,7 @@ import { } from "./voucher-pdf-core"; import { preflightFinalVoucherPrint, preflightVoucherTemplateMaster } from "./voucher-template-preflight"; import { type ResolvedVoucherTemplate } from "./voucher-template-repository"; +import { VoucherTemplateError } from "./voucher-template-error"; export async function preflightVoucherTemplateForPublish(template: ResolvedVoucherTemplate) { const master = await preflightVoucherTemplateMaster(template.masterBytes); @@ -18,14 +19,29 @@ export async function preflightVoucherTemplateForPublish(template: ResolvedVouch const errors: string[] = []; for (const scenario of scenarios) { const voucher = { templateId: template.id, templateKey: template.key, code: "TEST-2026-ABCDEF", type: scenario.type, originalValueCzk: scenario.type === VoucherType.VALUE ? 1500 : 1500, remainingValueCzk: scenario.type === VoucherType.VALUE ? 1500 : null, serviceNameSnapshot: scenario.serviceName ?? null, servicePriceSnapshotCzk: scenario.type === VoucherType.SERVICE ? 1500 : null, validUntil: new Date("2027-12-31T22:59:59.999Z") } as Parameters[0]; - const print = await preflightFinalVoucherPrint(await generateResolvedVoucherPrintPdf(voucher, template)); - const digitalPdf = await PDFDocument.load(await generateResolvedVoucherDigitalPdf(voucher, template)); - const digitalPage = digitalPdf.getPage(0); - if (print.errors.length || !print.geometryValid) errors.push(...print.errors, "Finální PRINT nemá očekávanou geometrii."); - const digitalSize = digitalPage.getSize(); - if (digitalPdf.getPageCount() !== 1 || Math.abs(digitalSize.width - 210 * 72 / 25.4) > 1 || Math.abs(digitalSize.height - 99 * 72 / 25.4) > 1) errors.push("Finální DIGITAL nemá očekávanou geometrii."); + + try { + const print = await preflightFinalVoucherPrint(await generateResolvedVoucherPrintPdf(voucher, template, { failOnTextOverflow: true })); + const digitalPdf = await PDFDocument.load(await generateResolvedVoucherDigitalPdf(voucher, template, { failOnTextOverflow: true })); + const digitalPage = digitalPdf.getPage(0); + if (print.errors.length || !print.geometryValid) errors.push(...print.errors, "Finální PRINT nemá očekávanou geometrii."); + const digitalSize = digitalPage.getSize(); + if (digitalPdf.getPageCount() !== 1 || Math.abs(digitalSize.width - 210 * 72 / 25.4) > 1 || Math.abs(digitalSize.height - 99 * 72 / 25.4) > 1) errors.push("Finální DIGITAL nemá očekávanou geometrii."); + } catch (error) { + if (error instanceof VoucherTemplateError && error.code === "text_overflow") { + errors.push(error.message); + continue; + } + throw error; + } + } + + try { + const stock = await preflightFinalVoucherPrint(await generateResolvedVoucherBatchPrintPdf({ batchNumber: "TEST-001", items: [{ code: "TEST-2026-ABCDEF" }] }, template, { failOnTextOverflow: true })); + if (stock.errors.length || !stock.geometryValid) errors.push(...stock.errors, "Finální STOCK nemá očekávanou geometrii."); + } catch (error) { + if (error instanceof VoucherTemplateError && error.code === "text_overflow") errors.push(error.message); + else throw error; } - const stock = await preflightFinalVoucherPrint(await generateResolvedVoucherBatchPrintPdf({ batchNumber: "TEST-001", items: [{ code: "TEST-2026-ABCDEF" }] }, template)); - if (stock.errors.length || !stock.geometryValid) errors.push(...stock.errors, "Finální STOCK nemá očekávanou geometrii."); return { ok: errors.length === 0, errors }; } From 54c02e6eda1543970c673981ba03e88e34260ced Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Sat, 26 Sep 2026 07:51:44 +0200 Subject: [PATCH 08/41] fix(vouchers): match editor text insets to PDF --- .../components/voucher-template-layout-editor.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/features/admin/components/voucher-template-layout-editor.tsx b/src/features/admin/components/voucher-template-layout-editor.tsx index bfbf777a..7f0c9e27 100644 --- a/src/features/admin/components/voucher-template-layout-editor.tsx +++ b/src/features/admin/components/voucher-template-layout-editor.tsx @@ -16,6 +16,7 @@ import { type VoucherTemplateLayoutV1, type VoucherTemplateTypographyPatch, } from "@/features/vouchers/lib/voucher-template-layout"; +import { VOUCHER_TEXT_HORIZONTAL_INSET_MM } from "@/features/vouchers/lib/voucher-text-fit"; import { createVoucherTemplatePreviewTextMeasurer, @@ -26,7 +27,6 @@ import { getVoucherTemplatePreviewText, isVoucherTemplatePreviewAreaVisible, VOUCHER_TEMPLATE_PREVIEW_FONT_FAMILIES, - VOUCHER_TEMPLATE_PREVIEW_HORIZONTAL_PADDING_MM, type ServicePreviewScenario, type VoucherTemplatePreviewMode, } from "./voucher-template-layout-preview"; @@ -201,12 +201,13 @@ export function VoucherTemplateLayoutEditor({ templateId, initialLayout, preview const item = layout[key]; const baseline = "baselineMm" in item ? item.baselineMm : null; const previewVisible = isVoucherTemplatePreviewAreaVisible(key, previewMode); - const textPreview = fontsReady && isVoucherTemplateTextAreaKey(key) && previewVisible ? (() => { const text = getVoucherTemplatePreviewText(key, serviceScenario); const fit = fitVoucherTemplatePreviewText(text, item as VoucherTemplateLayoutV1["valueArea"], previewTextMeasurer); return { text, fit }; })() : null; + const horizontalInsetMm = isVoucherTemplateTextAreaKey(key) ? VOUCHER_TEXT_HORIZONTAL_INSET_MM[key] : 0; + const textPreview = fontsReady && isVoucherTemplateTextAreaKey(key) && previewVisible ? (() => { const text = getVoucherTemplatePreviewText(key, serviceScenario); const fit = fitVoucherTemplatePreviewText(text, item as VoucherTemplateLayoutV1["valueArea"], previewTextMeasurer, horizontalInsetMm); return { text, fit }; })() : null; const baselinePx = baseline === null ? null : getVoucherTemplatePreviewBaselineTopPx(item as VoucherTemplateLayoutV1["valueArea"], canvasScale); const isSelected = selected === key; return selectArea(key)} onDragStart={() => { selectArea(key); setIsInteracting(true); }} onResizeStart={(_, direction) => { resizeStartRef.current = { key, area: item, direction }; selectArea(key); setIsInteracting(true); }} onResize={(_, direction, ref, __, pos) => applyResize(key, direction, ref, pos)} onDragStop={(_, data) => { setIsInteracting(false); update(key, { xMm: snapToHalfMm(data.x / canvasScale), yMm: snapToHalfMm(browserTopToPdfBottom(data.y / canvasScale, item.heightMm)) }); }} onResizeStop={(_, direction, ref, __, pos) => { setIsInteracting(false); applyResize(key, direction, ref, pos); resizeStartRef.current = null; }} resizeHandleStyles={cornerHandleStyles} className={`group relative cursor-move overflow-visible border transition-colors ${isSelected ? "z-20 border-[var(--color-accent-soft)] bg-[rgba(190,160,120,0.08)] outline outline-1 outline-offset-2 outline-[var(--color-accent)]/70" : "border-white/8 bg-transparent opacity-35 hover:border-white/40 hover:opacity-80"}`}> {isSelected ? {labels[key]} : {shortLabels[key]}} -
{key === "qrArea" ? : textPreview && baselinePx !== null ? : null}{isSelected && baselinePx !== null ? : null}
+
{key === "qrArea" ? : textPreview && baselinePx !== null ? : null}{isSelected && baselinePx !== null ? : null}
; })} @@ -239,7 +240,7 @@ function ToggleButton({ label, checked, onClick }: { label: string; checked: boo const handleStyle = { width: 8, height: 8, borderRadius: 3, background: "#dbc2a5", border: "1px solid #171311", boxShadow: "0 0 0 1px rgba(135,105,65,.55)" }; const cornerHandleStyles = { topLeft: { ...handleStyle, cursor: "nwse-resize" }, topRight: { ...handleStyle, cursor: "nesw-resize" }, bottomLeft: { ...handleStyle, cursor: "nesw-resize" }, bottomRight: { ...handleStyle, cursor: "nwse-resize" } }; -function PreviewCanvas({ area, preview, baselinePx, fontMetricsVersion, scale }: { area: VoucherTemplateLayoutV1["valueArea"]; preview: { text: string; fit: ReturnType }; baselinePx: number; fontMetricsVersion: number; scale: number }) { +function PreviewCanvas({ area, preview, baselinePx, fontMetricsVersion, scale, horizontalInsetMm }: { area: VoucherTemplateLayoutV1["valueArea"]; preview: { text: string; fit: ReturnType }; baselinePx: number; fontMetricsVersion: number; scale: number; horizontalInsetMm: number }) { const canvasRef = useRef(null); const fontFamily = VOUCHER_TEMPLATE_PREVIEW_FONT_FAMILIES[area.typography.fontFamilyKey] ?? '"Noto Sans", sans-serif'; useEffect(() => { @@ -260,9 +261,9 @@ function PreviewCanvas({ area, preview, baselinePx, fontMetricsVersion, scale }: context.textBaseline = "alphabetic"; context.textAlign = area.typography.alignment; context.fillStyle = "#2e241f"; - const x = area.typography.alignment === "center" ? widthPx / 2 : VOUCHER_TEMPLATE_PREVIEW_HORIZONTAL_PADDING_MM * scale / 2; + const x = area.typography.alignment === "center" ? widthPx / 2 : horizontalInsetMm * scale; preview.fit.lines.forEach((line, index) => { const lineBaselinePx = getVoucherTemplatePreviewLineBaselinePx(baselinePx, preview.fit.lines.length, index, preview.fit.lineHeightMm, scale); context.fillText(line, x, lineBaselinePx); }); - }, [area, baselinePx, fontFamily, fontMetricsVersion, preview, scale]); + }, [area, baselinePx, fontFamily, fontMetricsVersion, horizontalInsetMm, preview, scale]); return