mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
131 lines
4.7 KiB
TypeScript
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, '<')
|
|
.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, (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 "&" 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>
|
|
`;
|
|
}
|