import mjml2html from 'mjml' function getPath(data, path) { return String(path || '').split('.').filter(Boolean).reduce((value, key) => value?.[key], data) } export function escapeHtml(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') } export function interpolate(input, data) { return String(input ?? '').replace(/{{\s*([\w.[\]-]+)\s*}}/g, (_, path) => { const normalized = path.replace(/\[(\d+)\]/g, '.$1') const value = getPath(data, normalized) return value == null ? '' : String(value) }) } function safeUrl(input, data) { const url = interpolate(input, data).trim() return /^(https?:|mailto:)/i.test(url) ? url : '#' } function safeHex(value, fallback) { const color = String(value || '').trim() return /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(color) ? color : fallback } function safeNumber(value, fallback, min, max) { const number = Number(value) if (!Number.isFinite(number)) return fallback return Math.min(max, Math.max(min, number)) } function safeAlign(value) { return ['left', 'center', 'right'].includes(value) ? value : 'left' } function safeFont(value) { const font = String(value || '') const allowed = ['Arial', 'Helvetica', 'Verdana', 'Georgia', 'Times New Roman', 'system-ui'] return allowed.includes(font) ? font : 'Arial' } function safeBackgroundMode(value) { return ['none', 'watermark', 'full'].includes(value) ? value : 'none' } function safeDensityMode(value) { return ['auto', 'comfortable', 'compact'].includes(value) ? value : 'auto' } function resolveDensity(template, mode) { if (mode !== 'auto') return mode const blocks = Array.isArray(template?.blocks) ? template.blocks : [] const tableCount = blocks.filter(block => block?.type === 'table').length return blocks.length >= 20 || tableCount >= 4 ? 'compact' : 'comfortable' } function themeOf(template) { const theme = template?.theme || {} const reportDensityMode = safeDensityMode(theme.reportDensity) const reportDensity = resolveDensity(template, reportDensityMode) const baseFontSize = safeNumber(theme.baseFontSize, 15, 10, 22) const headingSize = safeNumber(theme.headingSize, 23, 14, 42) return { backgroundColor: safeHex(theme.backgroundColor, '#f8fafc'), surfaceColor: safeHex(theme.surfaceColor, '#ffffff'), textColor: safeHex(theme.textColor, '#172033'), mutedColor: safeHex(theme.mutedColor, '#64748b'), accentColor: safeHex(theme.accentColor, '#111827'), buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'), borderColor: safeHex(theme.borderColor, '#dbe2ea'), borderRadius: safeNumber(theme.borderRadius, 12, 0, 32), baseFontSize, bodyFontSize: Math.max(10, baseFontSize - (reportDensity === 'compact' ? 2 : 0)), headingSize, renderedHeadingSize: Math.max(14, headingSize - (reportDensity === 'compact' ? 4 : 0)), lineHeight: safeNumber(theme.lineHeight, 1.45, 1.1, 1.9), blockGap: safeNumber(theme.blockGap, 10, 0, 32), cardPadding: safeNumber(theme.cardPadding, 12, 4, 32), tableCellPadding: safeNumber(theme.tableCellPadding, 8, 2, 20), pagePadding: safeNumber(theme.pagePadding, 18, 0, 80), pageMarginTop: safeNumber(theme.pageMarginTop, 12, 0, 40), pageMarginRight: safeNumber(theme.pageMarginRight, 12, 0, 40), pageMarginBottom: safeNumber(theme.pageMarginBottom, 12, 0, 40), pageMarginLeft: safeNumber(theme.pageMarginLeft, 12, 0, 40), fontFamily: safeFont(theme.fontFamily), pageBackgroundImage: safeUrl(theme.pageBackgroundImage, {}), pageBackgroundMode: safeBackgroundMode(theme.pageBackgroundMode), pageBackgroundOpacity: safeNumber(theme.pageBackgroundOpacity, 0.08, 0, 0.35), decorativeBorder: theme.decorativeBorder === true, borderImage: safeUrl(theme.borderImage, {}), reportDensityMode, reportDensity } } function safeFit(value) { return ['contain', 'cover', 'fill'].includes(value) ? value : 'contain' } function safeVariant(block) { const explicit = String(block?.props?.variant || '').toLowerCase() if (['logo', 'hero', 'image'].includes(explicit)) return explicit const marker = `${block?.id || ''} ${block?.props?.alt || ''}`.toLowerCase() if (marker.includes('logo') || marker.includes('mark')) return 'logo' if (marker.includes('hero') || marker.includes('banner')) return 'hero' return 'image' } function reportBlockStyle(block, theme) { const style = block?.style || {} const compact = theme.reportDensity === 'compact' const forceBreakBefore = style.forcePageBreak === true const breakBefore = (style.pageBreakBefore === true && (!compact || forceBreakBefore)) ? 'page' : 'auto' const keepWithNext = style.keepWithNext === true || (compact && block?.type === 'heading') const breakAfter = style.pageBreakAfter === true ? 'page' : (keepWithNext ? 'avoid-page' : 'auto') const breakInside = style.allowPageBreak === true ? 'auto' : 'avoid-page' const rules = [ `background:${safeHex(style.backgroundColor, 'transparent')}`, `color:${safeHex(style.textColor, theme.textColor)}`, `border-color:${safeHex(style.borderColor, theme.borderColor)}`, `border-width:${safeNumber(style.borderWidth, 0, 0, 8)}px`, 'border-style:solid', `border-radius:${safeNumber(style.borderRadius, theme.borderRadius, 0, 32)}px`, `padding:${safeNumber(style.padding, 0, 0, 48)}px`, `text-align:${safeAlign(style.align)}`, `break-before:${breakBefore}`, `break-after:${breakAfter}`, `break-inside:${breakInside}`, `page-break-inside:${breakInside === 'avoid-page' ? 'avoid' : 'auto'}` ] return rules.join(';') } function imageStyle(block, theme) { const style = block?.style || {} const variant = safeVariant(block) const defaults = variant === 'logo' ? { maxWidth: 110, maxHeight: 72, fit: 'contain' } : variant === 'hero' ? { maxWidth: 999, maxHeight: 260, fit: 'cover' } : { maxWidth: 999, maxHeight: 340, fit: 'contain' } const maxWidth = safeNumber(style.maxWidth ?? block?.props?.maxWidth, defaults.maxWidth, 24, 999) const maxHeight = safeNumber(style.maxHeight ?? block?.props?.maxHeight, defaults.maxHeight, 24, 900) const width = style.width ?? block?.props?.width const widthRule = width == null || width === '' ? 'width:auto' : `width:${safeNumber(width, maxWidth, 24, 999)}px` const fit = safeFit(style.fit ?? block?.props?.fit ?? defaults.fit) const radius = safeNumber(style.borderRadius, theme.borderRadius, 0, 32) return [ widthRule, `max-width:min(100%,${maxWidth}px)`, `max-height:${maxHeight}px`, `object-fit:${fit}`, `border-radius:${radius}px`, 'display:inline-block' ].join(';') } function wrapReportBlock(block, theme, html) { const variant = block?.type === 'image' ? ` image-${safeVariant(block)}` : '' return `
| ${escapeHtml(c.label)} | `).join('')}
|---|
| ${escapeHtml(getPath(row, c.key))} | `).join('')}
${escapeHtml(interpolate(p.text, data))}
` break case 'kpi': html = `${escapeHtml(interpolate(p.text, data))}
` break case 'image': html = `