2365 lines
82 KiB
TypeScript
2365 lines
82 KiB
TypeScript
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<string, unknown>;
|
|
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 `<!doctype html>
|
|
<html><head><meta charset="utf-8">${fontAssetTags}<style>
|
|
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:${bg}}
|
|
body{font-family:Arial,Helvetica,sans-serif}
|
|
*,*::before,*::after{box-sizing:border-box}
|
|
</style></head><body>${slideRoot}${chartScripts}</body></html>`;
|
|
}
|
|
|
|
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 `<div class="relative overflow-hidden" data-template-v2-html-slide="true" style="box-sizing:border-box;position:relative;width:${cssNumber(
|
|
width
|
|
)}px;height:${cssNumber(
|
|
height
|
|
)}px;overflow:hidden;background:${background};font-family:Arial,Helvetica,sans-serif">${content}</div>`;
|
|
}
|
|
|
|
function renderFontAssetTags(fonts: unknown): string {
|
|
const css = readStringValueOrNull(fonts);
|
|
if (css) {
|
|
return `<style>${escapeStyleText(css)}${fontCssFamilyAliases(css)}</style>`;
|
|
}
|
|
|
|
const tags: string[] = [];
|
|
const records = readRecord(fonts);
|
|
const embeddedCss = readStringValueOrNull(records.css ?? records.font_css);
|
|
if (embeddedCss) {
|
|
tags.push(
|
|
`<style>${escapeStyleText(embeddedCss)}${fontCssFamilyAliases(embeddedCss)}</style>`
|
|
);
|
|
}
|
|
|
|
const faceEntries = readArray(records.fonts).length ? records.fonts : fonts;
|
|
tags.push(...normalizeFontFaces(faceEntries).map(renderFontFaceDefinition));
|
|
|
|
const stylesheets = normalizeFontStylesheetUrls(faceEntries);
|
|
tags.push(
|
|
...stylesheets.map(
|
|
(url) =>
|
|
`<link rel="stylesheet" href="${escapeAttribute(resolveBackendAssetUrl(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) =>
|
|
`<style>@font-face{font-family:${escapeCssFont(
|
|
family
|
|
)};src:${src};font-weight:${definition.weight ?? "400"};font-style:${definition.style ?? "normal"
|
|
};font-display:swap}</style>`
|
|
)
|
|
.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 `<div style="${frameAndBoxStyle(item, mode)}"></div>`;
|
|
case "ellipse":
|
|
return `<div style="${frameAndBoxStyle(item, mode, "border-radius:50%")}"></div>`;
|
|
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 `<div style="${frameStyle(item, mode)}${boxStyle(
|
|
item
|
|
)}color:${escapeCssColor(
|
|
color
|
|
)};background:currentColor;-webkit-mask:${maskUrl} center/${maskSize} no-repeat;mask:${maskUrl} center/${maskSize} no-repeat;${clipPath}"></div>`;
|
|
}
|
|
const fit = imageFit(item.fit);
|
|
const focusStyle = imageFocusStyle(item);
|
|
const cropTransformStyle = imageCropTransformStyle(item);
|
|
if (cropTransformStyle) {
|
|
return `<div style="${frameStyle(item, mode)}${boxStyle(
|
|
item
|
|
)}${clipPath}overflow:hidden;"><img alt="" src="${escapeAttribute(
|
|
source
|
|
)}" style="display:block;max-width:none;max-height:none;height:100%;width:100%;object-fit:${fit};${focusStyle}${cropTransformStyle}"></div>`;
|
|
}
|
|
return `<img alt="" src="${escapeAttribute(source)}" style="${frameStyle(
|
|
item,
|
|
mode
|
|
)}${boxStyle(
|
|
item
|
|
)}display:block;max-width:none;max-height:none;object-fit:${fit};${focusStyle}${clipPath}">`;
|
|
}
|
|
|
|
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 `<span style="${fontStyle(runFont)}">${escapeHtml(
|
|
readStringValue(run.text)
|
|
)}</span>`;
|
|
})
|
|
.join("");
|
|
|
|
return `<div style="${frameStyle(item, mode)}${transformStyle(item)}${fontStyle(font)}display:flex;align-items:${verticalAlign(
|
|
vertical
|
|
)};justify-content:${horizontalAlign(horizontal)};line-height:${cssNumber(
|
|
readNumber(font.lineHeight ?? font.line_height) ?? 1.1
|
|
)};${textOverflowStyle()}text-align:${textAlign(horizontal)};"><span style="display:block;width:100%">${runHtml}</span></div>`;
|
|
}
|
|
|
|
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) =>
|
|
`<span style="${fontStyle({
|
|
...font,
|
|
...readRecord(run.font),
|
|
})}">${escapeHtml(readStringValue(run.text))}</span>`
|
|
)
|
|
.join("");
|
|
return `<li style="${textOverflowStyle()}">${html}</li>`;
|
|
})
|
|
.join("");
|
|
const listStyle = `margin:0;padding-left:${marker === "none" ? 0 : 24}px;${marker === "none" ? "list-style-type:none;" : ""
|
|
}`;
|
|
|
|
return `<div style="${frameStyle(item, mode)}${transformStyle(item)}${fontStyle(
|
|
font
|
|
)}${textOverflowStyle()}"><${tag} style="${listStyle}">${entries}</${tag}></div>`;
|
|
}
|
|
|
|
function renderTable(item: JsonRecord, mode: RenderMode): string {
|
|
const rows = tableRows(item);
|
|
if (!rows.length) {
|
|
return `<div style="${frameStyle(
|
|
item,
|
|
mode
|
|
)}${transformStyle(item)}overflow:hidden"></div>`;
|
|
}
|
|
|
|
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 `<div style="${tableCellStyle(
|
|
cell,
|
|
isHeader,
|
|
tableFont
|
|
)}">${cellText(cell, tableFont, isHeader)}</div>`;
|
|
})
|
|
)
|
|
.join("");
|
|
return `<div style="${frameStyle(
|
|
item,
|
|
mode
|
|
)}${transformStyle(item)}display:grid;grid-template-columns:repeat(${colCount},minmax(0,1fr));grid-template-rows:repeat(${rowCount},minmax(0,1fr));overflow:hidden">${cells}</div>`;
|
|
}
|
|
|
|
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 `<div style="${style}">${
|
|
child ? renderItem(child, readRecordOrNull(child.position) ? "absolute" : "flow") : ""
|
|
}</div>`;
|
|
}
|
|
|
|
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 `<div style="${style}">${children}</div>`;
|
|
}
|
|
|
|
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 `<div style="${style}">${children}</div>`;
|
|
}
|
|
|
|
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 `<div style="${frameStyle(item, mode, childrenBounds(children))}${boxStyle(
|
|
item
|
|
)}overflow:visible">${content}</div>`;
|
|
}
|
|
|
|
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 `<div style="${frameStyle(item, mode)}${transformStyle(item)}overflow:visible"><svg width="100%" height="100%" viewBox="0 0 ${cssNumber(
|
|
box.width ?? 1
|
|
)} ${cssNumber(box.height ?? 1)}" preserveAspectRatio="none" style="display:block;overflow:visible"><line x1="0" y1="0" x2="${cssNumber(
|
|
box.width ?? 1
|
|
)}" y2="${cssNumber(box.height ?? 1)}" stroke="${escapeAttribute(
|
|
color
|
|
)}" stroke-width="${cssNumber(width)}"${dash ? ` stroke-dasharray="${dash}"` : ""}/></svg></div>`;
|
|
}
|
|
|
|
function renderSvg(item: JsonRecord, mode: RenderMode): string {
|
|
const svg = readStringValue(item.svg);
|
|
if (!svg) return "";
|
|
return `<div style="${frameStyle(item, mode)}${transformStyle(
|
|
item
|
|
)}overflow:hidden">${svg}</div>`;
|
|
}
|
|
|
|
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 `<div style="${frameStyle(item, mode)}${transformStyle(
|
|
item
|
|
)}overflow:hidden"><canvas data-presenton-chart="true" data-chart-config="${escapeAttribute(
|
|
JSON.stringify(config)
|
|
)}" width="${cssNumber(Math.round(width))}" height="${cssNumber(
|
|
Math.round(height)
|
|
)}" style="display:block;width:100%;height:100%"></canvas></div>`;
|
|
}
|
|
|
|
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
|
|
? `<div style="color:#111827;font-size:${cssNumber(
|
|
Math.max(
|
|
10,
|
|
Math.min(16, Math.round((box.height ?? fallbackSize.height) * 0.3))
|
|
)
|
|
)}px;font-weight:700;line-height:1;text-align:right">${escapeHtml(
|
|
metrics.label
|
|
)}</div>`
|
|
: "";
|
|
|
|
return `<div style="${frameStyle(item, mode, fallbackSize)}${transformStyle(
|
|
item
|
|
)}display:flex;flex-direction:column;gap:6px;justify-content:center;overflow:hidden"><div style="position:relative;width:100%;height:${cssNumber(
|
|
Math.max(
|
|
6,
|
|
Math.min(18, Math.round((box.height ?? fallbackSize.height) * 0.35))
|
|
)
|
|
)}px;border-radius:999px;background:${escapeCssColor(
|
|
baseColor
|
|
)};overflow:hidden"><div style="height:100%;width:${cssNumber(
|
|
metrics.ratio * 100
|
|
)}%;border-radius:inherit;background:${escapeCssColor(highlightColor)}"></div></div>${label}</div>`;
|
|
}
|
|
|
|
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
|
|
? `<path d="${escapeAttribute(
|
|
describeGaugeArc(60, 60, 48, metrics.ratio)
|
|
)}" fill="none" stroke="${escapeAttribute(
|
|
escapeCssColor(highlightColor)
|
|
)}" stroke-width="12" stroke-linecap="round"/>`
|
|
: "";
|
|
|
|
return `<div style="${frameStyle(item, mode, fallbackSize)}${transformStyle(
|
|
item
|
|
)}overflow:hidden"><svg width="100%" height="100%" viewBox="0 0 120 72" preserveAspectRatio="xMidYMid meet" style="display:block"><path d="M 12 60 A 48 48 0 0 1 108 60" fill="none" stroke="${escapeAttribute(
|
|
escapeCssColor(baseColor)
|
|
)}" stroke-width="12" stroke-linecap="round"/>${progressPath}<text x="60" y="52" text-anchor="middle" fill="#111827" font-family="Arial, Helvetica, sans-serif" font-size="18" font-weight="700">${escapeHtml(
|
|
metrics.label
|
|
)}</text></svg></div>`;
|
|
}
|
|
|
|
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 `<script src="${escapeAttribute(chartJsUrl)}"></script><script>${escapeScriptText(
|
|
chartRendererScript()
|
|
)}</script>`;
|
|
}
|
|
|
|
function readChartJsUrl(): string {
|
|
const runtime = globalThis as typeof globalThis & {
|
|
process?: { env?: Record<string, string | undefined> };
|
|
};
|
|
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.right<b.left||a.left>b.right||a.bottom<b.top||a.top>b.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<current?1:-1;if(prev!=null&&next==null)return prev<current?1:-1;return datasetIndex%2===0?-1:1}
|
|
function drawBarLabel(args){var ctx=args.ctx;var element=args.element;var x=readNumber(element&&element.x);var y=readNumber(element&&element.y);var base=readNumber(element&&element.base);var width=Math.abs(readNumber(element&&element.width)||0);var height=Math.abs(readNumber(element&&element.height)||0);if(x==null||y==null||base==null)return;var textWidth=ctx.measureText(args.label).width;var padding=5;var fits=args.horizontal?width>=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;i<candidates.length;i++){var candidate=candidates[i];var bounds=labelBounds(candidate.x,candidate.y,textWidth,textHeight);if(!fitsChartArea(bounds,args.chartArea))continue;if(args.occupied.some(function(existing){return boundsOverlap(bounds,existing)}))continue;args.occupied.push(bounds);ctx.fillStyle=args.outsideColor;ctx.fillText(args.label,candidate.x,candidate.y);return}}
|
|
function drawArcLabel(args){var element=args.element;var centerX=readNumber(element&&element.x);var centerY=readNumber(element&&element.y);var startAngle=readNumber(element&&element.startAngle);var endAngle=readNumber(element&&element.endAngle);var innerRadius=Math.max(0,readNumber(element&&element.innerRadius)||0);var outerRadius=Math.max(innerRadius,readNumber(element&&element.outerRadius)||0);var point=null;if(centerX!=null&¢erY!=null&&startAngle!=null&&endAngle!=null&&outerRadius>0){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 `<span style="${fontStyle(runFont)}">${escapeHtml(
|
|
readStringValue(run.text)
|
|
)}</span>`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
const text = cell.text;
|
|
if (typeof text === "string") {
|
|
return `<span style="${fontStyle(
|
|
readableTableFont(
|
|
{ ...tableFont, ...readRecord(cell.font), ...(header ? { bold: true } : {}) },
|
|
fillColor,
|
|
header
|
|
)
|
|
)}">${escapeHtml(text)}</span>`;
|
|
}
|
|
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 `<span style="${fontStyle(runFont)}">${escapeHtml(
|
|
readStringValue(run.text)
|
|
)}</span>`;
|
|
})
|
|
.join("");
|
|
}
|
|
return `<span style="${fontStyle(
|
|
readableTableFont(
|
|
{ ...tableFont, ...readRecord(cell.font), ...(header ? { bold: true } : {}) },
|
|
fillColor,
|
|
header
|
|
)
|
|
)}">${escapeHtml(readStringValue(textRecord.text))}</span>`;
|
|
}
|
|
|
|
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<string, string> = {
|
|
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("</script", "<\\/script").replaceAll("<!--", "<\\!--");
|
|
}
|
|
|
|
function escapeStyleText(value: string): string {
|
|
return value.replaceAll("</style", "<\\/style");
|
|
}
|
|
|
|
function escapeCssColor(value: string): string {
|
|
return /^[#(),.%\s\w-]+$/.test(value) ? value : "transparent";
|
|
}
|
|
|
|
function escapeCssFont(value: string): string {
|
|
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
}
|
|
|
|
function cssUrl(value: string): string {
|
|
return `url('${escapeCssUrl(value)}')`;
|
|
}
|
|
|
|
function escapeCssUrl(value: string): string {
|
|
return value
|
|
.replaceAll("\\", "\\\\")
|
|
.replaceAll('"', '\\"')
|
|
.replaceAll("'", "\\'")
|
|
.replaceAll("\n", "")
|
|
.replaceAll("\r", "");
|
|
}
|
|
|
|
function isFontStylesheetUrl(url: string): boolean {
|
|
return /\.css(\?|$)/i.test(url) || /fonts\.googleapis\.com/.test(url);
|
|
}
|