import type { TemplateFieldPlacement, TemplateLogoPlacement } from '@ema-platform/api'; /** * Client-side twin of the server's `certificate-layout.compiler`. * * The preview route renders whatever `hbsSource` it is handed, so a canvas * layout has to be compiled before it can be previewed — the server only * compiles on save, and previewing unsaved edits is the whole point of the * button. The two implementations must emit the same HTML; the server's copy * is authoritative for what is stored, this one only ever reaches a preview. */ function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function pct(value: number | undefined, fallback: number, min = 0): number { if (typeof value !== 'number' || Number.isNaN(value)) return fallback; return Math.min(100, Math.max(min, value)); } const CORNER_STYLES: Record string> = { TOP_LEFT: (o) => `top:${o}%;left:${o}%;`, TOP_CENTER: (o) => `top:${o}%;left:50%;transform:translateX(-50%);`, TOP_RIGHT: (o) => `top:${o}%;right:${o}%;`, BOTTOM_LEFT: (o) => `bottom:${o}%;left:${o}%;`, BOTTOM_RIGHT: (o) => `bottom:${o}%;right:${o}%;`, }; function logoHtml(logoUrl: string, placement: TemplateLogoPlacement): string { if (!logoUrl.trim()) return ''; const corner = placement.corner ?? 'TOP_LEFT'; const width = pct(placement.widthPct, 18); const offset = pct(placement.offsetPct, 5); const position = (CORNER_STYLES[corner] ?? CORNER_STYLES.TOP_LEFT)(offset); return ` \n`; } /** * Variable keys the renderer fills with a data URI — the fallback for a * block placed before `type` existed on it. Keep in sync with * `IMAGE_VARIABLE_KEYS` in the server's template-variables.ts; a new image * variable added there should be added here too. */ const IMAGE_VARIABLE_KEYS = new Set([ 'logo', 'holderPhoto', 'qrImage', 'sealImage', 'signatureImage', 'seafarerSignature', ]); function isImageBlock(block: TemplateFieldPlacement): boolean { if (block.type) return block.type === 'image'; return !!block.variable && IMAGE_VARIABLE_KEYS.has(block.variable); } function blockHtml(block: TemplateFieldPlacement): string { const x = pct(block.xPct, 0); const y = pct(block.yPct, 0); // Minimum 1%, matching the server compiler: a zero-width block would render // as an invisible sliver rather than as the mistake it is. const width = pct(block.widthPct, 30, 1); if (isImageBlock(block) && block.variable) { // Triple-brace: the value is a data URI, not markup — escaping it turns // every "&" into "&" and corrupts the src. const style = `position:absolute;left:${x}%;top:${y}%;width:${width}%;object-fit:contain;`; return ` \n`; } const size = block.fontSize ?? 14; const weight = block.fontWeight === 'bold' ? 'bold' : 'normal'; const style_ = block.fontStyle === 'italic' ? 'italic' : 'normal'; const align = block.align ?? 'left'; const color = escapeHtml(block.color ?? '#111111'); const content = block.variable ? `{{${block.variable}}}` : escapeHtml(block.text ?? ''); const style = `position:absolute;left:${x}%;top:${y}%;width:${width}%;` + `font-size:${size}px;font-weight:${weight};font-style:${style_};text-align:${align};color:${color};`; return `
${content}
\n`; } export function compileLayoutToHbs(input: { backgroundUrl: string; logoUrl: string; logoPlacement: TemplateLogoPlacement; fieldPlacements: TemplateFieldPlacement[]; }): string { const background = input.backgroundUrl.trim() ? ` \n` : ''; const blocks = input.fieldPlacements.map(blockHtml).join(''); return `
${background}${logoHtml(input.logoUrl, input.logoPlacement)}${blocks}
`; }