import { resolveBackendAssetUrl } from "@/utils/api"; import { markdownToPlainChartText } from "@/components/slide-editor/charts/chart-data"; import { normalizeRawTextMarkdownElement } from "@/components/slide-editor/text/template-v2-text"; type JsonRecord = Record; type RenderMode = "absolute" | "flow"; type DataLabelPosition = "base" | "mid" | "top" | "outside"; type ChartKind = | "bar" | "bubble" | "horizontal_bar" | "horizontal_stacked_bar" | "stacked_bar" | "line" | "area" | "pie" | "donut" | "polar_area" | "radar" | "scatter"; type InfographicKind = "progress_bar" | "gauge"; type JsonToHtmlItem = JsonRecord; const DATA_LABEL_POSITIONS = new Set(["base", "mid", "top", "outside"]); interface ChartPointData { x: number; y: number; r?: number; } interface ChartSeriesData { name: string; points: ChartPointData[]; values: number[]; } interface NormalizedChartData { categories: string[]; colors: string[]; series: ChartSeriesData[]; } interface Box { x: number; y: number; width?: number; height?: number; } interface FontFaceDefinition { family: string; url: string; format?: string; weight?: string; style?: string; } interface TemplateV2HtmlOptions { fonts?: unknown; width?: number; height?: number; } interface TemplateV2RenderPayload { items: JsonToHtmlItem[]; width: number; height: number; fonts?: unknown; background: string; } const ELEMENT_TYPES = new Set([ "text", "container", "image", "text-list", "table", "rectangle", "ellipse", "line", "svg", "chart", "infographic", "flex", "grid", "group", "list-view", "grid-view", ]); const DEFAULT_CHART_JS_URL = "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"; const DEFAULT_CHART_COLORS = [ "#7F22FE", "#155DFC", "#F59E0B", "#12B76A", "#EF4444", "#06B6D4", "#8B5CF6", "#64748B", ]; const CHART_FONT_FAMILY = "Inter, Arial, sans-serif"; export const TEMPLATE_V2_HTML_WIDTH = 1280; export const TEMPLATE_V2_HTML_HEIGHT = 720; export function templateV2UiToHtml( ui: unknown, options: TemplateV2HtmlOptions = {} ): string | null { const payload = templateV2RenderPayload(ui, options); if (!payload) return null; return jsonToHtml( payload.items, payload.width, payload.height, payload.fonts, payload.background ); } export function templateV2UiToHtmlFragment( ui: unknown, options: TemplateV2HtmlOptions = {} ): string | null { const payload = templateV2RenderPayload(ui, options); if (!payload) return null; return jsonToHtmlFragment( payload.items, payload.width, payload.height, payload.fonts, payload.background ); } function templateV2RenderPayload( ui: unknown, options: TemplateV2HtmlOptions ): TemplateV2RenderPayload | null { const record = readRecord(ui); const rootElements = readArray(record.elements); const components = readArray(record.components); const items = [...rootElements, ...components].map((item) => readRecord(normalizeTemplateV2AssetUrls(item)) ); if (items.length === 0) { return null; } const width = options.width ?? TEMPLATE_V2_HTML_WIDTH; const height = options.height ?? TEMPLATE_V2_HTML_HEIGHT; const background = normalizeCssColor(readString(record.background) ?? "#FFFFFF"); return { items, width, height, fonts: options.fonts, background, }; } export function hasTemplateV2RenderableUi(ui: unknown): boolean { const record = readRecord(ui); return readArray(record.components).length > 0 || readArray(record.elements).length > 0; } function normalizeTemplateV2AssetUrls(value: unknown): unknown { if (Array.isArray(value)) { return value.map(normalizeTemplateV2AssetUrls); } const record = readRecord(value); if (!Object.keys(record).length) return value; const normalized = Object.fromEntries( Object.entries(record).map(([key, child]) => [ key, normalizeTemplateV2AssetUrls(child), ]) ) as JsonRecord; if (readString(normalized.type) === "image") { const source = readString(normalized.data); if (source) { normalized.data = resolveBackendAssetUrl(source); } } return normalized; } function jsonToHtml( items: JsonToHtmlItem[], width: number, height: number, fonts: unknown = {}, background = "#FFFFFF" ): string { const records = items.map(readRecord); const chartScripts = records.some(hasChartItem) ? renderChartScripts() : ""; const fontAssetTags = renderFontAssetTags(fonts); const bg = escapeCssColor(background); const slideRoot = renderSlideRoot(records, width, height, bg); return ` ${fontAssetTags}${slideRoot}${chartScripts}`; } function jsonToHtmlFragment( items: JsonToHtmlItem[], width: number, height: number, fonts: unknown = {}, background = "#FFFFFF" ): string { const records = items.map(readRecord); const bg = escapeCssColor(background); return `${renderFontAssetTags(fonts)}${renderSlideRoot( records, width, height, bg )}`; } function renderSlideRoot( records: JsonRecord[], width: number, height: number, background: string ): string { const content = records.map((item) => renderItem(item, "absolute")).join(""); return `
${content}
`; } function renderFontAssetTags(fonts: unknown): string { const css = readStringValueOrNull(fonts); if (css) { return ``; } const tags: string[] = []; const records = readRecord(fonts); const embeddedCss = readStringValueOrNull(records.css ?? records.font_css); if (embeddedCss) { tags.push( `` ); } const faceEntries = readArray(records.fonts).length ? records.fonts : fonts; tags.push(...normalizeFontFaces(faceEntries).map(renderFontFaceDefinition)); const stylesheets = normalizeFontStylesheetUrls(faceEntries); tags.push( ...stylesheets.map( (url) => `` ) ); return tags.join(""); } function normalizeFontFaces(fonts: unknown): FontFaceDefinition[] { if (Array.isArray(fonts)) { return fonts.flatMap((entry) => normalizeFontFaceEntry(undefined, entry)); } return Object.entries(readRecord(fonts)).flatMap(([family, value]) => family === "css" || family === "font_css" || family === "fonts" ? [] : normalizeFontFaceEntry(family, value) ); } function normalizeFontFaceEntry( fallbackFamily: string | undefined, value: unknown ): FontFaceDefinition[] { if (Array.isArray(value)) { return value.flatMap((entry) => normalizeFontFaceEntry(fallbackFamily, entry)); } const url = readString(value); if (url) { if (isFontStylesheetUrl(url)) return []; const family = fallbackFamily?.trim(); return family ? [ { family, url, weight: inferFontWeight(`${family} ${url}`), style: inferFontStyle(`${family} ${url}`), }, ] : []; } const record = readRecord(value); const family = readString( record.family ?? record.name ?? record.fontFamily ?? record.font_family ) ?? fallbackFamily?.trim(); const source = readString( record.url ?? record.src ?? record.href ?? record.data ?? record.source ); if (!family || !source || isFontStylesheetUrl(source)) return []; return [ { family, url: source, format: readString(record.format) ?? undefined, weight: readFontWeight(record.weight ?? record.fontWeight ?? record.font_weight) ?? inferFontWeight(`${family} ${source}`), style: readFontStyle(record.style ?? record.fontStyle ?? record.font_style) ?? inferFontStyle(`${family} ${source}`), }, ]; } function normalizeFontStylesheetUrls(fonts: unknown): string[] { if (Array.isArray(fonts)) { return fonts.flatMap(normalizeFontStylesheetUrls); } const directUrl = readString(fonts); if (directUrl && isFontStylesheetUrl(directUrl)) return [directUrl]; return Object.values(readRecord(fonts)).flatMap((value) => { const url = readString(value); if (url && isFontStylesheetUrl(url)) return [url]; const record = readRecord(value); const source = readString( record.url ?? record.src ?? record.href ?? record.data ?? record.source ); return source && isFontStylesheetUrl(source) ? [source] : []; }); } function renderFontFaceDefinition(definition: FontFaceDefinition): string { const aliases = fontFamilyAliases(definition.family, definition.weight); const src = `url("${escapeCssUrl(resolveBackendAssetUrl(definition.url))}")${definition.format ? ` format("${escapeCssUrl(definition.format)}")` : "" }`; return aliases .map( (family) => `` ) .join(""); } function fontCssFamilyAliases(css: string): string { const aliases: string[] = []; const facePattern = /@font-face\s*\{[^}]*font-family\s*:\s*(['"]?)([^;'"}]+)\1[^}]*\}/gi; for (const match of css.matchAll(facePattern)) { const block = match[0]; const family = match[2]?.trim(); if (!family) continue; const weight = /font-weight\s*:\s*([^;}]+)/i.exec(block)?.[1]?.trim(); for (const alias of fontFamilyAliases(family, weight).filter( (item) => item !== family )) { aliases.push( block.replace( /font-family\s*:\s*(['"]?)([^;'"}]+)\1/i, `font-family:${escapeCssFont(alias)}` ) ); } } return aliases.join(""); } function fontFamilyAliases(family: string, weight?: string): string[] { const normalized = family.trim(); const aliases = new Set([normalized]); const alias = normalized .replace(/\s+(regular|bold\s*italic|bold|italic|black|semibold|semi\s*bold|medium|light)$/i, "") .trim(); if (alias && alias !== normalized && (weight || inferFontWeight(normalized))) { aliases.add(alias); } return [...aliases]; } function renderItem(item: JsonRecord, mode: RenderMode): string { if (isComponent(item)) { return renderGroup({ ...item, type: "group", children: item.elements }, mode); } switch (readString(item.type)) { case "rectangle": return `
`; case "ellipse": return `
`; case "line": return renderLine(item, mode); case "svg": return renderSvg(item, mode); case "image": return renderImage(item, mode); case "text": return renderText(item, mode); case "text-list": return renderTextList(item, mode); case "table": return renderTable(item, mode); case "container": return renderContainer(item, mode); case "flex": case "list-view": return renderFlex(item, mode); case "grid": case "grid-view": return renderGrid(item, mode); case "group": return renderGroup(item, mode); case "chart": return renderChart(item, mode); case "infographic": return renderInfographic(item, mode); default: if (Array.isArray(item.children)) return renderGroup(item, mode); if (readRecordOrNull(item.child)) return renderContainer(item, mode); return ""; } } function renderImage(item: JsonRecord, mode: RenderMode): string { const source = readString(item.data); if (!source) return ""; const color = normalizeChartColor(readString(item.color)); const clipPath = clipPathStyle(item); if (color && readBoolean(item.isIcon ?? item.is_icon)) { const maskUrl = cssUrl(source); const maskSize = imageMaskSize(item.fit); return `
`; } const fit = imageFit(item.fit); const focusStyle = imageFocusStyle(item); const cropTransformStyle = imageCropTransformStyle(item); if (cropTransformStyle) { return `
`; } return ``; } function renderText(item: JsonRecord, mode: RenderMode): string { const font = readRecord(item.font); const alignment = readRecord(item.alignment); const horizontal = readString(alignment.horizontal); const vertical = readString(alignment.vertical); const runs = normalizedRunsForHtml(item, font); const runHtml = runs .map((run) => { const runFont = { ...font, ...readRecord(run.font) }; return `${escapeHtml( readStringValue(run.text) )}`; }) .join(""); return `
${runHtml}
`; } function renderTextList(item: JsonRecord, mode: RenderMode): string { const marker = readString(item.marker); const tag = marker === "number" ? "ol" : "ul"; const font = readRecord(item.font); const entries = readArray(item.items) .map((entry) => { const runs = normalizedListRunsForHtml(entry, font); const html = runs .map( (run) => `${escapeHtml(readStringValue(run.text))}` ) .join(""); return `
  • ${html}
  • `; }) .join(""); const listStyle = `margin:0;padding-left:${marker === "none" ? 0 : 24}px;${marker === "none" ? "list-style-type:none;" : "" }`; return `
    <${tag} style="${listStyle}">${entries}
    `; } function renderTable(item: JsonRecord, mode: RenderMode): string { const rows = tableRows(item); if (!rows.length) { return `
    `; } const rowCount = Math.max(1, rows.length); const colCount = Math.max(1, ...rows.map((row) => row.length)); const tableFont = tableBaseFont(item); const cells = rows .flatMap((row, rowIndex) => Array.from({ length: colCount }, (_, colIndex) => { const cell = row[colIndex] ?? {}; const isHeader = rowIndex === 0; return `
    ${cellText(cell, tableFont, isHeader)}
    `; }) ) .join(""); return `
    ${cells}
    `; } function renderContainer(item: JsonRecord, mode: RenderMode): string { const child = readRecordOrNull(item.child); const alignment = readRecord(item.alignment); const style = `${frameStyle(item, mode)}${boxStyle(item)}${paddingStyle( readRecord(item.padding) )}display:flex;align-items:${verticalAlign( readString(alignment.vertical) )};justify-content:${horizontalAlign( readString(alignment.horizontal) )};${containerOverflowStyle(item, child)}`; return `
    ${ child ? renderItem(child, readRecordOrNull(child.position) ? "absolute" : "flow") : "" }
    `; } function containerOverflowStyle( item: JsonRecord, child: JsonRecord | null ): string { const overflow = readString(item.overflow); if (overflow === "hidden" || overflow === "visible") { return `overflow:${overflow}`; } if (readBoolean(item.clip)) return "overflow:hidden"; if (!child || readString(child.type) !== "image") return "overflow:visible"; const childHasClipPath = Boolean(readString(child.clip_path ?? child.clipPath)); const hasPositionedChild = Boolean(readRecordOrNull(child.position)); if (!hasPositionedChild) return "overflow:visible"; if (childHasClipPath) return "overflow:hidden"; const containerBox = readBox(item); const childBox = readBox(child); if (containerBox.width == null || containerBox.height == null) { return "overflow:visible"; } const epsilon = 0.01; const childOverflows = childBox.x < -epsilon || childBox.y < -epsilon || (childBox.width != null && childBox.x + childBox.width > containerBox.width + epsilon) || (childBox.height != null && childBox.y + childBox.height > containerBox.height + epsilon); return childOverflows ? "overflow:hidden" : "overflow:visible"; } function renderFlex(item: JsonRecord, mode: RenderMode): string { const direction = readString(item.direction) === "row" ? "row" : "column"; const children = readLayoutChildren(item) .map((child) => renderItem(readRecord(child), "flow")) .join(""); const gap = readNumber(item.gap) ?? 0; const style = `${frameStyle(item, mode)}${boxStyle(item)}${paddingStyle( readRecord(item.padding) )}display:flex;flex-direction:${direction};flex-wrap:${readBoolean(item.wrap) ? "wrap" : "nowrap"};align-items:${cssAlignment( readString(item.alignItems ?? item.align_items), "stretch" )};justify-content:${cssAlignment( readString(item.justifyContent ?? item.justify_content), "flex-start" )};gap:${cssNumber(gap)}px;column-gap:${cssNumber( readNumber(item.columnGap ?? item.column_gap) ?? gap )}px;row-gap:${cssNumber( readNumber(item.rowGap ?? item.row_gap) ?? gap )}px;overflow:visible`; return `
    ${children}
    `; } function renderGrid(item: JsonRecord, mode: RenderMode): string { const columns = Math.max(1, Math.floor(readNumber(item.columns) ?? 1)); const rows = readNumber(item.rows); const gap = readNumber(item.gap) ?? 0; const children = readLayoutChildren(item) .map((child) => renderItem(readRecord(child), "flow")) .join(""); const style = `${frameStyle(item, mode)}${boxStyle(item)}${paddingStyle( readRecord(item.padding) )}display:grid;grid-template-columns:repeat(${columns},minmax(0,1fr));${rows ? `grid-template-rows:repeat(${Math.max(1, Math.floor(rows))},minmax(0,1fr));` : "" }align-items:${cssAlignment( readString(item.alignItems ?? item.align_items), "stretch" )};justify-items:${cssAlignment( readString(item.justifyItems ?? item.justify_items), "stretch" )};column-gap:${cssNumber( readNumber(item.columnGap ?? item.column_gap) ?? gap )}px;row-gap:${cssNumber( readNumber(item.rowGap ?? item.row_gap) ?? gap )}px;overflow:visible`; return `
    ${children}
    `; } function readLayoutChildren(item: JsonRecord): unknown[] { const children = readArray(item.children); if (children.length) return children; const elements = readArray(item.elements); if (elements.length) return elements; const child = readRecordOrNull(item.item); const count = Math.max(0, Math.floor(readNumber(item.count) ?? 0)); return child && count ? Array.from({ length: count }, () => child) : []; } function renderGroup(item: JsonRecord, mode: RenderMode): string { const children = readArray(item.children).map(readRecord); const content = children.map((child) => renderItem(child, "absolute")).join(""); return `
    ${content}
    `; } function renderLine(item: JsonRecord, mode: RenderMode): string { const box = readBox(item); const stroke = readRecord(item.stroke); const color = colorWithOpacity( readString(stroke.color) ?? "#000000", readNumber(stroke.opacity) ); const width = Math.max(0, readNumber(stroke.width) ?? 1); const dash = readArray(stroke.dash) .map(readNumber) .filter((value): value is number => value != null) .join(" "); return `
    `; } function renderSvg(item: JsonRecord, mode: RenderMode): string { const svg = readStringValue(item.svg); if (!svg) return ""; return `
    ${svg}
    `; } function renderChart(item: JsonRecord, mode: RenderMode): string { const box = readBox(item); const width = Math.max(1, box.width ?? 1); const height = Math.max(1, box.height ?? 1); const config = chartConfig(item, height); return `
    `; } function renderInfographic(item: JsonRecord, mode: RenderMode): string { const kind = infographicKindFromValue( readString(item.infographicType ?? item.infographic_type) ); if (kind === "gauge") return renderGaugeInfographic(item, mode); return renderProgressBarInfographic(item, mode); } function renderProgressBarInfographic(item: JsonRecord, mode: RenderMode): string { const metrics = infographicMetrics(item); const highlightColor = infographicHighlightColor(item); const baseColor = infographicBaseColor(item); const fallbackSize = { width: 180, height: 40 }; const box = readBox(item, fallbackSize); const showLabel = (box.height ?? fallbackSize.height) >= 28; const label = showLabel ? `
    ${escapeHtml( metrics.label )}
    ` : ""; return `
    ${label}
    `; } function renderGaugeInfographic(item: JsonRecord, mode: RenderMode): string { const metrics = infographicMetrics(item); const highlightColor = infographicHighlightColor(item); const baseColor = infographicBaseColor(item); const fallbackSize = { width: 160, height: 96 }; const progressPath = metrics.ratio > 0 ? `` : ""; return `
    ${progressPath}${escapeHtml( metrics.label )}
    `; } function chartConfig(item: JsonRecord, height: number): JsonRecord { const chartKind = chartKindFromValue(readString(item.chartType ?? item.chart_type)); const data = normalizeChartData(item, chartKind); const primaryColor = safeChartColor(readString(item.color), DEFAULT_CHART_COLORS[0]); const colors = data.colors.length > 0 ? data.colors : [primaryColor]; const axisColor = safeChartColor( readString(item.axisColor ?? item.axis_color), "#98A2B3" ); const gridColor = safeChartColor( readString(item.gridColor ?? item.grid_color), axisColor ); const textColor = safeChartColor( readString(item.textColor ?? item.text_color ?? item.labelColor ?? item.label_color), "#475467" ); const titleColor = safeChartColor(readString(item.titleColor ?? item.title_color), "#344054"); const title = markdownToPlainChartText(readString(item.title) ?? ""); const fontSize = clamp(height * 0.033, 9, 18); const titleFontSize = clamp(height * 0.044, 11, 26); const valueFontSize = clamp(height * 0.029, 8, 15); const autoShowLegend = isPieLikeChart(chartKind) || data.series.length > 1 || Boolean(data.series[0]?.name && data.series[0].name !== "Series 1"); const showLegend = readOptionalBoolean( item.legend ?? item.showLegend, autoShowLegend ); const dataLabelPosition = readDataLabelPosition( Object.prototype.hasOwnProperty.call(item, "data_labels") ? item.data_labels : item.dataLabels ); const dataLabels = dataLabelPosition != null; const xAxisGrid = readOptionalBoolean( item.x_axis_grid ?? item.xAxisGrid ?? item.grid, true ); const yAxisGrid = readOptionalBoolean( item.y_axis_grid ?? item.yAxisGrid ?? item.grid, true ); const xAxis = readOptionalBoolean(item.x_axis ?? item.xAxis, true); const yAxis = readOptionalBoolean(item.y_axis ?? item.yAxis, true); const xAxisTitle = markdownToPlainChartText( readString( Object.prototype.hasOwnProperty.call(item, "x_axis_title") ? item.x_axis_title : item.xAxisTitle ) ?? "" ); const yAxisTitle = markdownToPlainChartText( readString( Object.prototype.hasOwnProperty.call(item, "y_axis_title") ? item.y_axis_title : item.yAxisTitle ) ?? "" ); const config: JsonRecord = { type: chartJsType(chartKind), data: { labels: data.categories, datasets: chartDatasets(chartKind, { ...data, colors }), }, options: { color: textColor, font: { family: CHART_FONT_FAMILY, }, indexAxis: isHorizontalChart(chartKind) ? "y" : "x", layout: { padding: isPieLikeChart(chartKind) ? { top: 16, right: 20, bottom: 12, left: 20 } : { top: 12, right: 22, bottom: 8, left: 12 }, }, responsive: false, maintainAspectRatio: false, animation: false, normalized: true, plugins: { legend: { display: showLegend, position: "bottom", labels: { boxWidth: Math.max(8, fontSize * 0.8), boxHeight: Math.max(8, fontSize * 0.8), color: textColor, font: { family: CHART_FONT_FAMILY, size: fontSize, weight: 600 }, padding: Math.max(8, fontSize), usePointStyle: true, }, }, title: { display: Boolean(title), text: title.split(/\r?\n/).filter(Boolean), color: titleColor, font: { family: CHART_FONT_FAMILY, size: titleFontSize, weight: "700", }, padding: { bottom: Math.max(16, titleFontSize * 0.8), top: 0, }, }, tooltip: { enabled: false }, presentonDataLabels: { enabled: dataLabels, color: textColor, fontFamily: CHART_FONT_FAMILY, fontSize: valueFontSize, horizontal: isHorizontalChart(chartKind), position: dataLabelPosition ?? "top", }, }, }, }; if (chartKind === "donut") { readRecord(config.options).cutout = "58%"; } else if (chartKind === "pie") { readRecord(config.options).cutout = "0%"; } else { readRecord(config.options).scales = chartScales({ axisColor, chartKind, fontSize, gridColor, xAxis, xAxisGrid, xAxisTitle, yAxis, yAxisGrid, yAxisTitle, }); } return config; } function chartDatasets(chartKind: ChartKind, data: NormalizedChartData): JsonRecord[] { if (chartKind === "pie" || chartKind === "donut") { const series = data.series[0]; if (!series) return []; return [ { label: series.name, data: series.values.map((value) => Math.max(0, value)), backgroundColor: series.values.map( (_, index) => data.colors[index % data.colors.length] ?? DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length] ), borderColor: "#FFFFFF", borderWidth: 1, hoverOffset: 0, }, ]; } if (chartKind === "polar_area") { const series = data.series.length ? data.series : [emptyChartSeries()]; return series.map((seriesItem) => { const colors = data.series.length === 1 ? categoryColors(seriesItem, data.colors) : seriesItem.values.map(() => seriesColor(seriesItem, data)); return { label: seriesItem.name, data: seriesItem.values, backgroundColor: colors.map((color) => withAlpha(color, 0.78)), borderColor: colors, borderWidth: 1, }; }); } if (chartKind === "scatter" || chartKind === "bubble") { return data.series.map((seriesItem) => { const colors = data.series.length === 1 ? categoryColors(seriesItem, data.colors) : [seriesColor(seriesItem, data)]; return { label: seriesItem.name, data: chartKind === "bubble" ? seriesItem.points.map((point) => ({ ...point, r: point.r ?? 6 })) : seriesItem.points.map(({ x, y }) => ({ x, y })), backgroundColor: colors.map((color) => withAlpha(color, 0.78)), borderColor: colors, borderWidth: 2, pointRadius: chartKind === "scatter" ? 4 : undefined, pointHoverRadius: 4, }; }); } const lineLike = chartKind === "line" || chartKind === "area"; return data.series.map((series, index) => { const color = seriesColor(series, data, index); const perCategoryColors = data.series.length === 1 && data.colors.length ? categoryColors(series, data.colors) : null; const barChart = isBarChart(chartKind); const stackedBarChart = isStackedChart(chartKind); const dataset: JsonRecord = { label: series.name, data: series.values, backgroundColor: chartKind === "area" ? withAlpha(color, 0.24) : lineLike ? color : perCategoryColors ?? color, borderColor: color, borderWidth: lineLike ? 3 : 0, borderRadius: barChart && stackedBarChart ? 7 : undefined, borderSkipped: barChart ? (stackedBarChart ? "start" : false) : undefined, fill: chartKind === "area", maxBarThickness: 62, presentonBarRadius: barChart && !stackedBarChart ? { horizontal: isHorizontalChart(chartKind), radius: 7 } : undefined, pointBackgroundColor: perCategoryColors ?? color, pointBorderColor: "#FFFFFF", pointBorderWidth: lineLike ? 1.5 : 0, pointRadius: lineLike ? 3.5 : 0, tension: lineLike ? 0.35 : 0, }; return dataset; }); } function normalizeChartData( item: JsonRecord, chartKind: ChartKind ): NormalizedChartData { const points = readArray(item.data) .map(readRecord) .map((point, index) => { const value = chartValue(point); return { label: readString(point.label) ?? `Value ${index + 1}`, value, point: chartPoint(point, index), color: normalizeChartColor(readString(point.color)), }; }) .filter( ( point ): point is { label: string; value: number; point: ChartPointData; color: string | null; } => Boolean(point) ); const series = readArray(item.series) .map(readRecord) .map((series, index) => { const rawValues = readArray(series.values ?? series.data); const values = rawValues.map(chartValue); return { name: readString(series.name) ?? `Series ${index + 1}`, points: rawValues.map((value, valueIndex) => chartPoint(value, valueIndex) ), values, }; }) .filter((series) => series.values.length); if (!series.length && points.length) { series.push({ name: readString(item.title) ?? "Series 1", points: points.map((point) => point.point), values: points.map((point) => point.value), }); } if (!series.length) { series.push(emptyChartSeries()); } if (isPieLikeChart(chartKind) && series.length > 1) { series.splice(1); } const maxLength = Math.min( 24, Math.max(1, ...series.map((item) => item.values.length), points.length) ); const categoryValues = readArray(item.categories); const categories = normalizeCategories( categoryValues.length ? categoryValues : points.map((point) => point.label), maxLength ); const colors = readColorArray(item.colors); const legacySeriesColors = colors.length ? [] : readColorArray(item.seriesColors ?? item.series_colors); const pointColors = points .map((point) => point.color) .filter((color): color is string => Boolean(color)); return { categories, colors: colors.length > 0 ? colors : legacySeriesColors.length > 0 ? legacySeriesColors : pointColors.length > 0 ? pointColors : [normalizeChartColor(readString(item.color)) ?? DEFAULT_CHART_COLORS[0]], series: series.map((item) => ({ ...item, values: padValues(item.values, categories.length), points: padPoints(item.points, categories.length, item.values), })), }; } function normalizeCategories(values: unknown[], length: number): string[] { return Array.from({ length }, (_, index) => { const value = values[index]; return readStringValue(value) || `Value ${index + 1}`; }); } function padValues(values: number[], length: number): number[] { return Array.from({ length }, (_, index) => values[index] ?? 0); } function padPoints( points: ChartPointData[], length: number, fallbackValues: number[] ): ChartPointData[] { return Array.from({ length }, (_, index) => { const point = points[index]; if (point) return point; return { x: index + 1, y: fallbackValues[index] ?? 0 }; }); } function readColorArray(value: unknown): string[] { return readArray(value) .map((item) => normalizeChartColor(readString(item))) .filter((color): color is string => Boolean(color)); } function normalizeChartColor(value: string | null): string | null { if (!value) return null; return safeChartColor(value, DEFAULT_CHART_COLORS[0]); } function normalizeChartKindValue(value: string | null): string { if (!value) return ""; return value .trim() .replace(/([a-z0-9])([A-Z])/g, "$1_$2") .toLowerCase() .replace(/[\s-]+/g, "_") .replace(/_+/g, "_"); } function chartKindFromValue(value: string | null): ChartKind { const normalized = value?.toLowerCase().replace(/[\s-]+/g, "_") ?? ""; if (normalized === "bubble") return "bubble"; if (normalized === "horizontal_bar" || normalized === "bar_horizontal") { return "horizontal_bar"; } if ( normalized === "horizontal_stacked_bar" || normalized === "stacked_horizontal_bar" ) { return "horizontal_stacked_bar"; } if ( normalized === "stacked_bar" || normalized === "bar_stacked" || normalized === "stacked" || normalized === "stacked_column" ) { return "stacked_bar"; } if (normalized === "line") return "line"; if (normalized === "area") return "area"; if (normalized === "pie") return "pie"; if (normalized === "donut" || normalized === "doughnut") return "donut"; if (normalized === "polar" || normalized === "polar_area") return "polar_area"; if (normalized === "radar") return "radar"; if (normalized === "scatter") return "scatter"; return "bar"; } function chartJsType(chartKind: ChartKind): string { if (chartKind === "donut") return "doughnut"; if (chartKind === "area") return "line"; if (chartKind === "polar_area") return "polarArea"; if ( chartKind === "horizontal_bar" || chartKind === "stacked_bar" || chartKind === "horizontal_stacked_bar" ) { return "bar"; } return chartKind; } function isPieLikeChart(chartKind: ChartKind): boolean { return chartKind === "pie" || chartKind === "donut"; } function isBarChart(chartKind: ChartKind): boolean { return ( chartKind === "bar" || chartKind === "horizontal_bar" || chartKind === "stacked_bar" || chartKind === "horizontal_stacked_bar" ); } function isHorizontalChart(chartKind: ChartKind): boolean { return ( chartKind === "horizontal_bar" || chartKind === "horizontal_stacked_bar" ); } function isStackedChart(chartKind: ChartKind): boolean { return chartKind === "stacked_bar" || chartKind === "horizontal_stacked_bar"; } function chartScales({ axisColor, chartKind, fontSize, gridColor, xAxis, xAxisGrid, xAxisTitle, yAxis, yAxisGrid, yAxisTitle, }: { axisColor: string; chartKind: ChartKind; fontSize: number; gridColor: string; xAxis: boolean; xAxisGrid: boolean; xAxisTitle: string; yAxis: boolean; yAxisGrid: boolean; yAxisTitle: string; }): JsonRecord | undefined { if (isPieLikeChart(chartKind) || chartKind === "polar_area") return undefined; if (chartKind === "radar") { return { r: { angleLines: { color: withAlpha(gridColor, xAxisGrid ? 0.35 : 0), display: xAxisGrid, }, beginAtZero: true, grid: { color: withAlpha(gridColor, yAxisGrid ? 0.35 : 0), display: yAxisGrid, }, pointLabels: { color: axisColor, display: xAxis, font: { family: CHART_FONT_FAMILY, size: fontSize, weight: 600 }, }, ticks: { backdropColor: "transparent", color: axisColor, display: yAxis, font: { family: CHART_FONT_FAMILY, size: Math.max(8, fontSize - 1), }, presentonFormat: true, }, }, }; } const horizontal = isHorizontalChart(chartKind); const stacked = isStackedChart(chartKind); const showCategoryGrid = horizontal ? xAxisGrid : yAxisGrid; const showLinearGrid = horizontal ? yAxisGrid : xAxisGrid; const showCategoryAxis = horizontal ? yAxis : xAxis; const showLinearAxis = horizontal ? xAxis : yAxis; const categoryAxis = { display: showCategoryAxis || showCategoryGrid, border: { color: axisColor, display: showCategoryAxis }, grid: { color: withAlpha(gridColor, showCategoryGrid ? 0.25 : 0), display: showCategoryGrid, drawTicks: showCategoryAxis, }, stacked, ticks: { color: axisColor, display: showCategoryAxis, font: { family: CHART_FONT_FAMILY, size: fontSize, weight: 600 }, maxRotation: 0, autoSkip: true, }, title: { color: axisColor, display: showCategoryAxis && Boolean(horizontal ? yAxisTitle : xAxisTitle), font: { family: CHART_FONT_FAMILY, size: fontSize, weight: 700 }, text: horizontal ? yAxisTitle : xAxisTitle, }, type: "category", }; const linearAxis = { beginAtZero: true, display: showLinearAxis || showLinearGrid, border: { color: axisColor, display: showLinearAxis }, grace: "8%", grid: { color: withAlpha(gridColor, showLinearGrid ? 0.35 : 0), display: showLinearGrid, drawTicks: showLinearAxis, }, stacked, ticks: { color: axisColor, display: showLinearAxis, font: { family: CHART_FONT_FAMILY, size: Math.max(8, fontSize - 2), weight: 600, }, presentonFormat: true, }, title: { color: axisColor, display: showLinearAxis && Boolean(horizontal ? xAxisTitle : yAxisTitle), font: { family: CHART_FONT_FAMILY, size: fontSize, weight: 700 }, text: horizontal ? xAxisTitle : yAxisTitle, }, type: "linear", }; if (chartKind === "scatter" || chartKind === "bubble") { return { x: { ...linearAxis, display: xAxis || yAxisGrid, border: { ...linearAxis.border, display: xAxis }, grid: { color: withAlpha(gridColor, yAxisGrid ? 0.35 : 0), display: yAxisGrid, drawTicks: xAxis, }, ticks: { ...linearAxis.ticks, display: xAxis }, title: { ...linearAxis.title, display: xAxis && Boolean(xAxisTitle), text: xAxisTitle, }, }, y: { ...linearAxis, display: yAxis || xAxisGrid, border: { ...linearAxis.border, display: yAxis }, grid: { color: withAlpha(gridColor, xAxisGrid ? 0.35 : 0), display: xAxisGrid, drawTicks: yAxis, }, ticks: { ...linearAxis.ticks, display: yAxis }, title: { ...linearAxis.title, display: yAxis && Boolean(yAxisTitle), text: yAxisTitle, }, }, }; } return horizontal ? { x: linearAxis, y: categoryAxis } : { x: categoryAxis, y: linearAxis }; } function chartValue(value: unknown): number { const direct = readNumber(value); if (direct != null) return direct; const record = readRecord(value); return ( readNumber(record.value) ?? readNumber(record.y) ?? readNumber(record.data) ?? 0 ); } function chartPoint(value: unknown, index: number): ChartPointData { const record = readRecord(value); const radius = readNumber(record.r ?? record.radius); return { x: readNumber(record.x) ?? index + 1, y: chartValue(value), ...(radius != null ? { r: radius } : {}), }; } function emptyChartSeries(): ChartSeriesData { return { name: "Series 1", points: [{ x: 1, y: 0 }], values: [0], }; } function seriesColor( series: ChartSeriesData, data: NormalizedChartData, index = data.series.indexOf(series) ): string { return ( data.colors[index % data.colors.length] ?? DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length] ); } function categoryColors(series: ChartSeriesData, colors: string[]): string[] { return series.values.map( (_, index) => colors[index % colors.length] ?? DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length] ); } function safeChartColor( value: string | null | undefined, fallback = DEFAULT_CHART_COLORS[0] ): string { const color = withHash(value) ?? fallback; if ( /^#[0-9A-Fa-f]{3}$/.test(color) || /^#[0-9A-Fa-f]{6}$/.test(color) || /^rgba?\(/i.test(color) ) { return color; } return fallback; } function withHash(value: string | null | undefined): string | null { if (!value) return null; const color = value.trim(); if (!color) return null; return color.startsWith("#") || /^rgba?\(/i.test(color) ? color : `#${color}`; } function withAlpha(color: string, alpha: number): string { const normalized = safeChartColor(color); const hex = normalized.match(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/); if (!hex) { const rgb = normalized.match(/^rgba?\(([^)]+)\)$/i); if (rgb) { const channels = rgb[1] .split(",") .slice(0, 3) .map((part) => part.trim()); return `rgba(${channels.join(", ")}, ${alpha})`; } return normalized; } const raw = hex[1].length === 3 ? hex[1] .split("") .map((char) => char + char) .join("") : hex[1]; const int = Number.parseInt(raw, 16); return `rgba(${(int >> 16) & 255}, ${(int >> 8) & 255}, ${int & 255}, ${alpha})`; } interface InfographicMetrics { ratio: number; label: string; } function infographicKindFromValue(value: string | null): InfographicKind { return value === "gauge" ? "gauge" : "progress_bar"; } function infographicMetrics(item: JsonRecord): InfographicMetrics { const rawMin = readNumber(item.minValue ?? item.min_value) ?? 0; const rawMax = readNumber(item.maxValue ?? item.max_value) ?? 100; const min = Math.min(rawMin, rawMax); const max = Math.max(rawMin, rawMax); const value = clamp(readNumber(item.value) ?? min, min, max); const ratio = max === min ? 0 : (value - min) / (max - min); return { ratio, label: formatInfographicNumber(value), }; } function infographicHighlightColor(item: JsonRecord): string { const fill = readRecord(item.fill); return ( normalizeChartColor( readString( item.highlightColor ?? item.highlight_color ?? item.color ?? fill.color ) ) ?? DEFAULT_CHART_COLORS[0] ); } function infographicBaseColor(item: JsonRecord): string { return ( normalizeChartColor(readString(item.baseColor ?? item.base_color)) ?? "#E5E7EB" ); } function describeGaugeArc( centerX: number, centerY: number, radius: number, ratio: number ): string { const start = gaugePoint(centerX, centerY, radius, 180); const end = gaugePoint( centerX, centerY, radius, 180 + clamp(ratio, 0, 1) * 180 ); return `M ${cssNumber(start.x)} ${cssNumber(start.y)} A ${cssNumber( radius )} ${cssNumber(radius)} 0 0 1 ${cssNumber(end.x)} ${cssNumber(end.y)}`; } function gaugePoint( centerX: number, centerY: number, radius: number, angleDegrees: number ): { x: number; y: number } { const radians = (angleDegrees * Math.PI) / 180; return { x: centerX + radius * Math.cos(radians), y: centerY + radius * Math.sin(radians), }; } function formatInfographicNumber(value: number): string { const rounded = Math.round(value * 100) / 100; return Object.is(rounded, -0) ? "0" : String(rounded); } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function readOptionalBoolean(value: unknown, fallback: boolean): boolean { return value == null ? fallback : readBoolean(value); } function readDataLabelPosition(value: unknown): DataLabelPosition | null { if (value === true) return "top"; if (value === false || value == null) return null; const text = readString(value); return text && DATA_LABEL_POSITIONS.has(text) ? (text as DataLabelPosition) : null; } function hasChartItem(item: JsonRecord): boolean { if (isComponent(item)) { return readArray(item.elements).map(readRecord).some(hasChartItem); } if (readString(item.type) === "chart") return true; if (Array.isArray(item.children)) { return readArray(item.children).map(readRecord).some(hasChartItem); } if (Array.isArray(item.elements)) { return readArray(item.elements).map(readRecord).some(hasChartItem); } const child = readRecordOrNull(item.child); if (child) return hasChartItem(child); const itemChild = readRecordOrNull(item.item); return itemChild ? hasChartItem(itemChild) : false; } function renderChartScripts(): string { const chartJsUrl = readChartJsUrl(); return ``; } function readChartJsUrl(): string { const runtime = globalThis as typeof globalThis & { process?: { env?: Record }; }; return ( runtime.process?.env?.NEXT_PUBLIC_CHART_JS_URL || runtime.process?.env?.CHART_JS_URL || DEFAULT_CHART_JS_URL ); } function chartRendererScript(): string { return ` (function(){ var state=window.__PRESENTON_JSON_CHARTS__={status:"pending"}; function finish(status,message){state.status=status;if(message)state.message=message} function readNumber(value){var parsed=Number(value);return Number.isFinite(parsed)?parsed:null} function chartValue(raw){if(typeof raw==="number")return raw;if(raw&&typeof raw==="object"){var value=raw.y!=null?raw.y:raw.value!=null?raw.value:raw.data;var numeric=readNumber(value);return numeric==null?0:numeric}var parsed=readNumber(raw);return parsed==null?0:parsed} function formatValue(value){if(!Number.isFinite(value))return "";if(Math.abs(value)>=1000&&typeof Intl!=="undefined"&&Intl.NumberFormat)return Intl.NumberFormat("en",{notation:"compact"}).format(value);return Math.abs(value)%1===0?String(value):String(Math.round(value*10)/10).replace(/\\.0$/,"")} function formatAxisTick(value){var numeric=Number(value);return Number.isFinite(numeric)?formatValue(numeric):String(value)} function hydrateScales(scales){if(!scales)return;Object.keys(scales).forEach(function(key){var scale=scales[key];if(!scale)return;if(scale.ticks&&scale.ticks.presentonFormat){scale.ticks.callback=formatAxisTick;delete scale.ticks.presentonFormat}if(scale.r&&scale.r.ticks&&scale.r.ticks.presentonFormat){scale.r.ticks.callback=formatAxisTick;delete scale.r.ticks.presentonFormat}})} function barBorderRadius(rawValue,horizontal,radius){var value=chartValue(rawValue);if(horizontal){return value<0?{bottomLeft:radius,bottomRight:0,topLeft:radius,topRight:0}:{bottomLeft:0,bottomRight:radius,topLeft:0,topRight:radius}}return value<0?{bottomLeft:radius,bottomRight:radius,topLeft:0,topRight:0}:{bottomLeft:0,bottomRight:0,topLeft:radius,topRight:radius}} function hydrateBarBorderRadii(config){var datasets=config&&config.data&&Array.isArray(config.data.datasets)?config.data.datasets:[];datasets.forEach(function(dataset){var options=dataset&&dataset.presentonBarRadius;if(!options)return;var radius=readNumber(options.radius);dataset.borderRadius=function(context){return barBorderRadius(context&&context.raw,!!options.horizontal,radius==null?7:radius)};delete dataset.presentonBarRadius})} function datasetBackgroundColor(dataset,index){var bg=dataset&&dataset.backgroundColor;var color=Array.isArray(bg)?bg[index]:bg;return typeof color==="string"?color:null} function clamp(value,min,max){return Math.min(Math.max(value,min),max)} function parseColor(color){if(!color)return null;var hex=String(color).match(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/);if(hex){var raw=hex[1].length===3?hex[1].split("").map(function(ch){return ch+ch}).join(""):hex[1];var value=Number.parseInt(raw,16);return[(value>>16)&255,(value>>8)&255,value&255,1]}var rgb=String(color).match(/^rgba?\\(([^)]+)\\)$/i);if(!rgb)return null;var channels=rgb[1].split(",").map(function(part){return Number(part.trim())});if(channels.length<3||channels.slice(0,3).some(Number.isNaN))return null;return[clamp(channels[0],0,255),clamp(channels[1],0,255),clamp(channels[2],0,255),clamp(Number.isFinite(channels[3])?channels[3]:1,0,1)]} function relativeLuminance(channels){var mapped=channels.map(function(channel){var n=channel/255;return n<=0.04045?n/12.92:Math.pow((n+0.055)/1.055,2.4)});return mapped[0]*0.2126+mapped[1]*0.7152+mapped[2]*0.0722} function contrastRatio(a,b){var lighter=Math.max(a,b);var darker=Math.min(a,b);return(lighter+0.05)/(darker+0.05)} function contrastTextColor(backgroundColor,fallback){var bg=parseColor(backgroundColor);if(!bg)return fallback;var composite=[bg[0],bg[1],bg[2]].map(function(channel){return channel*bg[3]+255*(1-bg[3])});var bgLum=relativeLuminance(composite);var dark=[16,24,40];var light=[255,255,255];var darkContrast=contrastRatio(bgLum,relativeLuminance(dark));var lightContrast=contrastRatio(bgLum,relativeLuminance(light));return lightContrast>=darkContrast?"#FFFFFF":"#101828"} function chartElementPoint(element){var x=readNumber(element&&element.x);var y=readNumber(element&&element.y);return x==null||y==null?null:{x:x,y:y}} function pointRadius(element){var options=element&&element.options||{};return Math.max(0,readNumber(options.radius)||readNumber(element&&element.radius)||3)} function labelBounds(x,y,width,height){var padding=2;return{left:x-width/2-padding,right:x+width/2+padding,top:y-height/2-padding,bottom:y+height/2+padding}} function boundsOverlap(a,b){return!(a.rightb.right||a.bottomb.bottom)} function fitsChartArea(bounds,area){return bounds.left>=area.left&&bounds.right<=area.right&&bounds.top>=area.top&&bounds.bottom<=area.bottom} function lineDirection(elements,index,datasetIndex){var current=readNumber(elements[index]&&elements[index].y);var prev=readNumber(elements[index-1]&&elements[index-1].y);var next=readNumber(elements[index+1]&&elements[index+1].y);if(current==null)return datasetIndex%2===0?-1:1;if(prev!=null&&next!=null){if(current<=prev&¤t<=next)return-1;if(current>=prev&¤t>=next)return 1}if(next!=null&&prev==null)return next=textWidth+padding*2&&height>=args.fontSize*1.35:height>=args.fontSize*1.65&&width>=textWidth+padding*2;var position=args.position==="outside"||!fits?"outside":args.position;if(position!=="outside"){ctx.fillStyle=contrastTextColor(args.color,args.outsideColor);if(args.horizontal){var hDirection=args.value<0?-1:1;var labelX=position==="base"?base+hDirection*(textWidth/2+padding):position==="top"?x-hDirection*(textWidth/2+padding):(x+base)/2;ctx.fillText(args.label,labelX,y);return}var vDirection=args.value<0?1:-1;var labelY=position==="base"?base+vDirection*(args.fontSize/2+padding):position==="top"?y-vDirection*(args.fontSize/2+padding):(y+base)/2;ctx.fillText(args.label,x,labelY);return}ctx.fillStyle=args.outsideColor;if(args.horizontal){var outsideDirection=args.value<0?-1:1;ctx.fillText(args.label,x+outsideDirection*(textWidth/2+padding),y);return}var outsideYDirection=args.value<0?1:-1;ctx.fillText(args.label,x,y+outsideYDirection*(args.fontSize/2+padding))} function drawPointLabel(args){var ctx=args.ctx;var point=chartElementPoint(args.element);if(!point)return;var radius=pointRadius(args.element);var textWidth=ctx.measureText(args.label).width;var textHeight=args.fontSize*1.15;var direction=args.lineLike?lineDirection(args.metaElements,args.index,args.datasetIndex):(args.index+args.datasetIndex)%2===0?-1:1;var vertical=radius+textHeight/2+5;var horizontal=radius+textWidth/2+5;if(args.position!=="outside"){var placed=args.position==="base"?{x:point.x,y:point.y+vertical}:args.position==="top"?{x:point.x,y:point.y-vertical}:{x:point.x,y:point.y};var placedBounds=labelBounds(placed.x,placed.y,textWidth,textHeight);if(fitsChartArea(placedBounds,args.chartArea)&&!args.occupied.some(function(existing){return boundsOverlap(placedBounds,existing)})){args.occupied.push(placedBounds);ctx.fillStyle=args.outsideColor;ctx.fillText(args.label,placed.x,placed.y);return}}var candidates=[{x:point.x,y:point.y+direction*vertical},{x:point.x,y:point.y-direction*vertical},{x:point.x+horizontal,y:point.y},{x:point.x-horizontal,y:point.y},{x:point.x+horizontal,y:point.y+direction*vertical},{x:point.x-horizontal,y:point.y+direction*vertical},{x:point.x+horizontal,y:point.y-direction*vertical},{x:point.x-horizontal,y:point.y-direction*vertical},{x:point.x,y:point.y+direction*vertical*1.7},{x:point.x,y:point.y-direction*vertical*1.7}];for(var i=0;i0){var angle=(startAngle+endAngle)/2;var ringWidth=Math.max(1,outerRadius-innerRadius);var textHeight=args.fontSize*1.15;var radius=args.position==="outside"?outerRadius+textHeight/2+7:args.position==="top"?Math.max(innerRadius+textHeight/2,outerRadius-textHeight/2-5):args.position==="base"?innerRadius>0?innerRadius+Math.min(ringWidth*0.25,textHeight+5):outerRadius*0.35:innerRadius+ringWidth/2;point={x:centerX+Math.cos(angle)*radius,y:centerY+Math.sin(angle)*radius}}else if(element&&typeof element.tooltipPosition==="function"){point=element.tooltipPosition(true)}if(!point)return;args.ctx.fillStyle=args.position==="outside"?args.outsideColor:contrastTextColor(args.color,args.outsideColor);args.ctx.fillText(args.label,point.x||0,point.y||0)} function isPointType(type){return type==="line"||type==="scatter"||type==="bubble"||type==="radar"} function isArcType(type){return type==="pie"||type==="doughnut"||type==="polarArea"} var dataLabelPlugin={id:"presentonDataLabels",afterDatasetsDraw:function(chart,args,options){if(!options||!options.enabled)return;var ctx=chart.ctx;var fontSize=options.fontSize||11;var outsideColor=options.color||"#475467";var position=options.position==="base"||options.position==="mid"||options.position==="outside"||options.position==="top"?options.position:"top";ctx.save();ctx.font="600 "+fontSize+"px "+(options.fontFamily||"Inter, Arial, sans-serif");ctx.textAlign="center";ctx.textBaseline="middle";var occupied=[];chart.data.datasets.forEach(function(dataset,datasetIndex){var meta=chart.getDatasetMeta(datasetIndex);if(meta.hidden)return;var metaType=String(meta.type||"");meta.data.forEach(function(element,index){var raw=Array.isArray(dataset.data)?dataset.data[index]:0;var value=chartValue(raw);var label=formatValue(value);if(!label)return;if(metaType==="bar"){drawBarLabel({color:datasetBackgroundColor(dataset,index),ctx:ctx,element:element,fontSize:fontSize,horizontal:!!options.horizontal,label:label,outsideColor:outsideColor,position:position,value:value});return}if(isPointType(metaType)){drawPointLabel({chartArea:chart.chartArea,ctx:ctx,datasetIndex:datasetIndex,element:element,fontSize:fontSize,index:index,label:label,lineLike:metaType==="line"||metaType==="radar",metaElements:meta.data,occupied:occupied,outsideColor:outsideColor,position:position});return}if(isArcType(metaType)){drawArcLabel({color:datasetBackgroundColor(dataset,index),ctx:ctx,element:element,fontSize:fontSize,label:label,outsideColor:outsideColor,position:position});return}var fallbackPosition=typeof element.tooltipPosition==="function"?element.tooltipPosition(true):null;if(!fallbackPosition)return;ctx.fillStyle=outsideColor;ctx.fillText(label,fallbackPosition.x||0,fallbackPosition.y||0)})});ctx.restore()}}; function render(){if(!window.Chart){finish("error","Chart.js failed to load");return}try{var Chart=window.Chart;Chart.register(dataLabelPlugin);document.querySelectorAll("canvas[data-presenton-chart]").forEach(function(canvas){var configText=canvas.getAttribute("data-chart-config");if(!configText)return;var config=JSON.parse(configText);config.options=config.options||{};config.options.animation=false;config.options.responsive=false;config.options.maintainAspectRatio=false;hydrateScales(config.options.scales);hydrateBarBorderRadii(config);var existing=typeof Chart.getChart==="function"?Chart.getChart(canvas):null;if(existing)existing.destroy();var chart=new Chart(canvas,config);if(typeof chart.update==="function")chart.update("none")});requestAnimationFrame(function(){finish("ready")})}catch(error){finish("error",error&&error.message?error.message:String(error))}} if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",render,{once:true})}else{render()} })(); `; } function frameAndBoxStyle(item: JsonRecord, mode: RenderMode, extra = ""): string { return `${frameStyle(item, mode)}${boxStyle(item)}${extra}`; } function frameStyle( item: JsonRecord, mode: RenderMode, fallbackSize?: { width: number; height: number } ): string { const box = readBox(item, fallbackSize); let style = `box-sizing:border-box;min-height:0;min-width:0;position:${mode === "absolute" ? "absolute" : "relative" };`; if (mode === "absolute") { style += `left:${cssNumber(box.x)}px;top:${cssNumber(box.y)}px;`; } if (box.width != null) style += `width:${cssNumber(box.width)}px;`; if (box.height != null) style += `height:${cssNumber(box.height)}px;`; return style; } function readBox( item: JsonRecord, fallbackSize?: { width: number; height: number } ): Box { const position = readRecord(item.position); const size = readRecord(item.size); return { x: readNumber(position.x) ?? 0, y: readNumber(position.y) ?? 0, width: readNumber(size.width) ?? fallbackSize?.width, height: readNumber(size.height) ?? fallbackSize?.height, }; } function childrenBounds( children: JsonRecord[] ): { width: number; height: number } { return children.reduce<{ width: number; height: number }>( (bounds, child) => { const box = readBox(child); return { width: Math.max(bounds.width, box.x + (box.width ?? 1)), height: Math.max(bounds.height, box.y + (box.height ?? 1)), }; }, { width: 1, height: 1 } ); } function boxStyle(item: JsonRecord): string { const fill = readRecord(item.fill); const stroke = readRecord(item.stroke); const shadow = readRecord(item.shadow); const radius = readRecord(item.borderRadius ?? item.border_radius); let style = transformStyle(item); const fillColor = readString(fill.color); if (fillColor) { style += `background-color:${escapeCssColor( colorWithOpacity(fillColor, readNumber(fill.opacity)) )};`; } const strokeColor = readString(stroke.color); const strokeWidth = readNumber(stroke.width); if (strokeColor || strokeWidth != null) { style += `border:${cssNumber(strokeWidth ?? 1)}px solid ${escapeCssColor( colorWithOpacity(strokeColor ?? "transparent", readNumber(stroke.opacity)) )};`; } const borderRadius = borderRadiusStyle(radius); if (borderRadius) style += `border-radius:${borderRadius};`; const shadowOpacity = Object.keys(shadow).length ? (readNumber(shadow.opacity) ?? 1) : 0; if (shadowOpacity > 0) { style += `box-shadow:${cssNumber( readNumber(shadow.offsetX ?? shadow.offset_x) ?? 0 )}px ${cssNumber(readNumber(shadow.offsetY ?? shadow.offset_y) ?? 0)}px ${cssNumber( readNumber(shadow.blur) ?? 0 )}px ${escapeCssColor( colorWithOpacity(readString(shadow.color) ?? "#000000", shadowOpacity) )};`; } const opacity = readNumber(item.opacity); if (opacity != null) style += `opacity:${cssNumber(opacity)};`; return style; } function transformStyle(item: JsonRecord): string { const rotation = readNumber(item.rotation); const flipH = readBoolean(item.flip_h ?? item.flipH); const flipV = readBoolean(item.flip_v ?? item.flipV); if (!rotation && !flipH && !flipV) return ""; const transforms = []; if (rotation) transforms.push(`rotate(${cssNumber(rotation)}deg)`); if (flipH) transforms.push("scaleX(-1)"); if (flipV) transforms.push("scaleY(-1)"); return `transform:${transforms.join(" ")};transform-origin:center;`; } function fontStyle(fontValue: unknown): string { const font = readRecord(fontValue); let style = `color:${escapeCssColor( colorWithOpacity(readString(font.color) ?? "#111827", readNumber(font.opacity)) )};`; const family = readString(font.family); const size = readNumber(font.size); if (family) style += `font-family:${escapeCssFont(family)};`; if (size != null) style += `font-size:${cssNumber(size)}px;`; if (readBoolean(font.italic)) style += "font-style:italic;"; if (readBoolean(font.bold)) style += "font-weight:700;"; const lineHeight = readNumber(font.lineHeight ?? font.line_height); if (lineHeight != null) style += `line-height:${cssNumber(lineHeight)};`; const letterSpacing = readNumber(font.letterSpacing ?? font.letter_spacing); if (letterSpacing != null) style += `letter-spacing:${cssNumber(letterSpacing)}px;`; return style; } function tableRows(item: JsonRecord): unknown[][] { const columns = readArray(item.columns); const bodyRows = readArray(item.rows).map(readArray); return (columns.length ? [columns, ...bodyRows] : bodyRows).filter((row) => Array.isArray(row) ); } function tableBaseFont(item: JsonRecord): JsonRecord { return { family: "Arial", size: 18, color: "#111827", line_height: 1.15, wrap: "word", ...readRecord(item.font), }; } function tableCellStyle( cellValue: unknown, header: boolean, tableFont: JsonRecord ): string { const cell = readRecord(cellValue); const cellFont = tableCellFont(cellValue, tableFont); const alignment = readString(cell.alignment) ?? readString(readRecord(cell.alignment).horizontal) ?? readString(readRecord(readRecord(cell.text).alignment).horizontal); const fill = readRecord(cell.color ?? cell.fill); const stroke = readRecord(cell.stroke); const fillColor = readString(fill.color); const background = fillColor ? colorWithOpacity(fillColor, readNumber(fill.opacity)) : "transparent"; let style = `${fontStyle(cellFont)}display:flex;align-items:center;justify-content:${horizontalAlign( alignment )};border:${cssNumber( readNumber(stroke.width) ?? 1 )}px solid ${escapeCssColor( colorWithOpacity(readString(stroke.color) ?? "#D1D5DB", readNumber(stroke.opacity)) )};min-height:0;min-width:0;overflow:hidden;padding:4px 6px;text-align:${textAlign( alignment )};vertical-align:middle;white-space:pre-wrap;word-break:break-word;`; if (header && !readBoolean(cellFont.bold)) style += "font-weight:700;"; style += `background:${escapeCssColor(background)};`; return style; } function textOverflowStyle(): string { return "overflow:visible;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;"; } function tableCellFont(cellValue: unknown, tableFont: JsonRecord): JsonRecord { if (typeof cellValue === "string" || typeof cellValue === "number") { return tableFont; } const cell = readRecord(cellValue); const firstRun = readRecord(readArray(cell.runs)[0]); const text = readRecord(cell.text); return { ...tableFont, ...readRecord(cell.font), ...(Object.keys(firstRun).length ? readRecord(firstRun.font) : {}), ...(Object.keys(text).length ? readRecord(text.font) : {}), }; } function cellText( cellValue: unknown, tableFont: JsonRecord, header: boolean ): string { if (typeof cellValue === "string" || typeof cellValue === "number") { return escapeHtml(readStringValue(cellValue)); } const cell = readRecord(cellValue); const fill = readRecord(cell.color ?? cell.fill); const fillColor = readString(fill.color) ? colorWithOpacity(readString(fill.color) ?? "", readNumber(fill.opacity)) : null; const directRuns = readArray(cell.runs).map(readRecord); if (directRuns.length) { const runs = normalizeRunsForHtml( directRuns, Object.prototype.hasOwnProperty.call(cell, "text") ? cell.text : joinedRunText(directRuns), cell.font, ); return runs .map((run) => { const runFont = readableTableFont( { ...tableFont, ...readRecord(cell.font), ...readRecord(run.font), ...(header ? { bold: true } : {}), }, fillColor, header ); return `${escapeHtml( readStringValue(run.text) )}`; }) .join(""); } const text = cell.text; if (typeof text === "string") { return `${escapeHtml(text)}`; } const textRecord = readRecord(text); const textRuns = normalizedRunsForHtml(textRecord, textRecord.font); if (textRuns.length) { return textRuns .map((run) => { const runFont = readableTableFont( { ...tableFont, ...readRecord(cell.font), ...readRecord(textRecord.font), ...readRecord(run.font), ...(header ? { bold: true } : {}), }, fillColor, header ); return `${escapeHtml( readStringValue(run.text) )}`; }) .join(""); } return `${escapeHtml(readStringValue(textRecord.text))}`; } function readableTableFont( font: JsonRecord, fillColor: string | null, header: boolean ): JsonRecord { if (header) return font; return { ...font, color: readableTableTextColor(readString(font.color), fillColor), }; } function readableTableTextColor( color: string | null, fill: string | null ): string { const textColor = normalizeReadableColor(color) ?? "#111827"; const textLuminance = colorLuminance(textColor); const fillLuminance = colorLuminance(fill); if (textLuminance == null || fillLuminance == null) return textColor; const lighter = Math.max(textLuminance, fillLuminance); const darker = Math.min(textLuminance, fillLuminance); const contrast = (lighter + 0.05) / (darker + 0.05); if (contrast >= 3) return textColor; return fillLuminance > 0.5 ? "#111827" : "#FFFFFF"; } function colorLuminance(color: string | null): number | null { const rgb = parseRgbColor(color); if (!rgb) return null; const [r, g, b] = rgb.map((channel) => { const normalized = channel / 255; return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } function parseRgbColor(color: string | null): [number, number, number] | null { const value = normalizeReadableColor(color); if (!value) return null; const hex = value.startsWith("#") ? value.slice(1) : value; if (/^[0-9a-fA-F]{3}$/.test(hex)) { return [ Number.parseInt(hex[0] + hex[0], 16), Number.parseInt(hex[1] + hex[1], 16), Number.parseInt(hex[2] + hex[2], 16), ]; } if (/^[0-9a-fA-F]{6}$/.test(hex)) { return [ Number.parseInt(hex.slice(0, 2), 16), Number.parseInt(hex.slice(2, 4), 16), Number.parseInt(hex.slice(4, 6), 16), ]; } const rgb = value.match( /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i ); if (!rgb) return null; return [ clamp(Number(rgb[1]), 0, 255), clamp(Number(rgb[2]), 0, 255), clamp(Number(rgb[3]), 0, 255), ]; } function normalizeReadableColor(color: string | null): string | null { if (!color) return null; const value = color.trim(); if (!value) return null; return value.startsWith("#") || value.startsWith("rgb") ? value : `#${value}`; } function readRuns(item: JsonRecord): JsonRecord[] { const runs = readArray(item.runs).map(readRecord); return runs.length ? runs : [{ text: readStringValue(item.text) }]; } function normalizedRunsForHtml(item: JsonRecord, fallbackFont: unknown): JsonRecord[] { const runs = readRuns(item); return normalizeRunsForHtml( runs, Object.prototype.hasOwnProperty.call(item, "text") ? item.text : joinedRunText(runs), item.font ?? fallbackFont, ); } function normalizedListRunsForHtml(value: unknown, fallbackFont: unknown): JsonRecord[] { const runs = readListRuns(value); const record = readRecord(value); return normalizeRunsForHtml( runs, Object.prototype.hasOwnProperty.call(record, "text") ? record.text : joinedRunText(runs), record.font ?? fallbackFont, ); } function normalizeRunsForHtml( runs: JsonRecord[], text: unknown, fallbackFont: unknown, ): JsonRecord[] { const normalized = normalizeRawTextMarkdownElement({ type: "text", font: fallbackFont, text: readStringValue(text), runs, }).runs; return normalized.map((run) => ({ text: run.text, font: run.font, })); } function joinedRunText(runs: JsonRecord[]): string { return runs.map((run) => readStringValue(run.text)).join(""); } function readListRuns(value: unknown): JsonRecord[] { if (Array.isArray(value)) return value.map(readRecord); const record = readRecord(value); const runs = readArray(record.runs).map(readRecord); if (runs.length) return runs; if (Object.prototype.hasOwnProperty.call(record, "text")) { return [{ text: readStringValue(record.text), font: record.font }]; } if ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" ) { return [{ text: readStringValue(value) }]; } return []; } function isComponent(item: JsonRecord): item is JsonRecord & { elements: unknown[] } { const type = readString(item.type); return Array.isArray(item.elements) && (!type || !ELEMENT_TYPES.has(type)); } function readRecord(value: unknown): JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as JsonRecord) : {}; } function readRecordOrNull(value: unknown): JsonRecord | null { const record = readRecord(value); return Object.keys(record).length ? record : null; } function readArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function readString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } function readStringValueOrNull(value: unknown): string | null { return typeof value === "string" && value.trim() ? value : null; } function readStringValue(value: unknown): string { return typeof value === "string" ? value : value == null ? "" : String(value); } function readNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function readBoolean(value: unknown): boolean { return value === true || value === "true" || value === "1"; } function readFontWeight(value: unknown): string | undefined { if (typeof value === "number" && Number.isFinite(value)) { return String(Math.round(value)); } const text = readString(value); if (!text) return undefined; const normalized = text.toLowerCase().replace(/\s+/g, ""); const namedWeights: Record = { thin: "100", extralight: "200", ultralight: "200", light: "300", regular: "400", normal: "400", medium: "500", semibold: "600", demibold: "600", bold: "700", extrabold: "800", ultrabold: "800", black: "900", heavy: "900", }; if (namedWeights[normalized]) return namedWeights[normalized]; return /^\d{3}$/.test(normalized) ? normalized : undefined; } function inferFontWeight(value: string): string | undefined { const normalized = decodeFontHint(value).toLowerCase(); if (/\b(black|heavy)\b/.test(normalized)) return "900"; if (/\b(extra|ultra)[\s_-]?bold\b/.test(normalized)) return "800"; if (/\bbold\b/.test(normalized)) return "700"; if (/\b(semi|demi)[\s_-]?bold\b/.test(normalized)) return "600"; if (/\bmedium\b/.test(normalized)) return "500"; if (/\bregular\b|\bnormal\b/.test(normalized)) return "400"; if (/\blight\b/.test(normalized)) return "300"; if (/\b(extra|ultra)[\s_-]?light\b/.test(normalized)) return "200"; if (/\bthin\b/.test(normalized)) return "100"; return undefined; } function readFontStyle(value: unknown): string | undefined { const text = readString(value)?.toLowerCase(); return text === "italic" || text === "oblique" || text === "normal" ? text : undefined; } function inferFontStyle(value: string): string | undefined { return /\bitalic\b/i.test(decodeFontHint(value)) ? "italic" : undefined; } function decodeFontHint(value: string): string { try { return decodeURIComponent(value); } catch { return value; } } function paddingStyle(padding: JsonRecord): string { return `padding:${cssNumber(readNumber(padding.top) ?? 0)}px ${cssNumber( readNumber(padding.right) ?? 0 )}px ${cssNumber(readNumber(padding.bottom) ?? 0)}px ${cssNumber( readNumber(padding.left) ?? 0 )}px;`; } function borderRadiusStyle(radius: JsonRecord): string { const tl = readNumber(radius.tl) ?? 0; const tr = readNumber(radius.tr) ?? tl; const br = readNumber(radius.br) ?? tl; const bl = readNumber(radius.bl) ?? tl; return tl || tr || br || bl ? `${cssNumber(tl)}px ${cssNumber(tr)}px ${cssNumber(br)}px ${cssNumber(bl)}px` : ""; } function imageFit(value: unknown): string { return value === "cover" || value === "fill" ? value : "contain"; } function imageMaskSize(value: unknown): string { const fit = imageFit(value); return fit === "fill" ? "100% 100%" : fit; } function imageCropScale(item: JsonRecord): number { const value = readNumber(item.crop_scale ?? item.cropScale); if (value == null) return 1; return clamp(value, 1, 6); } function imageCropTransformStyle(item: JsonRecord): string { const cropScale = imageCropScale(item); if (cropScale <= 1) return ""; return `transform:scale(${cssNumber(cropScale)});transform-origin:${ imageFocusValue(item) ?? "center" };`; } function imageFocusStyle(item: JsonRecord): string { const focus = imageFocusValue(item); return focus ? `object-position:${focus};` : ""; } function imageFocusValue(item: JsonRecord): string | null { const focus = readArray(item.focus); const rawX = item.focus_x ?? item.focusX ?? focus[0]; const rawY = item.focus_y ?? item.focusY ?? focus[1]; if (rawX == null && rawY == null) return null; const focusX = clamp(readNumber(rawX) ?? 50, 0, 100); const focusY = clamp(readNumber(rawY) ?? 50, 0, 100); return `${cssNumber(focusX)}% ${cssNumber(focusY)}%`; } function clipPathStyle(item: JsonRecord): string { const value = normalizeCssClipPath( readString(item.clippath ?? item.clipPath ?? item.clip_path) ); return value ? `clip-path:${value};-webkit-clip-path:${value};` : ""; } function normalizeCssClipPath(value: string | null): string | null { if (!value) return null; let normalized = value.replace(/\s+/g, " ").trim(); if (!normalized || normalized.toLowerCase() === "none") return null; const doubleQuotedPath = normalized.match(/^path\("([^"]*)"\)$/i); if (doubleQuotedPath) { normalized = `path('${doubleQuotedPath[1]}')`; } if (/[";{}<>\\]/.test(normalized)) return null; if (!/^[a-zA-Z0-9\s.,%()+\-_' ]+$/.test(normalized)) return null; const functionName = normalized.match(/^([a-z-]+)\(/i)?.[1]?.toLowerCase(); if ( !functionName || !["path", "polygon", "circle", "ellipse", "inset"].includes(functionName) || !normalized.endsWith(")") ) { return null; } if (functionName === "path" && !/^path\('[^']*'\)$/i.test(normalized)) { return null; } return normalized; } function horizontalAlign(value: string | null): string { return value === "center" ? "center" : value === "right" ? "flex-end" : "flex-start"; } function verticalAlign(value: string | null): string { return value === "middle" || value === "center" ? "center" : value === "bottom" ? "flex-end" : "flex-start"; } function textAlign(value: string | null): string { return value === "center" || value === "right" ? value : "left"; } function cssAlignment(value: string | null, fallback: string): string { return value === "flex-start" || value === "flex-end" || value === "center" || value === "stretch" ? value : fallback; } function normalizeCssColor(color: string): string { const normalized = color.trim(); const hex = normalized.match(/^#?([0-9a-fA-F]{6})$/)?.[1]; return hex ? `#${hex}` : normalized; } function colorWithOpacity(color: string, opacity: number | null): string { const normalized = normalizeCssColor(color); if (opacity == null || opacity >= 1 || normalized === "transparent") return normalized; const hex = normalized.replace("#", ""); if (!/^[0-9a-fA-F]{6}$/.test(hex)) return normalized; const value = Number.parseInt(hex, 16); return `rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${Math.max( 0, opacity )})`; } function cssNumber(value: number): string { return Number.isFinite(value) ? String(value) : "0"; } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function escapeAttribute(value: string): string { return escapeHtml(value); } function escapeScriptText(value: string): string { return value.replaceAll("