Files
emaui/apps/backoffice/src/app/features/certificate-designer/config/layout-compiler.ts

131 lines
4.7 KiB
TypeScript

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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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, (offset: number) => 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 ` <img class="ema-logo" src="${escapeHtml(logoUrl)}" alt="" style="position:absolute;${position}width:${width}%;" />\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 "&amp;" and corrupts the src.
const style = `position:absolute;left:${x}%;top:${y}%;width:${width}%;object-fit:contain;`;
return ` <img class="ema-block" style="${style}" src="{{{${block.variable}}}}" alt="" />\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 ` <div class="ema-block" style="${style}">${content}</div>\n`;
}
export function compileLayoutToHbs(input: {
backgroundUrl: string;
logoUrl: string;
logoPlacement: TemplateLogoPlacement;
fieldPlacements: TemplateFieldPlacement[];
}): string {
const background = input.backgroundUrl.trim()
? ` <img class="ema-background" src="${escapeHtml(input.backgroundUrl)}" alt="" />\n`
: '';
const blocks = input.fieldPlacements.map(blockHtml).join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
@page { margin: 0; }
html, body { margin: 0; padding: 0; height: 100%; }
body { font-family: "Helvetica Neue", Arial, sans-serif; }
.ema-page { position: relative; width: 100%; height: 100vh; overflow: hidden; }
.ema-background {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
}
.ema-block { box-sizing: border-box; line-height: 1.3; word-wrap: break-word; }
</style>
</head>
<body>
<div class="ema-page">
${background}${logoHtml(input.logoUrl, input.logoPlacement)}${blocks} </div>
</body>
</html>
`;
}