Files
2026-07-13 13:08:41 +08:00

1610 lines
47 KiB
TypeScript

import { resolveBackendAssetUrl } from "@/utils/api";
import { chartDataFromSeriesWithColors } from "@/components/slide-editor/charts/chart-data";
import { renderMarkdownTextRuns } from "@/components/slide-editor/text/markdown-text";
import {
EDITOR_STAGE_HEIGHT,
EDITOR_STAGE_WIDTH,
type Alignment,
type BorderRadius,
type ChartSeries,
type DataLabelPosition,
type Deck,
type DesignVariable,
type Fill,
type Font,
type GroupElement,
type LayoutAlignment,
type LayoutItem,
type Padding,
type Shadow,
type Slide,
type SlideElement,
type Stroke,
type TableCell,
type TextElement,
type TextListItem,
type TextRun,
} from "@/components/slide-editor/types";
const MIN_ELEMENT_SIZE = 1;
const MAX_BORDER_RADIUS = 128;
type UnknownRecord = Record<string, unknown>;
type AdaptedPosition = { x: number; y: number };
type AdaptedSize = { width: number; height: number };
type AdaptedBaseElement = {
decorative?: boolean | null;
position?: AdaptedPosition | null;
size?: AdaptedSize | null;
rotation?: number | null;
opacity?: number | null;
shadow?: Shadow | null;
component_id?: string | null;
component_instance_id?: string | null;
component_description?: string | null;
component_slot?: string | null;
design_variables?: DesignVariable[] | null;
layout?: LayoutItem | null;
};
type AdaptedRequiredBaseElement = AdaptedBaseElement & {
position: AdaptedPosition;
size: AdaptedSize;
};
export type TemplateV2Layout = {
id?: unknown;
description?: unknown;
elements?: unknown;
components?: unknown;
};
export type TemplateV2ImportResponse = {
id?: unknown;
name?: unknown;
description?: unknown;
merged_components?: unknown;
raw_layouts?: unknown;
layouts?: unknown;
assets?: unknown;
};
export type GeneratedTemplateV2PresentationResponse = {
id?: unknown;
title?: unknown;
description?: unknown;
layout?: unknown;
slides?: unknown;
};
const LAYOUT_ALIGNMENT_VALUES = new Set([
"flex-start",
"flex-end",
"center",
"stretch",
]);
const DATA_LABEL_POSITIONS = new Set(["base", "mid", "top", "outside"]);
export function adaptTemplateV2ResponseToDeck(
template: TemplateV2ImportResponse,
): Deck {
const layouts = extractRenderableLayouts(template);
if (layouts.length === 0) {
throw new Error("Template response did not include any layouts.");
}
const slides = layouts.slice(0, 50).map(adaptLayoutToSlide);
const deck = {
title: truncateString(readString(template.name) ?? "Imported template", 90),
description:
truncateString(readString(template.description) ?? "", 1200) || null,
slides,
} satisfies Deck;
return deck;
}
export function normalizeTemplateV2Fonts(
template: TemplateV2ImportResponse,
fallbackFonts: Record<string, string> = {},
): Record<string, string> {
const assets = asRecord(template.assets);
const assetFonts = asRecord(assets?.fonts);
const fonts =
assetFonts && Object.keys(assetFonts).length > 0 ? assetFonts : fallbackFonts;
return Object.fromEntries(
Object.entries(fonts)
.filter(
(entry): entry is [string, string] =>
typeof entry[0] === "string" &&
entry[0].trim().length > 0 &&
typeof entry[1] === "string" &&
entry[1].trim().length > 0,
)
.map(([name, url]) => [name, resolveBackendAssetUrl(url)]),
);
}
export function adaptGeneratedTemplateV2PresentationToDeck(
presentation: GeneratedTemplateV2PresentationResponse,
): Deck {
const presentationRecord = asRecord(presentation) ?? {};
const layoutPayload = readValue(presentationRecord, "layout");
const layouts = extractTemplateV2Layouts(layoutPayload);
const layoutById = new Map(
layouts
.map((layout) => [readString(layout.id), layout] as const)
.filter((entry): entry is [string, TemplateV2Layout] => Boolean(entry[0])),
);
const generatedSlides = readArray(presentationRecord, "slides")
.filter(isRecord)
.sort((a, b) => (readNumber(a, "index") ?? 0) - (readNumber(b, "index") ?? 0));
if (layouts.length === 0 && generatedSlides.length === 0) {
throw new Error("Generated presentation did not include template v2 slides.");
}
const slides =
generatedSlides.length > 0
? generatedSlides.slice(0, 50).map((slide, index) => {
const uiLayout = readGeneratedSlideUiLayout(slide);
if (uiLayout) {
return adaptLayoutToSlide(uiLayout, index);
}
const layoutId = readString(slide.layout);
const layout =
(layoutId ? layoutById.get(layoutId) : null) ??
layouts[index % layouts.length];
if (!layout) {
throw new Error(
`Generated slide ${index + 1} did not include a renderable template v2 layout.`,
);
}
return adaptLayoutToSlide(layout, index);
})
: layouts.slice(0, 50).map(adaptLayoutToSlide);
const deck = {
title: truncateString(
readString(presentationRecord.title) ??
readString(presentationRecord.id) ??
"Generated presentation",
90,
),
description:
truncateString(readString(presentationRecord.description) ?? "", 1200) ||
null,
slides,
} satisfies Deck;
return deck;
}
function readGeneratedSlideUiLayout(slide: UnknownRecord): TemplateV2Layout | null {
const ui = asRecord(readValue(slide, "ui"));
return ui ? (ui as TemplateV2Layout) : null;
}
export function extractTemplateV2Layouts(value: unknown): TemplateV2Layout[] {
if (Array.isArray(value)) {
return value.filter(isRecord) as TemplateV2Layout[];
}
const record = asRecord(value);
if (Array.isArray(record?.layouts)) {
return record.layouts.filter(isRecord) as TemplateV2Layout[];
}
const nestedLayouts = asRecord(record?.layouts);
if (Array.isArray(nestedLayouts?.layouts)) {
return nestedLayouts.layouts.filter(isRecord) as TemplateV2Layout[];
}
return [];
}
function extractRenderableLayouts(
template: TemplateV2ImportResponse,
): TemplateV2Layout[] {
const layouts = extractTemplateV2Layouts(template.layouts);
if (layouts.length > 0) return layouts;
return extractTemplateV2Layouts(template.raw_layouts);
}
export function adaptTemplateV2LayoutToSlide(
layout: TemplateV2Layout,
index = 0,
): Slide {
return adaptLayoutToSlide(layout, index);
}
export function withEqualTemplateV2FlowChildSizes(
element: Record<string, unknown>,
): unknown[] {
const children = readArray(element, "children");
if (children.length === 0) return children;
const size = readRecord(element, "size");
const width = readNumber(size ?? {}, "width");
const height = readNumber(size ?? {}, "height");
if (width == null || height == null) return children;
const padding = readRecord(element, "padding");
const contentWidth = Math.max(
MIN_ELEMENT_SIZE,
width -
(readNumber(padding ?? {}, "left") ?? 0) -
(readNumber(padding ?? {}, "right") ?? 0),
);
const contentHeight = Math.max(
MIN_ELEMENT_SIZE,
height -
(readNumber(padding ?? {}, "top") ?? 0) -
(readNumber(padding ?? {}, "bottom") ?? 0),
);
const type = readString(element.type);
if (type === "flex" || type === "list-view") {
const childTypes = children.map((child) => readString(asRecord(child)?.type));
if (
childTypes.some((childType) => childType == null) ||
new Set(childTypes).size > 1
) {
return children;
}
const direction = readString(element.direction) === "row" ? "row" : "column";
const gap =
direction === "row"
? readNumber(element, "column_gap") ??
readNumber(element, "gap") ??
0
: readNumber(element, "row_gap") ??
readNumber(element, "gap") ??
0;
const availableMain = Math.max(
MIN_ELEMENT_SIZE,
(direction === "row" ? contentWidth : contentHeight) -
gap * Math.max(0, children.length - 1),
);
const mainSize = availableMain / children.length;
return children.map((child) =>
withTemplateV2ChildSize(
child,
direction === "row" ? mainSize : contentWidth,
direction === "row" ? contentHeight : mainSize,
),
);
}
if (type === "grid" || type === "grid-view") {
const columns = Math.max(
1,
Math.min(children.length, Math.trunc(readNumber(element, "columns") ?? 1)),
);
const rows = Math.max(
1,
Math.trunc(readNumber(element, "rows") ?? Math.ceil(children.length / columns)),
);
const columnGap =
readNumber(element, "column_gap") ??
readNumber(element, "gap") ??
0;
const rowGap =
readNumber(element, "row_gap") ??
readNumber(element, "gap") ??
0;
const cellWidth = Math.max(
MIN_ELEMENT_SIZE,
(contentWidth - columnGap * Math.max(0, columns - 1)) / columns,
);
const cellHeight = Math.max(
MIN_ELEMENT_SIZE,
(contentHeight - rowGap * Math.max(0, rows - 1)) / rows,
);
return children.map((child) =>
withTemplateV2ChildSize(child, cellWidth, cellHeight),
);
}
return children;
}
function withTemplateV2ChildSize(
child: unknown,
width: number,
height: number,
) {
const record = asRecord(child);
if (!record) return child;
const size: UnknownRecord = readRecord(record, "size") ?? {};
return {
...record,
size: {
...size,
width: readNumber(size, "width") ?? round(width),
height: readNumber(size, "height") ?? round(height),
},
};
}
export function normalizeTemplateV2Slide(slide: Slide): Slide {
return {
...slide,
elements: slide.elements.map(normalizeTemplateV2Element),
};
}
function adaptLayoutToSlide(layout: TemplateV2Layout, index: number): Slide {
const rawElements = extractLayoutElements(layout);
const elements = rawElements
.slice(0, 80)
.map((element) => adaptElement(element))
.filter((element): element is SlideElement => Boolean(element));
const slideElements =
elements.length > 0 ? elements : [invisibleFallbackElement()];
return normalizeTemplateV2Slide({
title: titleFromLayout(layout, index),
background: backgroundFromElements(slideElements),
elements: slideElements,
});
}
function normalizeTemplateV2Element(element: SlideElement): SlideElement {
if ("children" in element && Array.isArray(element.children)) {
const next = {
...element,
children: element.children.map(normalizeTemplateV2Element),
} as SlideElement;
return next.type === "group" ? normalizeAuthorInfoCardGroup(next) : next;
}
if (element.type === "container" && element.child) {
return {
...element,
child: normalizeTemplateV2Element(element.child),
};
}
return element;
}
function normalizeAuthorInfoCardGroup(group: GroupElement): GroupElement {
if (!isAuthorInfoCard(group)) return group;
const cardRight = authorInfoCardRightEdge(group);
let changed = false;
const children = group.children.map((child) => {
if (!isAuthorInfoText(child) || !child.position || !child.size) {
return child;
}
const availableWidth = Math.max(
MIN_ELEMENT_SIZE,
cardRight - child.position.x - 24,
);
if (availableWidth <= child.size.width + 1) return child;
changed = true;
return {
...child,
size: {
...child.size,
width: Math.min(EDITOR_STAGE_WIDTH, availableWidth),
},
};
});
return changed ? { ...group, children } : group;
}
function isAuthorInfoCard(group: GroupElement) {
return (
group.component_id === "author_info_card" ||
group.component_slot === "author_info_card" ||
group.component_instance_id?.startsWith("author_info_card:")
);
}
function authorInfoCardRightEdge(group: GroupElement) {
const background = group.children.find(
(child) =>
child.type === "rectangle" &&
(child.position?.x ?? 0) <= 0.05 &&
(child.position?.y ?? 0) <= 0.05 &&
child.size?.width,
);
if (background?.size?.width) {
return (background.position?.x ?? 0) + background.size.width;
}
return group.size.width;
}
function isAuthorInfoText(
element: SlideElement,
): element is Extract<SlideElement, { type: "text" }> {
return (
element.type === "text" &&
(element.component_slot === "author_name" || element.component_slot === "date")
);
}
function extractLayoutElements(layout: TemplateV2Layout): unknown[] {
if (Array.isArray(layout.elements) && layout.elements.length > 0) {
return layout.elements;
}
const components = readArray(layout as UnknownRecord, "components");
return components
.map((component, componentIndex) =>
componentToGroupElement(component, componentIndex),
)
.filter((element): element is UnknownRecord => Boolean(asRecord(element)));
}
function componentToGroupElement(
component: unknown,
componentIndex: number,
): UnknownRecord | null {
const raw = asRecord(component);
if (!raw) return null;
const elements = readArray(raw, "elements");
if (elements.length === 0) return null;
const renderedElements = elements.filter(isRecord);
const componentFrame = rawComponentFrame(raw);
const frame = componentFrame ?? rawElementsFrame(renderedElements);
if (!frame) return null;
const componentId =
truncateString(readString(raw.id) ?? `component_${componentIndex}`, 120) ||
`component_${componentIndex}`;
const componentDescription = truncateString(
readString(raw.description) ?? "",
600,
);
return stripNullish({
type: "group",
position: { x: frame.x, y: frame.y },
size: { width: frame.width, height: frame.height },
children: componentFrame
? renderedElements.map((element) =>
frameTopLevelFlowElementToComponent(element, componentFrame),
)
: renderedElements.map((element) =>
localizeRawElementToFrame(element, frame),
),
name: componentId,
component_id: componentId,
component_instance_id: `${componentId}:${componentIndex}`,
component_description: componentDescription || null,
design_variables: readArray(raw, "design_variables"),
});
}
export function extractTemplateV2MergedComponents(template: unknown): unknown[] {
const raw = asRecord(template);
if (!raw) return [];
const value = readValue(raw, "merged_components");
if (Array.isArray(value)) {
return value.filter(isRecord);
}
const record = asRecord(value);
if (!record) return [];
const components = readArray(record, "components");
if (components.length > 0) {
return components.filter(isRecord);
}
return Object.values(record).filter(isRecord);
}
export function adaptTemplateV2ComponentToElement(
component: unknown,
componentIndex = 0,
): SlideElement | null {
const rawGroup = componentToGroupElement(component, componentIndex);
if (!rawGroup) return null;
const element = adaptElement(rawGroup);
return element ? normalizeTemplateV2Element(element) : null;
}
function frameTopLevelFlowElementToComponent(
element: UnknownRecord,
frame: RawFrame,
): UnknownRecord {
const type = readString(element.type);
if (
(type !== "grid" && type !== "flex") ||
readRecord(element, "position") ||
readRecord(element, "size")
) {
return element;
}
return {
...element,
position: { x: 0, y: 0 },
size: { width: frame.width, height: frame.height },
};
}
function rawElementsFrame(elements: UnknownRecord[]) {
return mergeRawElementFrames(
elements
.map((element) => rawElementFrame(element))
.filter((frame): frame is RawFrame => Boolean(frame)),
);
}
type RawFrame = { x: number; y: number; width: number; height: number };
function rawElementFrame(
element: UnknownRecord,
offsetX = 0,
offsetY = 0,
): RawFrame | null {
const position = readRecord(element, "position");
const size = readRecord(element, "size");
const x = readNumber(position ?? {}, "x");
const y = readNumber(position ?? {}, "y");
const width = readNumber(size ?? {}, "width");
const height = readNumber(size ?? {}, "height");
const hasPosition = x != null && y != null;
const frame =
hasPosition && width != null && height != null
? {
x: offsetX + x,
y: offsetY + y,
width: Math.max(1, width),
height: Math.max(1, height),
}
: null;
const childOffsetX = hasPosition ? offsetX + x : offsetX;
const childOffsetY = hasPosition ? offsetY + y : offsetY;
const childFrames = rawElementChildren(element)
.map((child) => rawElementFrame(child, childOffsetX, childOffsetY))
.filter((childFrame): childFrame is RawFrame => Boolean(childFrame));
return mergeRawElementFrames(frame ? [frame, ...childFrames] : childFrames);
}
function rawElementChildren(element: UnknownRecord): UnknownRecord[] {
const children = readArray(element, "children").filter(isRecord);
const child = asRecord(readValue(element, "child"));
return [...children, ...(child ? [child] : [])];
}
function mergeRawElementFrames(frames: RawFrame[]): RawFrame | null {
if (frames.length === 0) {
return null;
}
const minX = Math.min(...frames.map((frame) => frame.x));
const minY = Math.min(...frames.map((frame) => frame.y));
const maxX = Math.max(...frames.map((frame) => frame.x + frame.width));
const maxY = Math.max(...frames.map((frame) => frame.y + frame.height));
return {
x: minX,
y: minY,
width: Math.max(1, maxX - minX),
height: Math.max(1, maxY - minY),
};
}
function rawComponentFrame(component: UnknownRecord) {
const position = readRecord(component, "position");
const size = readRecord(component, "size");
const x = readNumber(position ?? {}, "x");
const y = readNumber(position ?? {}, "y");
const width = readNumber(size ?? {}, "width");
const height = readNumber(size ?? {}, "height");
if (x == null || y == null || width == null || height == null) return null;
return {
x,
y,
width: Math.max(1, width),
height: Math.max(1, height),
};
}
function localizeRawElementToFrame(
element: UnknownRecord,
frame: { x: number; y: number },
) {
const position = readRecord(element, "position");
const localized: UnknownRecord = position
? {
...element,
position: {
...position,
x: (readNumber(position, "x") ?? 0) - frame.x,
y: (readNumber(position, "y") ?? 0) - frame.y,
},
}
: { ...element };
if (!position) {
const children = readArray(localized, "children");
if (children.length > 0) {
localized.children = children.map((child) => {
const rawChild = asRecord(child);
return rawChild ? localizeRawElementToFrame(rawChild, frame) : child;
});
}
const child = asRecord(readValue(localized, "child"));
if (child) {
localized.child = localizeRawElementToFrame(child, frame);
}
}
return localized;
}
function adaptElement(value: unknown): SlideElement | null {
const raw = asRecord(value);
const type = readString(raw?.type);
if (!raw || !type) return null;
switch (type) {
case "text":
return adaptText(raw);
case "container":
return adaptContainer(raw);
case "image":
return adaptImage(raw);
case "text-list":
return adaptTextList(raw);
case "table":
return adaptTable(raw);
case "rectangle":
return adaptRectangle(raw);
case "ellipse":
return adaptEllipse(raw);
case "line":
return adaptLine(raw);
case "chart":
return adaptChart(raw);
case "infographic":
return adaptInfographic(raw);
case "flex":
return adaptFlex(raw);
case "grid":
return adaptGrid(raw);
case "group":
return adaptGroup(raw);
default:
return null;
}
}
function adaptText(raw: UnknownRecord): SlideElement {
const font = adaptFont(readRecord(raw, "font"));
const element: TextElement = {
...baseElement(raw),
type: "text",
font: font && font.line_height == null ? { ...font, line_height: 1 } : font,
alignment: adaptAlignment(readRecord(raw, "alignment")),
fill: adaptFill(readRecord(raw, "fill")),
stroke: adaptStroke(readRecord(raw, "stroke")),
runs: adaptTextRuns(readArray(raw, "runs"), readString(raw.text)),
max_length: readNumber(raw, "max_length"),
min_length: readNumber(raw, "min_length"),
};
return element;
}
function adaptContainer(raw: UnknownRecord): SlideElement {
const child = adaptElement(readValue(raw, "child"));
const base = baseElement(raw);
const padding = adaptPadding(readRecord(raw, "padding"));
const inferredSize =
!base.size && child?.size ? paddedChildSize(child.size, padding) : null;
return {
...base,
...(inferredSize ? { size: inferredSize } : {}),
type: "container",
alignment: adaptAlignment(readRecord(raw, "alignment")),
fill: adaptFill(readRecord(raw, "fill")),
stroke: adaptStroke(readRecord(raw, "stroke")),
border_radius: adaptBorderRadius(readRecord(raw, "border_radius")),
padding,
shadow: adaptShadow(readRecord(raw, "shadow")),
child,
};
}
function paddedChildSize(
size: AdaptedSize,
padding: Padding | null,
): AdaptedSize {
return {
width: clamp(
round(size.width + (padding?.left ?? 0) + (padding?.right ?? 0)),
MIN_ELEMENT_SIZE,
EDITOR_STAGE_WIDTH,
),
height: clamp(
round(size.height + (padding?.top ?? 0) + (padding?.bottom ?? 0)),
MIN_ELEMENT_SIZE,
EDITOR_STAGE_HEIGHT,
),
};
}
function adaptImage(raw: UnknownRecord): SlideElement {
const data = readString(raw.data);
return {
...baseElement(raw),
type: "image",
flip_h: readBoolean(raw, "flip_h"),
flip_v: readBoolean(raw, "flip_v"),
data: data ? resolveBackendAssetUrl(data) : null,
name: truncateString(readString(raw.name) ?? "", 120) || null,
fit: readEnum(raw, ["contain", "cover", "fill"], "fit"),
focus_x: readNumber(raw, "focus_x"),
focus_y: readNumber(raw, "focus_y"),
crop_scale: readNumber(raw, "crop_scale"),
border_radius: adaptBorderRadius(readRecord(raw, "border_radius")),
clippath: readString(raw.clippath ?? raw.clipPath ?? raw.clip_path),
color: readString(raw.color),
is_icon: readBoolean(raw, "is_icon"),
};
}
function adaptTextList(raw: UnknownRecord): SlideElement {
const font = adaptFont(readRecord(raw, "font"));
const marker = readEnum(raw, ["bullet", "number", "none"], "marker");
return {
...baseElement(raw),
type: "text-list",
font,
marker,
items: adaptTextListItems(readArray(raw, "items")),
max_items: readNumber(raw, "max_items"),
min_items: readNumber(raw, "min_items"),
max_item_length: readNumber(raw, "max_item_length"),
min_item_length: readNumber(raw, "min_item_length"),
};
}
function adaptTable(raw: UnknownRecord): SlideElement {
const columns = adaptTableCells(readArray(raw, "columns")).slice(0, 6);
const rows = readArray(raw, "rows")
.map((row) => adaptTableCells(Array.isArray(row) ? row : []))
.filter((row) => row.length > 0)
.slice(0, 7);
return {
...baseElement(raw),
type: "table",
font: adaptFont(readRecord(raw, "font")),
columns,
rows: rows.length > 0 ? rows : [columns],
max_columns: readNumber(raw, "max_columns"),
min_columns: readNumber(raw, "min_columns"),
max_rows: readNumber(raw, "max_rows"),
min_rows: readNumber(raw, "min_rows"),
};
}
function adaptRectangle(raw: UnknownRecord): SlideElement | null {
const base = baseElement(raw);
const fill = adaptFill(readRecord(raw, "fill"));
const stroke = adaptStroke(readRecord(raw, "stroke"));
if (!hasVisiblePaint(fill, stroke, base.opacity)) return null;
return {
...base,
type: "rectangle",
fill,
stroke,
border_radius: adaptBorderRadius(readRecord(raw, "border_radius")),
};
}
function adaptEllipse(raw: UnknownRecord): SlideElement | null {
const base = baseElement(raw);
const fill = adaptFill(readRecord(raw, "fill"));
const stroke = adaptStroke(readRecord(raw, "stroke"));
if (!hasVisiblePaint(fill, stroke, base.opacity)) return null;
return {
...base,
type: "ellipse",
fill,
stroke,
};
}
function adaptLine(raw: UnknownRecord): SlideElement {
return {
...baseElement(raw),
type: "line",
stroke:
adaptStroke(readRecord(raw, "stroke")) ?? {
color: "000000",
width: 1,
},
};
}
function adaptChart(raw: UnknownRecord): SlideElement {
const categories = adaptChartCategories(readArray(raw, "categories"));
const series = readArray(raw, "series")
.map(adaptChartSeries)
.filter((item): item is ChartSeries => item != null);
const chartType =
readEnum(
raw,
[
"area",
"bar",
"bubble",
"donut",
"horizontal_bar",
"horizontal_stacked_bar",
"line",
"pie",
"polar_area",
"radar",
"scatter",
"stacked_bar",
],
"chart_type",
) ??
"bar";
const supportedSeries =
chartType === "pie" || chartType === "donut"
? series.slice(0, 1)
: series;
const colors = readArray(raw, "colors")
.map(readColor)
.filter((item): item is string => Boolean(item))
.slice(0, 12);
const color = colors[0] ?? null;
const data = chartDataFromSeriesWithColors(
categories,
supportedSeries,
colors,
supportedSeries.length <= 1,
).slice(0, 8);
const dataLabels = readDataLabelPosition(
raw,
Object.prototype.hasOwnProperty.call(raw, "data_labels")
? "data_labels"
: "dataLabels",
);
return {
...baseElement(raw),
type: "chart",
chart_type: chartType,
data: data.length > 0 ? data : [{ label: "Data", value: 0 }],
title: truncateString(readString(raw.title) ?? "", 80) || null,
title_color: readColor(readValue(raw, "title_color") ?? raw.titleColor),
color,
axis_color: readColor(readValue(raw, "axis_color")),
grid_color: readColor(readValue(raw, "grid_color")),
data_labels: dataLabels,
legend: readBoolean(raw, "legend"),
colors,
x_axis: readBoolean(raw, "x_axis"),
y_axis: readBoolean(raw, "y_axis"),
x_axis_grid: readBoolean(raw, "x_axis_grid"),
y_axis_grid: readBoolean(raw, "y_axis_grid"),
x_axis_title:
truncateString(
readString(readValue(raw, "x_axis_title")) ?? "",
80,
) || null,
y_axis_title:
truncateString(
readString(readValue(raw, "y_axis_title")) ?? "",
80,
) || null,
categories,
series: supportedSeries,
source: truncateString(readString(raw.source) ?? "", 120) || null,
};
}
function adaptInfographic(raw: UnknownRecord): SlideElement {
const minValue = readNumber(raw, "min_value") ?? 0;
const rawMaxValue = readNumber(raw, "max_value") ?? 100;
const maxValue =
rawMaxValue === minValue ? minValue + 1 : rawMaxValue;
return {
...baseElement(raw),
type: "infographic",
infographic_type:
readEnum(
raw,
["progress_bar", "gauge"],
"infographic_type",
) ?? "gauge",
min_value: minValue,
max_value: maxValue,
value: readNumber(raw, "value") ?? minValue,
base_color: readColor(readValue(raw, "base_color")),
highlight_color: readColor(
readValue(raw, "highlight_color"),
),
};
}
function adaptFlex(raw: UnknownRecord): SlideElement {
return {
...requiredBaseElement(raw),
type: "flex",
direction: readEnum(raw, ["row", "column"], "direction") ?? "column",
wrap: readBoolean(raw, "wrap"),
align_items: readLayoutAlignment(raw, "align_items"),
justify_content: readLayoutAlignment(raw, "justify_content"),
padding: adaptPadding(readRecord(raw, "padding")),
gap: readNumber(raw, "gap"),
column_gap: readNumber(raw, "column_gap"),
row_gap: readNumber(raw, "row_gap"),
children: withEqualTemplateV2FlowChildSizes(raw)
.map(adaptElement)
.filter(Boolean) as SlideElement[],
max_children: readNumber(raw, "max_children"),
min_children: readNumber(raw, "min_children"),
};
}
function adaptGrid(raw: UnknownRecord): SlideElement {
return {
...requiredBaseElement(raw),
type: "grid",
columns: positiveInteger(readNumber(raw, "columns"), 1),
rows: positiveIntegerOrNull(readNumber(raw, "rows")),
gap: readNumber(raw, "gap"),
column_gap: readNumber(raw, "column_gap"),
row_gap: readNumber(raw, "row_gap"),
align_items: readLayoutAlignment(raw, "align_items"),
justify_items: readLayoutAlignment(raw, "justify_items"),
padding: adaptPadding(readRecord(raw, "padding")),
children: withEqualTemplateV2FlowChildSizes(raw)
.map(adaptElement)
.filter(Boolean) as SlideElement[],
max_children: readNumber(raw, "max_children"),
min_children: readNumber(raw, "min_children"),
};
}
function adaptGroup(raw: UnknownRecord): SlideElement {
const children = readArray(raw, "children")
.map(adaptElement)
.filter(Boolean) as SlideElement[];
const base = baseElement(raw);
const frame = groupFrame(base, children);
return {
...base,
...frame,
type: "group",
children,
max_children: readNumber(raw, "max_children"),
min_children: readNumber(raw, "min_children"),
};
}
function groupFrame(
base: AdaptedBaseElement,
children: SlideElement[],
): AdaptedRequiredBaseElement {
const bounds = childrenBounds(children);
return {
...base,
position: base.position ?? { x: 0, y: 0 },
size: base.size ?? bounds,
};
}
function childrenBounds(children: SlideElement[]): AdaptedSize {
if (children.length === 0) {
return { width: MIN_ELEMENT_SIZE, height: MIN_ELEMENT_SIZE };
}
const bounds = children.reduce(
(acc, child) => {
const x = child.position?.x ?? 0;
const y = child.position?.y ?? 0;
const width = child.size?.width ?? MIN_ELEMENT_SIZE;
const height = child.size?.height ?? MIN_ELEMENT_SIZE;
return {
width: Math.max(acc.width, x + width),
height: Math.max(acc.height, y + height),
};
},
{ width: MIN_ELEMENT_SIZE, height: MIN_ELEMENT_SIZE },
);
return {
width: clamp(round(bounds.width), MIN_ELEMENT_SIZE, EDITOR_STAGE_WIDTH),
height: clamp(round(bounds.height), MIN_ELEMENT_SIZE, EDITOR_STAGE_HEIGHT),
};
}
function baseElement(
raw: UnknownRecord,
options: { requireFrame?: boolean } = {},
): AdaptedBaseElement {
const base: AdaptedBaseElement = {};
const position = adaptPosition(readRecord(raw, "position"));
const size = adaptSize(readRecord(raw, "size"));
const componentSlot =
readString(readValue(raw, "component_slot")) ??
readString(raw.name);
const decorative = readDecorative(raw);
if (decorative != null) base.decorative = decorative;
if (position) base.position = position;
if (size) base.size = size;
if (options.requireFrame && !position) base.position = { x: 0, y: 0 };
if (options.requireFrame && !size) base.size = { width: EDITOR_STAGE_WIDTH, height: EDITOR_STAGE_HEIGHT };
if (options.requireFrame && !readRecord(raw, "layout")) {
base.layout = { grow: 1, shrink: 1 };
}
if (readNumber(raw, "rotation") != null) {
base.rotation = clamp(readNumber(raw, "rotation") ?? 0, -360, 360);
}
if (readNumber(raw, "opacity") != null) {
base.opacity = clamp(readNumber(raw, "opacity") ?? 1, 0, 1);
}
if (componentSlot) base.component_slot = truncateString(componentSlot, 120);
const designVariables = adaptDesignVariables(
readArray(raw, "design_variables"),
);
if (designVariables.length > 0) base.design_variables = designVariables;
if (readString(readValue(raw, "component_id"))) {
base.component_id = truncateString(
readString(readValue(raw, "component_id")) ?? "",
120,
);
}
if (readString(readValue(raw, "component_instance_id"))) {
base.component_instance_id = truncateString(
readString(readValue(raw, "component_instance_id")) ?? "",
160,
);
}
if (readString(readValue(raw, "component_description"))) {
base.component_description = truncateString(
readString(readValue(raw, "component_description")) ?? "",
600,
);
}
const shadow = adaptShadow(readRecord(raw, "shadow"));
if (shadow) base.shadow = shadow;
const layout = adaptLayoutItem(readRecord(raw, "layout"));
if (layout) base.layout = { ...(base.layout ?? {}), ...layout };
return base;
}
function requiredBaseElement(raw: UnknownRecord): AdaptedRequiredBaseElement {
return baseElement(raw, { requireFrame: true }) as AdaptedRequiredBaseElement;
}
function adaptDesignVariables(values: unknown[]): DesignVariable[] {
return values
.map((value): DesignVariable | null => {
const raw = asRecord(value);
if (!raw) return null;
const name = truncateString(readString(raw.name) ?? "", 120);
const type = truncateString(readString(raw.type) ?? "", 40);
const options: unknown[] = readArray(raw, "options")
.map(toJsonValue)
.filter((option) => option !== undefined);
const effect = readArray(raw, "effect")
.map((item) => {
const rawEffect = asRecord(item);
if (!rawEffect) return null;
const path = truncateString(
readString(readValue(rawEffect, "path")) ?? "",
240,
);
const expression = truncateString(
readString(readValue(rawEffect, "effect")) ?? "",
120,
);
return path && expression ? { path, effect: expression } : null;
})
.filter((item): item is DesignVariable["effect"][number] =>
Boolean(item),
);
if (!name || options.length === 0 || effect.length === 0) return null;
return {
name,
...(type ? { type } : {}),
options,
effect,
} satisfies DesignVariable;
})
.filter((item): item is DesignVariable => Boolean(item));
}
function toJsonValue(value: unknown): unknown {
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (Array.isArray(value)) {
return value.map(toJsonValue).filter((item) => item !== undefined);
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value)
.map(([key, item]) => [key, toJsonValue(item)] as const)
.filter(([, item]) => item !== undefined),
);
}
return undefined;
}
function adaptPosition(value: UnknownRecord | null): { x: number; y: number } | null {
if (!value) return null;
return {
x: round(readNumber(value, "x") ?? 0),
y: round(readNumber(value, "y") ?? 0),
};
}
function adaptSize(value: UnknownRecord | null): { width: number; height: number } | null {
if (!value) return null;
return {
width: clamp(round(readNumber(value, "width") ?? MIN_ELEMENT_SIZE), MIN_ELEMENT_SIZE, EDITOR_STAGE_WIDTH),
height: clamp(round(readNumber(value, "height") ?? MIN_ELEMENT_SIZE), MIN_ELEMENT_SIZE, EDITOR_STAGE_HEIGHT),
};
}
function adaptLayoutItem(value: UnknownRecord | null): LayoutItem | null {
if (!value) return null;
return stripNullish({
grow: clampOptional(readNumber(value, "grow"), 0, 12),
shrink: clampOptional(readNumber(value, "shrink"), 0, 12),
basis: readNumber(value, "basis"),
min_width: readNumber(value, "min_width"),
max_width: readNumber(value, "max_width"),
min_height: readNumber(value, "min_height"),
max_height: readNumber(value, "max_height"),
column_span: clampInteger(readNumber(value, "column_span"), 1, 12),
row_span: clampInteger(readNumber(value, "row_span"), 1, 12),
align_self: readLayoutAlignment(value, "align_self"),
});
}
function adaptAlignment(value: UnknownRecord | null): Alignment | null {
if (!value) return null;
return stripNullish({
horizontal: readEnum(value, ["left", "center", "right"], "horizontal"),
vertical: readEnum(value, ["top", "middle", "bottom"], "vertical"),
});
}
function adaptFont(value: UnknownRecord | null): Font | null {
if (!value) return null;
const fontWeight = readNumber(value, "font_weight");
const size = readNumber(value, "size");
return stripNullish({
family:
truncateString(readString(value.family) ?? readString(value.name) ?? "", 80) ||
null,
size: size == null ? null : clamp(round(size), 6, 360),
color: readColor(value.color),
bold: readBoolean(value, "bold") ?? (fontWeight == null ? null : fontWeight >= 600),
italic: readBoolean(value, "italic"),
underline:
readBoolean(value, "underline") ??
([
readString(value.text_decoration),
readString(value.textDecoration),
].includes("underline")
? true
: null),
line_height: clampOptional(readNumber(value, "line_height"), 0.8, 2.2),
letter_spacing: clamp(readNumber(value, "letter_spacing") ?? 0, -200, 600),
ellipsis: readBoolean(value, "ellipsis"),
opacity: clampOptional(readNumber(value, "opacity"), 0, 1),
});
}
function adaptFill(value: UnknownRecord | null): Fill | null {
const color = readColor(value?.color);
if (!color) return null;
return stripNullish({
color,
opacity: clamp(readNumber(value ?? {}, "opacity") ?? 1, 0, 1),
});
}
function adaptStroke(value: UnknownRecord | null): Stroke | null {
const color = readColor(value?.color);
if (!color) return null;
return stripNullish({
color,
opacity: clamp(readNumber(value ?? {}, "opacity") ?? 1, 0, 1),
width: clamp(round(readNumber(value ?? {}, "width") ?? 1), 0, 8),
dash: readArray(value ?? {}, "dash")
.map((item) => readRawNumber(item))
.filter((item): item is number => item != null && item >= 0),
});
}
function hasVisiblePaint(
fill: Fill | null,
stroke: Stroke | null,
opacity?: number | null,
) {
if ((opacity ?? 1) <= 0) return false;
if (fill && (fill.opacity ?? 1) > 0) return true;
return Boolean(stroke && stroke.width > 0 && (stroke.opacity ?? 1) > 0);
}
function adaptBorderRadius(value: UnknownRecord | null): BorderRadius | null {
if (!value) return null;
return {
tl: clamp(round(readNumber(value, "tl") ?? 0), 0, MAX_BORDER_RADIUS),
tr: clamp(round(readNumber(value, "tr") ?? 0), 0, MAX_BORDER_RADIUS),
bl: clamp(round(readNumber(value, "bl") ?? 0), 0, MAX_BORDER_RADIUS),
br: clamp(round(readNumber(value, "br") ?? 0), 0, MAX_BORDER_RADIUS),
};
}
function adaptPadding(value: UnknownRecord | null): Padding | null {
if (!value) return null;
return {
top: Math.max(0, round(readNumber(value, "top") ?? 0)),
right: Math.max(0, round(readNumber(value, "right") ?? 0)),
bottom: Math.max(0, round(readNumber(value, "bottom") ?? 0)),
left: Math.max(0, round(readNumber(value, "left") ?? 0)),
};
}
function adaptShadow(value: UnknownRecord | null): Shadow | null {
if (!value) return null;
return stripNullish({
color: readColor(value.color),
blur: clamp(round(readNumber(value, "blur") ?? 0), 0, 100),
opacity: clamp(readNumber(value, "opacity") ?? 0.2, 0, 1),
offset_x: clamp(round(readNumber(value, "offset_x") ?? 0), -256, 256),
offset_y: clamp(round(readNumber(value, "offset_y") ?? 0), -256, 256),
});
}
function textRun(text: string, font?: Font | null): TextRun {
return stripNullish({ text, font }) as TextRun;
}
function adaptTextRun(item: unknown): TextRun | null {
const record = asRecord(item);
if (!record) return null;
const text = truncateString(readString(record.text) ?? "", 700);
if (!text) return null;
return textRun(text, adaptFont(readRecord(record, "font")));
}
function adaptTextRuns(value: unknown[], fallbackText?: string | null): TextRun[] {
const runs = value
.map(adaptTextRun)
.filter((item): item is TextRun => Boolean(item))
.slice(0, 24);
if (runs.length > 0) return runs;
return [{ text: truncateString(fallbackText || "Text", 700) }];
}
function adaptTextListItems(value: unknown[]): TextListItem[] {
const items = value
.map((item) => {
let runs: TextRun[] = [];
if (Array.isArray(item)) {
runs = item
.map(adaptTextRun)
.filter((run): run is TextRun => Boolean(run));
} else {
const record = asRecord(item);
const text = truncateString(
readString(record?.text) ?? readString(item) ?? "",
180,
);
runs = text ? [textRun(text)] : [];
}
if (runs.length === 0) return null;
return renderMarkdownTextRuns(runs).slice(0, 12);
})
.filter((item): item is TextRun[] => Boolean(item?.length))
.slice(0, 8);
return items.length > 0 ? items : [[textRun("List item")]];
}
function adaptTableCells(value: unknown[]): TableCell[] {
const cells = value
.map((item) => {
if (typeof item === "string" || typeof item === "number") {
return { runs: [textRun(truncateString(String(item), 80))] } satisfies TableCell;
}
const record = asRecord(item);
if (!record) return null;
const rawRuns = readArray(record, "runs");
const runText = rawRuns
.map((run) => readString(asRecord(run)?.text) ?? "")
.join("");
const firstRun = asRecord(rawRuns[0]) ?? {};
const textValue = record.text;
const textRecord = asRecord(textValue);
const text = truncateString(
runText ||
(textRecord
? readString(textRecord.text) ?? ""
: readString(textValue) ?? ""),
80,
);
const runs =
rawRuns.map(adaptTextRun).filter((run): run is TextRun => Boolean(run))
.length > 0
? rawRuns
.map(adaptTextRun)
.filter((run): run is TextRun => Boolean(run))
: text
? [textRun(text)]
: [];
return stripNullish({
color:
adaptFill(readRecord(record, "color")) ??
adaptFill(readRecord(record, "fill")),
font:
adaptFont(readRecord(record, "font")) ??
adaptFont(readRecord(firstRun, "font")) ??
adaptFont(readRecord(textRecord ?? {}, "font")),
alignment: readEnum(record, ["left", "center", "right"], "alignment"),
runs,
}) as TableCell;
})
.filter((item): item is TableCell => Boolean(item));
return cells.length > 0 ? cells : [{ runs: [] }];
}
function adaptChartCategories(value: unknown[]): string[] {
return value
.map((item, index) =>
truncateString(readString(item) ?? readString(asRecord(item)?.label) ?? "", 40) ||
`Item ${index + 1}`,
)
.filter(Boolean)
.slice(0, 24);
}
function adaptChartSeries(value: unknown): ChartSeries | null {
const record = asRecord(value);
if (!record) return null;
const values = readArray(record, "values")
.map(readRawNumber)
.filter((item): item is number => item != null)
.map((item) => clamp(item, -1_000_000, 1_000_000))
.slice(0, 24);
if (values.length === 0) return null;
return {
name: truncateString(readString(record.name) ?? "Series", 80) || "Series",
values,
};
}
function invisibleFallbackElement(): SlideElement {
return {
type: "rectangle",
position: { x: 0, y: 0 },
size: { width: MIN_ELEMENT_SIZE, height: MIN_ELEMENT_SIZE },
fill: { color: "FFFFFF" },
opacity: 0,
};
}
function backgroundFromElements(elements: SlideElement[]) {
const background = findBackgroundRectangle(elements, 0, 0);
return background?.type === "rectangle" && background.fill?.color
? background.fill.color
: "FFFFFF";
}
function findBackgroundRectangle(
elements: SlideElement[],
offsetX: number,
offsetY: number,
): SlideElement | null {
for (const element of elements) {
const x = offsetX + (element.position?.x ?? 0);
const y = offsetY + (element.position?.y ?? 0);
if (
element.type === "rectangle" &&
x === 0 &&
y === 0 &&
element.size?.width === EDITOR_STAGE_WIDTH &&
element.size?.height === EDITOR_STAGE_HEIGHT &&
element.fill?.color
) {
return element;
}
if (
element.type === "group" ||
element.type === "flex" ||
element.type === "grid"
) {
const background = findBackgroundRectangle(element.children, x, y);
if (background) return background;
}
if (element.type === "container" && element.child) {
const background = findBackgroundRectangle([element.child], x, y);
if (background) return background;
}
}
return null;
}
function titleFromLayout(layout: TemplateV2Layout, index: number) {
const id = readString(layout.id);
const slideNumber = id?.match(/\d+/)?.[0] ?? `${index + 1}`;
return truncateString(`Slide ${slideNumber}`, 60);
}
function readValue(record: UnknownRecord, key: string) {
return record[key];
}
function readRecord(
record: UnknownRecord | null | undefined,
key: string,
) {
return asRecord(record ? readValue(record, key) : null);
}
function readArray(record: UnknownRecord, key: string) {
const value = readValue(record, key);
return Array.isArray(value) ? value : [];
}
function readNumber(record: UnknownRecord, key: string) {
return readRawNumber(readValue(record, key));
}
function readRawNumber(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(record: UnknownRecord, key: string) {
const value = readValue(record, key);
return typeof value === "boolean" ? value : null;
}
function readDataLabelPosition(
record: UnknownRecord,
key: string,
): DataLabelPosition | null {
const value = readValue(record, key);
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 readDecorative(record: UnknownRecord): boolean | null {
return readBoolean(record, "decorative");
}
function readEnum<const T extends readonly string[]>(
record: UnknownRecord,
values: T,
key: string,
): T[number] | null {
const value = readString(readValue(record, key));
return value && (values as readonly string[]).includes(value)
? (value as T[number])
: null;
}
function readLayoutAlignment(
record: UnknownRecord,
key: string,
): LayoutAlignment | null {
const value = readString(readValue(record, key));
return value && LAYOUT_ALIGNMENT_VALUES.has(value)
? (value as LayoutAlignment)
: null;
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readColor(value: unknown): string | null {
const color = readString(value)?.trim();
if (!color) return null;
return /^#?[0-9A-Fa-f]{6}$/.test(color) ? color : null;
}
function asRecord(value: unknown): UnknownRecord | null {
return isRecord(value) ? value : null;
}
function isRecord(value: unknown): value is UnknownRecord {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function stripNullish<T extends UnknownRecord>(value: T): T {
return Object.fromEntries(
Object.entries(value).filter(
([, item]) =>
item !== null &&
item !== undefined &&
(!Array.isArray(item) || item.length > 0),
),
) as T;
}
function positiveInteger(value: number | null, fallback: number) {
return Math.max(1, Math.trunc(value ?? fallback));
}
function positiveIntegerOrNull(value: number | null) {
return value == null ? null : positiveInteger(value, 1);
}
function clampInteger(value: number | null, min: number, max: number) {
return value == null ? null : Math.trunc(clamp(value, min, max));
}
function clampOptional(value: number | null, min: number, max: number) {
return value == null ? null : clamp(value, min, max);
}
function truncateString(value: string, maxLength: number) {
return value.length > maxLength ? value.slice(0, maxLength) : value;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function round(value: number) {
return Math.round(value * 10000) / 10000;
}