) => {
+ const rules: string[] = [];
+ const push = (key: string, cssKey = key, unit: string | null = null) => {
+ const value = style[key];
+ if (value === undefined || value === null) return;
+ const text = String(value).trim();
+ if (!text) return;
+ if (unit) {
+ const numeric = Number(text);
+ if (Number.isFinite(numeric)) {
+ rules.push(`${cssKey}:${Math.max(0, numeric)}${unit}`);
+ return;
+ }
+ }
+ rules.push(`${cssKey}:${text}`);
+ };
+ push('fill', 'background-color');
+ push('color');
+ push('borderColor', 'border-color');
+ push('borderWidth', 'border-width', 'px');
+ push('radius', 'border-radius', 'px');
+ push('padding', 'padding', 'px');
+ push('fontFamily', 'font-family');
+ push('fontSize', 'font-size', 'px');
+ push('fontWeight', 'font-weight');
+ push('fontStyle', 'font-style');
+ push('lineHeight', 'line-height', 'px');
+ push('textAlign', 'text-align');
+ push('opacity');
+ return rules.join(';');
+};
+
+const getFieldKeyForBlock = (slide: FrontendSlide, block: FrontendSlideBlock) => {
+ const candidates = [
+ PREFERRED_FIELD_KEYS[block.role] || '',
+ block.role || '',
+ block.id || '',
+ slugify(block.role || ''),
+ slugify(block.id || ''),
+ ].filter(Boolean);
+ const fieldKeys = new Set(slide.editableFields.map((field) => field.key));
+ return candidates.find((candidate) => fieldKeys.has(candidate));
+};
+
+const getFieldKeyForChild = (slide: FrontendSlide, child: FrontendBlockChild) => {
+ const candidates = [
+ PREFERRED_FIELD_KEYS[child.role] || '',
+ child.role || '',
+ child.id || '',
+ slugify(child.role || ''),
+ slugify(child.id || ''),
+ ].filter(Boolean);
+ const fieldKeys = new Set(slide.editableFields.map((field) => field.key));
+ return candidates.find((candidate) => fieldKeys.has(candidate));
+};
+
+const wrapEditableText = (
+ field: FrontendEditableField | undefined,
+ fallbackValue: string,
+ itemIndex?: number,
+) => {
+ const fieldKey = field?.key;
+ const fieldType = field?.type || (typeof itemIndex === 'number' ? 'list' : 'text');
+ const value = field
+ ? typeof itemIndex === 'number'
+ ? field.items[itemIndex] || fallbackValue
+ : field.type === 'list'
+ ? field.items.filter((item) => item.trim()).join(' • ')
+ : field.value || fallbackValue
+ : fallbackValue;
+ const itemAttr = typeof itemIndex === 'number' ? ` data-edit-index="${itemIndex}"` : '';
+ const attrs = fieldKey
+ ? ` class="ppt-inline-editable${typeof itemIndex === 'number' ? ' ppt-inline-editable-list' : ''}" data-edit-key="${escapeHtml(fieldKey)}" data-edit-type="${escapeHtml(fieldType)}"${itemAttr}`
+ : '';
+ return `${formatTextValue(value)}`;
+};
+
+const renderVisualAsset = (
+ asset: FrontendVisualAsset | undefined,
+ assetKey: string,
+ label: string,
+ imageFit?: FrontendCanvasVisualStyle['imageFit'],
+) => {
+ const previewSrc = (asset?.previewSrc || asset?.src || '').trim();
+ const originalSrc = (asset?.originalSrc || previewSrc || '').trim();
+ const sourceLabel = asset?.sourceType === 'paper_asset' ? '论文图表' : asset?.sourceType === 'upload' ? '用户上传' : 'AI 配图';
+ const safeAssetKey = escapeHtml(assetKey || asset?.key || 'main_visual');
+ const safeLabel = escapeHtml(asset?.label || label || assetKey || 'Image');
+ const safeAlt = escapeHtml(asset?.alt || safeLabel || 'Slide image');
+ const resolvedImageFit = imageFit === 'contain' || imageFit === 'fill' ? imageFit : 'cover';
+
+ if (!previewSrc) {
+ return `
+
+
+
${escapeHtml(sourceLabel)}
+
+`.trim();
+ }
+
+ return `
+
+
+
})
+
+
${escapeHtml(sourceLabel)}
+
+`.trim();
+};
+
+const sortBlocks = (blocks: FrontendSlideBlock[]) =>
+ [...blocks].sort((a, b) => {
+ const orderA = Number(a.layout?.order || 0);
+ const orderB = Number(b.layout?.order || 0);
+ if (orderA !== orderB) return orderA - orderB;
+ return a.id.localeCompare(b.id);
+ });
+
+const withManualBlockClass = (_block: FrontendSlideBlock, className: string) =>
+ className;
+
+const isRightColumnBlock = (block: FrontendSlideBlock) =>
+ block.layout?.zone === 'right'
+ || block.layout?.zone === 'aside'
+ || block.layout?.preferredSide === 'right';
+
+const isLeftColumnBlock = (block: FrontendSlideBlock) =>
+ block.layout?.zone === 'left'
+ || block.layout?.preferredSide === 'left';
+
+const renderTextBlock = (
+ slide: FrontendSlide,
+ block: FrontendSlideBlock,
+ className: string,
+ tagName: 'p' | 'div' | 'h1' | 'h2' = 'p',
+) => {
+ const field = buildFieldMap(slide).get(getFieldKeyForBlock(slide, block) || '');
+ const value = field?.value || block.content || '';
+ const blockClassName = withManualBlockClass(block, className);
+ if (block.children && block.children.length > 0) {
+ const shouldRenderPrimary = Boolean(value.trim()) && !hasLegacyChildForBlock(block);
+ return `
+
+ ${shouldRenderPrimary ? `${wrapEditableText(field, value)}` : ''}
+ ${renderBlockChildren(slide, block)}
+
+`.trim();
+ }
+ return `<${tagName} class="${blockClassName}" data-block-id="${escapeHtml(block.id)}" data-block-role="${escapeHtml(block.role)}">${wrapEditableText(field, value)}${tagName}>`;
+};
+
+const renderListBlock = (slide: FrontendSlide, block: FrontendSlideBlock, className: string) => {
+ const field = buildFieldMap(slide).get(getFieldKeyForBlock(slide, block) || '');
+ const items = (field?.items?.length ? field.items : block.items || []).filter((item) => item.trim());
+ if (items.length === 0 && (!block.children || block.children.length === 0)) return '';
+ if (block.children && block.children.length > 0) {
+ const shouldRenderPrimary = items.length > 0 && !hasLegacyChildForBlock(block);
+ return `
+
+ ${shouldRenderPrimary ? `
+
+ ${items.map((item, index) => `- ${wrapEditableText(field, item, index)}
`).join('')}
+
+ ` : ''}
+ ${renderBlockChildren(slide, block)}
+
+`.trim();
+ }
+ return `
+
+ ${items.map((item, index) => `- ${wrapEditableText(field, item, index)}
`).join('')}
+
+`.trim();
+};
+
+const renderImageBlock = (slide: FrontendSlide, block: FrontendSlideBlock, className: string) => {
+ const blockClassName = withManualBlockClass(block, className);
+ if (block.children && block.children.length > 0) {
+ return `${renderBlockChildren(slide, block)}
`;
+ }
+ const assetKey = block.assetKey || block.id || 'main_visual';
+ const asset = buildAssetMap(slide).get(assetKey);
+ return `${renderVisualAsset(asset, assetKey, block.role || assetKey)}
`;
+};
+
+const hasLegacyChildForBlock = (block: FrontendSlideBlock) =>
+ (block.children || []).some((child) => child.id === `${block.id}_content`);
+
+const getTableCellFieldKey = (ownerId: string, rowIndex: number | 'h', colIndex: number) =>
+ `${ownerId}_cell_${rowIndex}_${colIndex}`;
+
+const normalizeTableMatrix = (headers: string[], rows: string[][]) => {
+ const maxCols = Math.max(
+ headers.length,
+ ...rows.map((row) => row.length),
+ 1,
+ );
+ return {
+ headers: Array.from({ length: maxCols }, (_, index) => headers[index] || `列 ${index + 1}`),
+ rows: rows.length > 0
+ ? rows.map((row) => Array.from({ length: maxCols }, (_, index) => row[index] || ''))
+ : [Array.from({ length: maxCols }, () => '')],
+ };
+};
+
+const renderTableMarkup = (
+ slide: FrontendSlide,
+ owner: FrontendSlideBlock | FrontendBlockChild,
+ className: string,
+ includeBlockAttrs = true,
+) => {
+ const fieldMap = buildFieldMap(slide);
+ const tableData = normalizeTableMatrix(owner.tableData?.headers || [], owner.tableData?.rows || []);
+ const blockAttrs = includeBlockAttrs
+ ? ` data-block-id="${escapeHtml(owner.id)}" data-block-role="${escapeHtml(owner.role)}"`
+ : '';
+ return `
+
+`.trim();
+};
+
+const renderTableBlock = (slide: FrontendSlide, block: FrontendSlideBlock, className: string) =>
+ renderTableMarkup(slide, block, withManualBlockClass(block, className));
+
+const renderChildItem = (slide: FrontendSlide, child: FrontendBlockChild) => {
+ const field = buildFieldMap(slide).get(getFieldKeyForChild(slide, child) || '');
+ if (child.type === 'table') {
+ return renderTableMarkup(slide, child, 'schema-child-item schema-table-card', false);
+ }
+ if (child.type === 'image') {
+ const assetKey = child.assetKey || child.id || 'main_visual';
+ const asset = buildAssetMap(slide).get(assetKey);
+ return `${renderVisualAsset(asset, assetKey, child.role || assetKey)}
`;
+ }
+ if (child.type === 'list') {
+ const items = (field?.items?.length ? field.items : child.items || []).filter((item) => item.trim());
+ if (items.length === 0) return '';
+ return `
+
+
${escapeHtml(child.role.replace(/_/g, ' '))}
+
+ ${items.map((item, index) => `- ${wrapEditableText(field, item, index)}
`).join('')}
+
+
+`.trim();
+ }
+ const value = field?.value || child.content || '';
+ if (child.type === 'callout') {
+ return `${wrapEditableText(field, value)}
`;
+ }
+ if (child.type === 'quote') {
+ return `${wrapEditableText(field, value)}
`;
+ }
+ if (child.type === 'stat') {
+ return `
+
+
${wrapEditableText(field, value)}
+
${escapeHtml(child.role.replace(/_/g, ' ') || 'Highlight')}
+
+`.trim();
+ }
+ return `${wrapEditableText(field, value)}
`;
+};
+
+const renderBlockChildren = (slide: FrontendSlide, block: FrontendSlideBlock) => {
+ const children = block.children || [];
+ if (children.length === 0) {
+ return '';
+ }
+ return `${children.map((child) => renderChildItem(slide, child)).join('')}
`;
+};
+
+const renderStatBlock = (slide: FrontendSlide, block: FrontendSlideBlock, className: string) => {
+ const blockClassName = withManualBlockClass(block, className);
+ if (block.children && block.children.length > 0) {
+ return `${renderBlockChildren(slide, block)}
`;
+ }
+ const field = buildFieldMap(slide).get(getFieldKeyForBlock(slide, block) || '');
+ const value = field?.value || block.content || '';
+ return `
+
+
${wrapEditableText(field, value)}
+
${escapeHtml(block.role.replace(/_/g, ' ') || 'Highlight')}
+
+`.trim();
+};
+
+const renderCalloutBlock = (slide: FrontendSlide, block: FrontendSlideBlock, className: string) => {
+ const blockClassName = withManualBlockClass(block, className);
+ if (block.children && block.children.length > 0) {
+ return `${renderBlockChildren(slide, block)}
`;
+ }
+ const field = buildFieldMap(slide).get(getFieldKeyForBlock(slide, block) || '');
+ const value = field?.value || block.content || '';
+ return `${wrapEditableText(field, value)}
`;
+};
+
+const renderQuoteBlock = (slide: FrontendSlide, block: FrontendSlideBlock, className: string) => {
+ const blockClassName = withManualBlockClass(block, className);
+ if (block.children && block.children.length > 0) {
+ return `${renderBlockChildren(slide, block)}
`;
+ }
+ const field = buildFieldMap(slide).get(getFieldKeyForBlock(slide, block) || '');
+ const value = field?.value || block.content || '';
+ return `${wrapEditableText(field, value)}
`;
+};
+
+const renderGenericCard = (slide: FrontendSlide, block: FrontendSlideBlock, className = 'schema-card') => {
+ const blockClassName = withManualBlockClass(block, className);
+ if (block.children && block.children.length > 0) {
+ return `
+
+
${escapeHtml(block.role.replace(/_/g, ' '))}
+ ${renderBlockChildren(slide, block)}
+
+`.trim();
+ }
+ if (block.type === 'image') return renderImageBlock(slide, block, `${className} schema-image-card`);
+ if (block.type === 'table') return renderTableBlock(slide, block, `${className} schema-table-card`);
+ if (block.type === 'list') {
+ return `
+
+
${escapeHtml(block.role.replace(/_/g, ' '))}
+ ${renderListBlock(slide, block, 'schema-bullets')}
+
+`.trim();
+ }
+ if (block.type === 'quote') return renderQuoteBlock(slide, block, `${className} schema-quote`);
+ if (block.type === 'stat') return renderStatBlock(slide, block, `${className} schema-stat-card`);
+ if (block.type === 'callout') return renderCalloutBlock(slide, block, `${className} schema-callout`);
+ return `
+
+
${escapeHtml(block.role.replace(/_/g, ' '))}
+ ${renderTextBlock(slide, block, 'schema-card-text')}
+
+`.trim();
+};
+
+const renderColumnBlock = (slide: FrontendSlide, block: FrontendSlideBlock) => {
+ if (block.type === 'image') return renderImageBlock(slide, block, 'schema-visual-card');
+ if (block.type === 'table') return renderTableBlock(slide, block, 'schema-table-card');
+ if (block.type === 'callout') return renderCalloutBlock(slide, block, 'schema-callout');
+ if (block.type === 'stat') return renderStatBlock(slide, block, 'schema-stat-card');
+ if (block.type === 'quote') return renderQuoteBlock(slide, block, 'schema-quote');
+ if (block.type === 'list') return renderGenericCard(slide, block);
+ return renderGenericCard(slide, block);
+};
+
+const renderColumnBlocks = (
+ slide: FrontendSlide,
+ blocks: FrontendSlideBlock[],
+ options: { limit?: number; bareLists?: boolean; tallFirstImage?: boolean } = {},
+) => {
+ const limit = options.limit ?? Number.POSITIVE_INFINITY;
+ let imageCount = 0;
+ return sortBlocks(blocks)
+ .map((block) => {
+ if (block.type === 'image') {
+ const isFirstImage = imageCount === 0;
+ imageCount += 1;
+ return renderImageBlock(
+ slide,
+ block,
+ options.tallFirstImage && isFirstImage ? 'schema-visual-card tall' : 'schema-visual-card',
+ );
+ }
+ if (options.bareLists && block.type === 'list') {
+ return renderListBlock(slide, block, 'schema-bullets');
+ }
+ return renderColumnBlock(slide, block);
+ })
+ .filter(Boolean)
+ .slice(0, limit);
+};
+
+const pickSchemaTemplateKey = (slide: FrontendSlide) => {
+ const requested = slide.templateKey || '';
+ if (SUPPORTED_SCHEMA_TEMPLATES.includes(requested as (typeof SUPPORTED_SCHEMA_TEMPLATES)[number])) {
+ return requested as (typeof SUPPORTED_SCHEMA_TEMPLATES)[number];
+ }
+
+ const blocks = sortBlocks(slide.blocks);
+ const imageCount = blocks.filter((block) => block.type === 'image').length;
+ const listCount = blocks.filter((block) => block.type === 'list').length;
+ const statCount = blocks.filter((block) => block.type === 'stat').length;
+ const quoteCount = blocks.filter((block) => block.type === 'quote').length;
+
+ if (quoteCount > 0) return 'quote_focus';
+ if (imageCount >= 2) return 'visual_compare';
+ if (statCount >= 2) return 'metrics_dashboard';
+ if (listCount >= 2) return 'dual_list';
+ if (imageCount === 1 && listCount > 0) return 'split_media';
+ if (imageCount === 1) return 'hero_visual';
+ if (blocks.length <= 3) return 'section_divider';
+ if (blocks.length >= 6) return 'insight_grid';
+ return 'text_focus';
+};
+
+const buildSchemaBaseCss = (theme?: FrontendDeckTheme | null, visualSpec?: FrontendCanvasVisualSpec | null) => {
+ const resolved = resolveCanvasVisualTheme(theme, visualSpec);
+ return `
+.slide-root.schema-root {
+ --schema-bg: ${resolved.palette.bg};
+ --schema-panel: ${resolved.palette.panel};
+ --schema-primary: ${resolved.palette.primary};
+ --schema-secondary: ${resolved.palette.secondary};
+ --schema-accent: ${resolved.palette.accent};
+ --schema-text: ${resolved.palette.text};
+ --schema-muted: ${resolved.palette.muted};
+ --schema-title-font: ${resolved.typography.titleFontStack};
+ --schema-body-font: ${resolved.typography.bodyFontStack};
+ --schema-title-size: ${resolved.typography.titleSize}px;
+ --schema-summary-size: ${resolved.typography.summarySize}px;
+ --schema-body-size: ${resolved.typography.bodySize}px;
+ --schema-eyebrow-size: ${resolved.typography.eyebrowSize}px;
+ --schema-card-radius: ${resolved.surface.cardRadius}px;
+ --schema-card-padding: ${resolved.surface.cardPadding}px;
+ --schema-shell-padding-x: ${resolved.layout.safeMargin}px;
+ --schema-shell-padding-top: ${resolved.layout.safeMargin}px;
+ --schema-shell-padding-bottom: ${Math.max(40, Math.round(resolved.layout.safeMargin * 0.9))}px;
+ --schema-shell-gap: ${resolved.layout.sectionGap}px;
+ width: 100%;
+ height: 100%;
+ background:
+ radial-gradient(circle at top right, color-mix(in srgb, var(--schema-secondary) 22%, transparent) 0%, transparent 28%),
+ radial-gradient(circle at bottom left, color-mix(in srgb, var(--schema-accent) 18%, transparent) 0%, transparent 34%),
+ linear-gradient(180deg, color-mix(in srgb, var(--schema-bg) 88%, #020617 12%), var(--schema-bg));
+ color: var(--schema-text);
+ overflow: hidden;
+}
+.slide-root.schema-root * {
+ box-sizing: border-box;
+}
+.schema-shell {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ padding: var(--schema-shell-padding-top) var(--schema-shell-padding-x) var(--schema-shell-padding-bottom);
+ display: flex;
+ flex-direction: column;
+ gap: var(--schema-shell-gap);
+ background:
+ linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
+ linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px);
+ background-size: 52px 52px;
+}
+.schema-header {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.schema-eyebrow {
+ align-self: flex-start;
+ padding: 8px 14px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--schema-secondary) 16%, transparent);
+ border: 1px solid color-mix(in srgb, var(--schema-primary) 38%, transparent);
+ color: var(--schema-primary);
+ font-size: var(--schema-eyebrow-size);
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.schema-title {
+ margin: 0;
+ font-family: var(--schema-title-font);
+ font-size: var(--schema-title-size);
+ line-height: 1.02;
+ letter-spacing: -0.04em;
+}
+.schema-summary {
+ margin: 0;
+ max-width: 860px;
+ font-family: var(--schema-body-font);
+ font-size: var(--schema-summary-size);
+ line-height: 1.42;
+ color: var(--schema-muted);
+}
+.schema-main {
+ flex: 1 1 auto;
+ min-height: 0;
+}
+.schema-footer {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 18px;
+}
+.schema-card,
+.schema-callout,
+.schema-stat-card,
+.schema-visual-card,
+.schema-image-card,
+.schema-table-card {
+ background: var(--schema-panel);
+ border: 1px solid color-mix(in srgb, var(--schema-primary) 22%, transparent);
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28);
+ border-radius: var(--schema-card-radius);
+ backdrop-filter: blur(10px);
+}
+.schema-card,
+.schema-stat-card,
+.schema-callout,
+.schema-table-card {
+ padding: var(--schema-card-padding) calc(var(--schema-card-padding) + 2px);
+}
+.schema-card-title,
+.schema-label {
+ font-size: calc(var(--schema-eyebrow-size) - 2px);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-weight: 700;
+ color: var(--schema-primary);
+ margin-bottom: 12px;
+}
+.schema-card-text,
+.schema-body,
+.schema-takeaway,
+.schema-callout {
+ font-family: var(--schema-body-font);
+ font-size: var(--schema-body-size);
+ line-height: 1.45;
+ color: var(--schema-text);
+}
+.schema-block-children {
+ display: grid;
+ gap: 14px;
+}
+.schema-text-block-with-children,
+.schema-list-block-with-children {
+ display: grid;
+ gap: 14px;
+}
+.schema-text-block-with-children .schema-block-children {
+ font-family: var(--schema-body-font);
+ font-size: var(--schema-body-size);
+ line-height: 1.45;
+ letter-spacing: 0;
+ text-transform: none;
+ font-weight: 400;
+ color: var(--schema-text);
+}
+.schema-child-item {
+ min-width: 0;
+}
+.schema-child-image {
+ min-height: 220px;
+}
+.schema-bullets {
+ margin: 0;
+ padding-left: 24px;
+ display: grid;
+ gap: 12px;
+ font-family: var(--schema-body-font);
+ font-size: calc(var(--schema-body-size) - 1px);
+ line-height: 1.35;
+}
+.schema-bullets.tight {
+ gap: 8px;
+ font-size: calc(var(--schema-body-size) - 2px);
+}
+.schema-bullets li {
+ color: var(--schema-text);
+}
+.schema-table-card {
+ min-width: 0;
+}
+.schema-table-scroll {
+ width: 100%;
+ overflow: auto;
+}
+.schema-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: var(--schema-body-font);
+ font-size: calc(var(--schema-body-size) - 5px);
+ line-height: 1.25;
+}
+.schema-table th,
+.schema-table td {
+ border: 1px solid color-mix(in srgb, var(--schema-primary) 22%, transparent);
+ padding: 10px 12px;
+ text-align: left;
+ vertical-align: top;
+ color: var(--schema-text);
+}
+.schema-table th {
+ background: color-mix(in srgb, var(--schema-primary) 14%, transparent);
+ color: var(--schema-primary);
+ font-weight: 800;
+}
+.schema-table td {
+ background: rgba(255, 255, 255, 0.03);
+}
+.schema-canvas-root {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+}
+.schema-canvas-container {
+ min-width: 0;
+ min-height: 0;
+ width: 100%;
+}
+.schema-canvas-container[data-canvas-node-id="root"],
+.schema-canvas-container[data-canvas-node-id="main"] {
+ flex: 1 1 auto;
+ height: 100%;
+}
+.schema-canvas-component {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.schema-canvas-heading {
+ margin: 0;
+ font-family: var(--schema-title-font);
+ font-size: var(--schema-title-size);
+ line-height: 1.05;
+ letter-spacing: 0;
+ color: var(--schema-text);
+}
+.schema-canvas-text {
+ margin: 0;
+ font-family: var(--schema-body-font);
+ font-size: var(--schema-body-size);
+ line-height: 1.42;
+ color: var(--schema-text);
+}
+.schema-canvas-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 120px;
+ padding: 18px;
+ border: 1px dashed color-mix(in srgb, var(--schema-primary) 34%, transparent);
+ border-radius: 18px;
+ color: var(--schema-muted);
+ background: color-mix(in srgb, var(--schema-panel) 72%, transparent);
+ font-family: var(--schema-body-font);
+ font-size: 18px;
+}
+.template-canvas-schema .schema-shell {
+ padding: 58px 64px 52px;
+}
+.template-canvas-schema .schema-main {
+ display: flex;
+ min-height: 0;
+}
+.template-canvas-schema .schema-visual-card {
+ min-height: 220px;
+ height: 100%;
+}
+.template-canvas-schema .schema-bullets {
+ max-height: 100%;
+ overflow: hidden;
+}
+.schema-quote {
+ margin: 0;
+ padding: 28px 30px;
+ font-family: var(--schema-title-font);
+ font-size: calc(var(--schema-title-size) * 0.72);
+ line-height: 1.16;
+ letter-spacing: -0.03em;
+ border-radius: 32px;
+ background: linear-gradient(135deg, color-mix(in srgb, var(--schema-primary) 14%, transparent), color-mix(in srgb, var(--schema-accent) 10%, transparent));
+ border: 1px solid color-mix(in srgb, var(--schema-primary) 26%, transparent);
+ color: var(--schema-text);
+}
+.schema-stat-value {
+ font-family: var(--schema-title-font);
+ font-size: calc(var(--schema-title-size) * 0.72);
+ line-height: 1;
+ letter-spacing: -0.04em;
+}
+.schema-stat-label {
+ margin-top: 10px;
+ font-size: 14px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--schema-muted);
+}
+.schema-tag {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 220px;
+ padding: 13px 18px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--schema-accent) 36%, transparent);
+ color: var(--schema-accent);
+ background: rgba(7, 16, 29, 0.68);
+ font-size: calc(var(--schema-eyebrow-size) - 2px);
+ font-weight: 700;
+ letter-spacing: 0.05em;
+}
+.schema-grid-2 {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 22px;
+}
+.schema-grid-3 {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 18px;
+}
+.schema-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+.schema-visual-card,
+.schema-image-card {
+ padding: 16px;
+ min-height: 260px;
+}
+.schema-visual-card.tall {
+ min-height: 380px;
+}
+.schema-timeline {
+ display: grid;
+ gap: 14px;
+}
+.schema-timeline-item {
+ display: grid;
+ grid-template-columns: 34px 1fr;
+ gap: 14px;
+ align-items: start;
+}
+.schema-timeline-dot {
+ width: 18px;
+ height: 18px;
+ margin-top: 6px;
+ border-radius: 999px;
+ background: linear-gradient(135deg, var(--schema-primary), var(--schema-accent));
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--schema-primary) 18%, transparent);
+}
+.schema-timeline-copy {
+ padding: 14px 16px;
+ border-radius: 20px;
+ background: color-mix(in srgb, var(--schema-panel) 86%, transparent);
+ border: 1px solid color-mix(in srgb, var(--schema-primary) 18%, transparent);
+ font-family: var(--schema-body-font);
+ font-size: calc(var(--schema-body-size) - 2px);
+ line-height: 1.35;
+}
+.schema-columns {
+ display: grid;
+ grid-template-columns: 1.06fr 0.94fr;
+ gap: 24px;
+ height: 100%;
+}
+.schema-columns.equal {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+.schema-main-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+[data-insert-zone] {
+ position: relative;
+ min-height: 96px;
+}
+.schema-surface {
+ padding: 20px 22px;
+ border-radius: 24px;
+ background: color-mix(in srgb, var(--schema-panel) 92%, transparent);
+ border: 1px solid color-mix(in srgb, var(--schema-primary) 18%, transparent);
+}
+.slide-root .ppt-inline-editable {
+ cursor: text;
+ transition: box-shadow 0.18s ease, background-color 0.18s ease;
+}
+.slide-root .ppt-inline-editable:hover {
+ background: rgba(125, 211, 252, 0.08);
+ box-shadow: 0 0 0 2px rgba(125, 211, 252, 0.16);
+ border-radius: 0.2em;
+}
+.slide-root .ppt-managed-image {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 180px;
+ cursor: pointer;
+}
+.slide-root .ppt-managed-image-frame {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ border-radius: 20px;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ background:
+ radial-gradient(circle at top right, rgba(125, 211, 252, 0.18), transparent 28%),
+ linear-gradient(135deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0.2));
+}
+.slide-root .ppt-managed-image-frame-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-style: dashed;
+}
+.slide-root .ppt-managed-image-empty-text {
+ padding: 10px 14px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.72);
+ color: rgba(15, 23, 42, 0.76);
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+}
+.slide-root .ppt-managed-image-el {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.slide-root .ppt-managed-image-badge {
+ position: absolute;
+ left: 12px;
+ bottom: 12px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ background: rgba(6, 16, 29, 0.68);
+ color: rgba(255, 255, 255, 0.92);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ backdrop-filter: blur(8px);
+ opacity: 0;
+ transform: translateY(6px);
+ transition: opacity 0.18s ease, transform 0.18s ease;
+}
+.slide-root .ppt-managed-image:hover .ppt-managed-image-frame {
+ box-shadow: 0 0 0 2px rgba(125, 211, 252, 0.2), 0 18px 36px rgba(15, 23, 42, 0.18);
+}
+.slide-root .ppt-managed-image:hover .ppt-managed-image-badge {
+ opacity: 1;
+ transform: translateY(0);
+}
+`.trim();
+};
+
+const buildSchemaSlideContext = (slide: FrontendSlide) => {
+ const blocks = sortBlocks(slide.blocks || []);
+ const findByRole = (...roles: string[]) => blocks.find((block) => roles.includes(block.role));
+ const images = blocks.filter((block) => block.type === 'image');
+ const lists = blocks.filter((block) => block.type === 'list');
+ const stats = blocks.filter((block) => block.type === 'stat');
+ const quotes = blocks.filter((block) => block.type === 'quote');
+ const callouts = blocks.filter((block) => block.type === 'callout');
+ const tables = blocks.filter((block) => block.type === 'table');
+ const bodyTexts = blocks.filter(
+ (block) =>
+ block.type === 'text'
+ && !['title', 'summary', 'eyebrow', 'footer', 'takeaway'].includes(block.role),
+ );
+ return {
+ blocks,
+ eyebrow: findByRole('eyebrow'),
+ title: findByRole('title'),
+ summary: findByRole('summary'),
+ takeaway: findByRole('takeaway'),
+ footer: findByRole('footer'),
+ images,
+ lists,
+ stats,
+ quotes,
+ callouts,
+ tables,
+ bodyTexts,
+ remaining: blocks.filter(
+ (block) =>
+ !['eyebrow', 'title', 'summary', 'takeaway', 'footer'].includes(block.role),
+ ),
+ };
+};
+
+const renderHeader = (slide: FrontendSlide, context: ReturnType) => `
+
+`;
+
+const renderFooter = (slide: FrontendSlide, context: ReturnType, theme?: FrontendDeckTheme | null) => {
+ const resolved = resolveTheme(theme);
+ const footerBlock = context.footer;
+ const footerAttrs = footerBlock
+ ? ` data-block-id="${escapeHtml(footerBlock.id)}" data-block-role="${escapeHtml(footerBlock.role)}"`
+ : '';
+ const footerField = footerBlock
+ ? buildFieldMap(slide).get(getFieldKeyForBlock(slide, footerBlock) || '')
+ : undefined;
+ const shouldRenderFooterPrimary = footerBlock
+ ? !hasLegacyChildForBlock(footerBlock)
+ : true;
+ return `
+
+ `;
+};
+
+const renderCanvasComponent = (slide: FrontendSlide, node: FrontendCanvasNode) => {
+ const props = node.props || {};
+ const component = normalizeCanvasComponent(node.component || props.component || props.kind);
+ const attrs = `data-block-id="${escapeHtml(node.id)}" data-block-role="${escapeHtml(component)}" data-canvas-node-id="${escapeHtml(node.id)}"`;
+ const visualStyle = getCanvasNodeVisualStyle(slide, node, component);
+ const styleAttr = buildCanvasStyleAttr(visualStyle);
+ const style = styleAttr ? ` style="${escapeHtml(styleAttr)}"` : '';
+
+ if (component === 'heading') {
+ const ref = props.text_ref || props.textRef || props.ref;
+ const value = resolveTextRef(slide, ref, String(props.text || slide.title || 'Untitled'));
+ const field = ref ? buildFieldMap(slide).get(String(ref)) : undefined;
+ return `${wrapEditableText(field, value || 'Untitled')}
`;
+ }
+
+ if (component === 'text' || component === 'quote' || component === 'callout') {
+ const ref = props.text_ref || props.textRef || props.ref;
+ const value = resolveTextRef(slide, ref, String(props.text || ''));
+ if (!value.trim()) return renderCanvasPlaceholder(node, 'Missing text reference');
+ const field = ref ? buildFieldMap(slide).get(String(ref)) : undefined;
+ const className = component === 'quote' ? 'schema-quote' : component === 'callout' ? 'schema-callout' : 'schema-canvas-text';
+ return `${wrapEditableText(field, value)}
`;
+ }
+
+ if (component === 'bullets') {
+ const ref = props.items_ref || props.itemsRef || props.ref;
+ const items = resolveListRef(slide, ref, Array.isArray(props.items) ? props.items.map((item) => String(item || '').trim()).filter(Boolean) : []);
+ if (items.length === 0) return renderCanvasPlaceholder(node, 'Missing list reference');
+ const field = ref ? buildFieldMap(slide).get(String(ref)) : undefined;
+ return `
+
+ ${items.map((item, index) => `- ${wrapEditableText(field, item, index)}
`).join('')}
+
+`.trim();
+ }
+
+ if (component === 'figure') {
+ const ref = String(props.asset_ref || props.assetRef || props.ref || '').trim();
+ const assetFromContent = resolveContentPath(slide.content, `assets.${ref}`) as Record | undefined;
+ const assetKey = String(
+ props.asset_key
+ || props.assetKey
+ || assetFromContent?.assetKey
+ || assetFromContent?.asset_key
+ || ref
+ || node.id,
+ );
+ return `${renderVisualAsset(buildAssetMap(slide).get(assetKey), assetKey, String(props.label || 'Figure'), visualStyle.imageFit)}
`;
+ }
+
+ if (component === 'stat') {
+ const valueRef = props.value_ref || props.valueRef || props.ref || props.text_ref || props.textRef;
+ const labelRef = props.label_ref || props.labelRef;
+ const value = resolveTextRef(slide, valueRef, String(props.value || props.text || ''));
+ const label = resolveTextRef(slide, labelRef, String(props.label || ''));
+ const valueField = valueRef ? buildFieldMap(slide).get(String(valueRef)) : undefined;
+ const labelField = labelRef ? buildFieldMap(slide).get(String(labelRef)) : undefined;
+ if (!value.trim() && !label.trim()) return renderCanvasPlaceholder(node, 'Missing stat reference');
+ return `
+
+
${wrapEditableText(valueField, value || '--')}
+ ${label ? `
${formatTextValue(label)}
` : ''}
+
+`.trim();
+ }
+
+ if (component === 'table') {
+ return renderCanvasTableMarkup(slide, node, attrs);
+ }
+
+ const fallbackText = resolveTextRef(slide, props.text_ref || props.textRef || props.ref, String(props.text || props.content || node.id || ''));
+ if (fallbackText.trim()) {
+ return `${formatTextValue(fallbackText)}
`;
+ }
+ return renderCanvasPlaceholder(node, 'Empty component');
+};
+
+const renderCanvasNode = (slide: FrontendSlide, node: FrontendCanvasNode): string => {
+ if (!node || !node.id) return '';
+ if (node.type === 'component') {
+ return renderCanvasComponent(slide, node);
+ }
+ const children = (node.children || []).map((child) => renderCanvasNode(slide, child)).filter(Boolean).join('');
+ if (!children) return '';
+ const style = buildCanvasStyle(node);
+ const styleAttr = style ? ` style="${escapeHtml(style)}"` : '';
+ return `
+
+ ${children}
+
+`.trim();
+};
+
+const renderCanvasSlide = (slide: FrontendSlide) => `
+
+
+
+ ${slide.root ? renderCanvasNode(slide, slide.root) : ''}
+
+ ${!slide.root ? renderCanvasPlaceholder({ type: 'component', id: 'missing_root', component: 'placeholder' }, 'Missing canvas root') : ''}
+
+
+`.trim();
+
+const TEMPLATE_RENDERERS: Record string> = {
+ title_cover: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ const coverBlocks = [
+ ...context.images,
+ ...context.callouts,
+ ...context.lists,
+ ...context.tables,
+ ...context.bodyTexts,
+ ...context.stats,
+ ];
+ const shouldRenderOnTitleRight = (block: FrontendSlideBlock) =>
+ block.type === 'image' ? !isLeftColumnBlock(block) : isRightColumnBlock(block);
+ const leftBlocks = renderColumnBlocks(
+ slide,
+ coverBlocks.filter((block) => !shouldRenderOnTitleRight(block)),
+ { limit: 4, bareLists: true },
+ );
+ const rightBlocks = renderColumnBlocks(
+ slide,
+ coverBlocks.filter(shouldRenderOnTitleRight),
+ { limit: 4, tallFirstImage: true },
+ );
+ return `
+
+
+
+ ${renderHeader(slide, context)}
+
+
+ ${leftBlocks.join('')}
+
+
+ ${rightBlocks.length > 0 ? rightBlocks.join('') : `
${renderVisualAsset(undefined, 'main_visual', 'Main Visual')}
`}
+
+
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+ section_divider: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ return `
+
+
+
+ ${renderHeader(slide, context)}
+ ${context.callouts[0] ? renderCalloutBlock(slide, context.callouts[0], 'schema-callout') : ''}
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+ text_focus: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ const leftBlocks = [
+ ...context.lists.filter((block) => !isRightColumnBlock(block)).map((block) => renderListBlock(slide, block, 'schema-bullets')),
+ ...context.bodyTexts.filter(isLeftColumnBlock).map((block) => renderGenericCard(slide, block)),
+ ...context.tables.filter(isLeftColumnBlock).map((block) => renderTableBlock(slide, block, 'schema-table-card')),
+ ...context.callouts.filter(isLeftColumnBlock).map((block) => renderCalloutBlock(slide, block, 'schema-callout')),
+ ...context.stats.filter(isLeftColumnBlock).map((block) => renderStatBlock(slide, block, 'schema-stat-card')),
+ ].filter(Boolean).slice(0, 5);
+ const rightBlocks = [
+ ...context.lists.filter(isRightColumnBlock).map((block) => renderGenericCard(slide, block)),
+ ...context.bodyTexts.filter((block) => !isLeftColumnBlock(block)).map((block) => renderGenericCard(slide, block)),
+ ...context.tables.filter((block) => !isLeftColumnBlock(block)).map((block) => renderTableBlock(slide, block, 'schema-table-card')),
+ ...context.callouts.filter((block) => !isLeftColumnBlock(block)).map((block) => renderCalloutBlock(slide, block, 'schema-callout')),
+ ...context.stats.filter((block) => !isLeftColumnBlock(block)).map((block) => renderStatBlock(slide, block, 'schema-stat-card')),
+ ].slice(0, 5);
+ return `
+
+
+ ${renderHeader(slide, context)}
+
+
+ ${leftBlocks.join('')}
+
+
+ ${rightBlocks.join('')}
+
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+ hero_visual: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ const contentBlocks = [
+ ...context.lists,
+ ...context.bodyTexts,
+ ...context.tables,
+ ...context.callouts,
+ ...context.stats,
+ ];
+ const leftBlocks = renderColumnBlocks(
+ slide,
+ contentBlocks.filter((block) => !isRightColumnBlock(block)),
+ { limit: 4, bareLists: true },
+ );
+ const rightBlocks = renderColumnBlocks(
+ slide,
+ [
+ ...context.images,
+ ...contentBlocks.filter(isRightColumnBlock),
+ ],
+ { limit: 5, tallFirstImage: true },
+ );
+ return `
+
+
+ ${renderHeader(slide, context)}
+
+
+ ${leftBlocks.join('')}
+
+
+ ${rightBlocks.join('')}
+
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+ split_media: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ const contentBlocks = [
+ ...context.lists,
+ ...context.bodyTexts,
+ ...context.tables,
+ ...context.callouts,
+ ...context.stats,
+ ];
+ const leftBlocks = renderColumnBlocks(
+ slide,
+ contentBlocks.filter((block) => !isRightColumnBlock(block)),
+ { limit: 5, bareLists: true },
+ );
+ const rightBlocks = renderColumnBlocks(
+ slide,
+ [
+ ...context.images,
+ ...contentBlocks.filter(isRightColumnBlock),
+ ],
+ { limit: 5, tallFirstImage: true },
+ );
+ return `
+
+`.trim();
+ },
+ visual_compare: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ const cards = [
+ ...context.lists.map((block) => renderGenericCard(slide, block)),
+ ...context.tables.map((block) => renderTableBlock(slide, block, 'schema-table-card')),
+ ...context.bodyTexts.map((block) => renderGenericCard(slide, block)),
+ ...context.callouts.map((block) => renderCalloutBlock(slide, block, 'schema-callout')),
+ ...context.stats.map((block) => renderStatBlock(slide, block, 'schema-stat-card')),
+ ].slice(0, 4);
+ return `
+
+
+ ${renderHeader(slide, context)}
+
+
+ ${context.images.slice(0, 2).map((block) => renderImageBlock(slide, block, 'schema-visual-card tall')).join('')}
+
+
+ ${cards.join('')}
+
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+ insight_grid: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ const cards = context.remaining.filter((block) => block.type !== 'image').slice(0, 6);
+ return `
+
+
+ ${renderHeader(slide, context)}
+
+ ${cards.map((block) => renderGenericCard(slide, block)).join('')}
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+ metrics_dashboard: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ return `
+
+
+ ${renderHeader(slide, context)}
+
+
+ ${context.stats.slice(0, 3).map((block) => renderStatBlock(slide, block, 'schema-stat-card')).join('')}
+
+
+
+ ${context.lists[0] ? renderListBlock(slide, context.lists[0], 'schema-bullets') : ''}
+ ${context.callouts[0] ? renderCalloutBlock(slide, context.callouts[0], 'schema-callout') : ''}
+
+ ${context.images[0] ? renderImageBlock(slide, context.images[0], 'schema-visual-card') : context.bodyTexts[0] ? renderGenericCard(slide, context.bodyTexts[0]) : ''}
+
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+ timeline_overview: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ const timelineBlock = context.lists[0];
+ const timelineField = buildFieldMap(slide).get(getFieldKeyForBlock(slide, timelineBlock || ({} as FrontendSlideBlock)) || '');
+ const timelineItems = (timelineField?.items || timelineBlock?.items || []).filter((item) => item.trim());
+ const shouldRenderTimelineItems = timelineItems.length > 0 && (!timelineBlock || !hasLegacyChildForBlock(timelineBlock));
+ return `
+
+
+ ${renderHeader(slide, context)}
+
+
+ ${shouldRenderTimelineItems ? timelineItems.map((item, index) => `
+
+
+
${wrapEditableText(timelineField, item, index)}
+
+ `).join('') : ''}
+ ${timelineBlock?.children?.length ? renderBlockChildren(slide, timelineBlock) : ''}
+
+
+ ${context.images[0] ? renderImageBlock(slide, context.images[0], 'schema-visual-card tall') : ''}
+ ${context.callouts[0] ? renderCalloutBlock(slide, context.callouts[0], 'schema-callout') : ''}
+
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+ stacked_cards: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ const cards = [
+ ...context.images.map((block) => renderImageBlock(slide, block, 'schema-visual-card')),
+ ...context.remaining.filter((block) => block.type !== 'image').map((block) => renderGenericCard(slide, block)),
+ ].slice(0, 6);
+ return `
+
+
+ ${renderHeader(slide, context)}
+
+ ${cards.join('')}
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+ quote_focus: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ const quoteBlock = context.quotes[0] || context.callouts[0] || context.bodyTexts[0];
+ return `
+
+
+ ${renderHeader(slide, context)}
+
+ ${quoteBlock ? (quoteBlock.type === 'quote' ? renderQuoteBlock(slide, quoteBlock, 'schema-quote') : renderCalloutBlock(slide, quoteBlock, 'schema-quote')) : ''}
+
+ ${context.lists[0] ? renderGenericCard(slide, context.lists[0]) : ''}
+ ${context.images[0] ? renderImageBlock(slide, context.images[0], 'schema-visual-card') : context.callouts.slice(1, 2).map((block) => renderCalloutBlock(slide, block, 'schema-callout')).join('')}
+
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+ dual_list: (slide, theme) => {
+ const context = buildSchemaSlideContext(slide);
+ return `
+
+
+ ${renderHeader(slide, context)}
+
+ ${context.lists.slice(0, 2).map((block) => renderGenericCard(slide, block)).join('')}
+ ${context.bodyTexts[0] ? renderGenericCard(slide, context.bodyTexts[0]) : context.callouts[0] ? renderCalloutBlock(slide, context.callouts[0], 'schema-callout') : ''}
+ ${context.images[0] ? renderImageBlock(slide, context.images[0], 'schema-visual-card') : ''}
+
+ ${renderFooter(slide, context, theme)}
+
+
+`.trim();
+ },
+};
+
+export const isSchemaDrivenSlide = (slide: FrontendSlide) =>
+ Boolean(slide.root || slide.schemaVersion || (Array.isArray(slide.blocks) && slide.blocks.length > 0));
+
+export const buildSchemaSlideMarkup = (slide: FrontendSlide, theme?: FrontendDeckTheme | null) => {
+ if (slide.renderEngine === 'canvas' && slide.root) {
+ return `${renderCanvasSlide(slide)}`;
+ }
+ const templateKey = pickSchemaTemplateKey(slide);
+ const renderer = TEMPLATE_RENDERERS[templateKey] || TEMPLATE_RENDERERS.text_focus;
+ return `${renderer(slide, theme)}`;
+};
diff --git a/frontend-workflow/src/components/paper2ppt/frontendSlideUtils.ts b/frontend-workflow/src/components/paper2ppt/frontendSlideUtils.ts
index d9ec30da..d5a5bdad 100644
--- a/frontend-workflow/src/components/paper2ppt/frontendSlideUtils.ts
+++ b/frontend-workflow/src/components/paper2ppt/frontendSlideUtils.ts
@@ -1,4 +1,5 @@
-import { FrontendEditableField, FrontendSlide, FrontendVisualAsset } from './types';
+import { FrontendDeckTheme, FrontendEditableField, FrontendSlide, FrontendVisualAsset } from './types';
+import { buildSchemaSlideMarkup, isSchemaDrivenSlide } from './frontendSchemaRenderer';
const FIELD_PLACEHOLDER_RE = /\{\{(?:field|list):([a-zA-Z0-9_]+)\}\}/g;
const IMAGE_PLACEHOLDER_RE = /\{\{image:([a-zA-Z0-9_]+)\}\}/g;
@@ -146,7 +147,13 @@ const renderVisualAsset = (asset: FrontendVisualAsset) => {
`.trim();
};
-export const buildFrontendSlideMarkup = (slide: FrontendSlide) => {
+export { isSchemaDrivenSlide } from './frontendSchemaRenderer';
+
+export const buildFrontendSlideMarkup = (slide: FrontendSlide, deckTheme?: FrontendDeckTheme | null) => {
+ if (isSchemaDrivenSlide(slide)) {
+ return buildSchemaSlideMarkup(slide, deckTheme);
+ }
+
let html = ensureSlideRoot(sanitizeTemplate(slide.htmlTemplate || ''));
html = replaceAttributePlaceholders(html, slide.editableFields);
slide.editableFields.forEach((field) => {
diff --git a/frontend-workflow/src/components/paper2ppt/index.tsx b/frontend-workflow/src/components/paper2ppt/index.tsx
index cddf541e..b50c9f72 100644
--- a/frontend-workflow/src/components/paper2ppt/index.tsx
+++ b/frontend-workflow/src/components/paper2ppt/index.tsx
@@ -16,8 +16,16 @@ import {
} from '../../utils/pointsMessaging';
import {
+ FrontendBlockChild,
FrontendDeckTheme,
+ FrontendEditableField,
+ FrontendCanvasNode,
+ FrontendCanvasVisualSpec,
+ FrontendCanvasVisualStyle,
FrontendSlide,
+ FrontendSlideBlock,
+ FrontendTableData,
+ parseFrontendInsertZoneTarget,
PptGenerationMode,
Step,
SlideOutline,
@@ -44,6 +52,10 @@ import {
inspectSlideLayout,
validateFrontendSlideCode,
} from './frontendSlideUtils';
+import {
+ buildCanvasSlidesPptxBlob,
+ canExportCanvasSlidesToPptx,
+} from './canvasPptxExporter';
const MANAGED_CREDENTIAL_SCOPE = 'paper2ppt';
@@ -495,23 +507,1224 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
const getPreviewPath = (item: any, key: string) =>
String(item?.[`${key}_preview_path`] || item?.[`${key}PreviewPath`] || '').trim();
+ const SUPPORTED_SCHEMA_TEMPLATE_KEYS = new Set([
+ 'title_cover',
+ 'section_divider',
+ 'text_focus',
+ 'hero_visual',
+ 'split_media',
+ 'visual_compare',
+ 'insight_grid',
+ 'metrics_dashboard',
+ 'timeline_overview',
+ 'stacked_cards',
+ 'quote_focus',
+ 'dual_list',
+ ]);
+
+ const TEMPLATE_KEY_ALIASES: Record = {
+ cover: 'title_cover',
+ cover_slide: 'title_cover',
+ title_slide: 'title_cover',
+ divider: 'section_divider',
+ section: 'section_divider',
+ section_break: 'section_divider',
+ text_only: 'text_focus',
+ text_heavy: 'text_focus',
+ hero: 'hero_visual',
+ single_visual: 'hero_visual',
+ media_split: 'split_media',
+ split_layout: 'split_media',
+ compare: 'visual_compare',
+ comparison: 'visual_compare',
+ image_compare: 'visual_compare',
+ grid: 'insight_grid',
+ card_grid: 'insight_grid',
+ dashboard: 'metrics_dashboard',
+ metrics: 'metrics_dashboard',
+ timeline: 'timeline_overview',
+ process_timeline: 'timeline_overview',
+ cards: 'stacked_cards',
+ card_stack: 'stacked_cards',
+ quote: 'quote_focus',
+ quote_slide: 'quote_focus',
+ two_lists: 'dual_list',
+ dual_column_list: 'dual_list',
+ };
+
+ const PREFERRED_SCHEMA_FIELD_KEYS: Record = {
+ title: 'title',
+ summary: 'summary',
+ key_points: 'key_points',
+ takeaway: 'takeaway',
+ footer: 'footer',
+ eyebrow: 'eyebrow',
+ };
+
+ const normalizeStringList = (value: unknown): string[] =>
+ Array.isArray(value)
+ ? value.map((item) => String(item || '').trim()).filter(Boolean)
+ : [];
+
+ const normalizeTableRows = (value: unknown): string[][] =>
+ Array.isArray(value)
+ ? value
+ .map((row) =>
+ Array.isArray(row)
+ ? row.map((cell) => String(cell ?? '').trim())
+ : [],
+ )
+ .filter((row) => row.length > 0)
+ : [];
+
+ const normalizeSchemaTableData = (value: unknown): FrontendTableData | undefined => {
+ const source = (value && typeof value === 'object') ? value as Record : {};
+ const headers = normalizeStringList(
+ source.headers
+ || source.columns
+ || source.cols,
+ );
+ const rows = normalizeTableRows(
+ source.rows
+ || source.data
+ || source.values,
+ );
+ const maxColumns = Math.max(headers.length, ...rows.map((row) => row.length), 0);
+ if (maxColumns === 0) {
+ return undefined;
+ }
+ return {
+ headers: Array.from({ length: maxColumns }, (_, index) => headers[index] || `列 ${index + 1}`),
+ rows: rows.length > 0
+ ? rows.map((row) => Array.from({ length: maxColumns }, (_, index) => row[index] || ''))
+ : [Array.from({ length: maxColumns }, () => '')],
+ };
+ };
+
+ const toFiniteNumber = (value: unknown, fallback: number) => {
+ const num = Number(value);
+ return Number.isFinite(num) ? num : fallback;
+ };
+
+ const slugifySchemaToken = (value: unknown) =>
+ String(value || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '');
+
+ const normalizeSchemaTemplateKey = (value: unknown) => {
+ const candidate = slugifySchemaToken(value);
+ if (!candidate) {
+ return '';
+ }
+ if (SUPPORTED_SCHEMA_TEMPLATE_KEYS.has(candidate)) {
+ return candidate;
+ }
+ return TEMPLATE_KEY_ALIASES[candidate] || '';
+ };
+
+ const pickSchemaTemplateKeyFromBlocks = (
+ blocks: FrontendSlideBlock[],
+ visualAssetCount: number,
+ ) => {
+ const imageCount = blocks.filter((block) => block.type === 'image').length;
+ const listCount = blocks.filter((block) => block.type === 'list').length;
+ const statCount = blocks.filter((block) => block.type === 'stat').length;
+ const quoteCount = blocks.filter((block) => block.type === 'quote').length;
+
+ if (quoteCount > 0) return 'quote_focus';
+ if (imageCount >= 2) return 'visual_compare';
+ if (statCount >= 2) return 'metrics_dashboard';
+ if (listCount >= 2) return 'dual_list';
+ if (imageCount === 1 && listCount > 0) return 'split_media';
+ if (imageCount === 1 || visualAssetCount > 0) return 'hero_visual';
+ if (blocks.length <= 3) return 'section_divider';
+ if (blocks.length >= 6) return 'insight_grid';
+ return 'text_focus';
+ };
+
+ const normalizeSchemaLayoutMode = (value: unknown): FrontendSlide['layoutMode'] => {
+ const candidate = String(value || '').trim().toLowerCase();
+ if (candidate === 'fixed' || candidate === 'hybrid' || candidate === 'fluid') {
+ return candidate;
+ }
+ return 'fluid';
+ };
+
+ const normalizeSchemaBlockType = (value: unknown): FrontendSlideBlock['type'] => {
+ const candidate = String(value || '').trim().toLowerCase();
+ const aliases: Record = {
+ paragraph: 'text',
+ textarea: 'text',
+ body: 'text',
+ bullets: 'list',
+ bullet_list: 'list',
+ points: 'list',
+ figure: 'image',
+ visual: 'image',
+ chart: 'image',
+ metric: 'stat',
+ number: 'stat',
+ note: 'callout',
+ };
+ const normalized = aliases[candidate] || candidate || 'text';
+ if (
+ normalized === 'text'
+ || normalized === 'list'
+ || normalized === 'image'
+ || normalized === 'quote'
+ || normalized === 'stat'
+ || normalized === 'callout'
+ || normalized === 'table'
+ ) {
+ return normalized;
+ }
+ return 'text';
+ };
+
+ const normalizeSchemaBlockLayout = (value: unknown, fallbackOrder: number): FrontendSlideBlock['layout'] => {
+ const layout = (value && typeof value === 'object') ? value as Record : {};
+ const zoneCandidate = String(
+ layout.zone
+ || layout.slot
+ || layout.region
+ || layout.area
+ || 'main',
+ ).trim().toLowerCase();
+ const zone = (
+ zoneCandidate === 'header'
+ || zoneCandidate === 'main'
+ || zoneCandidate === 'aside'
+ || zoneCandidate === 'footer'
+ || zoneCandidate === 'full'
+ || zoneCandidate === 'left'
+ || zoneCandidate === 'right'
+ ) ? zoneCandidate : 'main';
+
+ const span = Math.max(1, Math.min(12, Math.round(toFiniteNumber(layout.span || layout.columns, zone === 'header' || zone === 'footer' || zone === 'full' ? 12 : 6))));
+ const order = Math.max(1, Math.round(toFiniteNumber(layout.order, fallbackOrder)));
+ const widthCandidate = String(
+ layout.preferred_width
+ || layout.preferredWidth
+ || layout.width
+ || '',
+ ).trim().toLowerCase();
+ const preferredWidth = (
+ widthCandidate === 'full'
+ || widthCandidate === 'wide'
+ || widthCandidate === 'half'
+ || widthCandidate === 'third'
+ || widthCandidate === 'narrow'
+ || widthCandidate === 'auto'
+ ) ? widthCandidate : span >= 12 ? 'full' : span >= 8 ? 'wide' : span >= 6 ? 'half' : span >= 4 ? 'third' : 'auto';
+
+ const sideCandidate = String(
+ layout.preferred_side
+ || layout.preferredSide
+ || layout.side
+ || '',
+ ).trim().toLowerCase();
+ const preferredSide = (
+ sideCandidate === 'left'
+ || sideCandidate === 'right'
+ || sideCandidate === 'center'
+ || sideCandidate === 'auto'
+ ) ? sideCandidate : zone === 'left' ? 'left' : zone === 'right' || zone === 'aside' ? 'right' : 'auto';
+
+ const emphasisCandidate = String(layout.emphasis || '').trim().toLowerCase();
+ const emphasis = (
+ emphasisCandidate === 'high'
+ || emphasisCandidate === 'medium'
+ || emphasisCandidate === 'low'
+ ) ? emphasisCandidate : 'medium';
+
+ return {
+ zone,
+ span,
+ order,
+ preferredWidth,
+ preferredSide,
+ emphasis,
+ };
+ };
+
+ const normalizeSchemaBlockChildren = (rawChildren: unknown): FrontendBlockChild[] => {
+ if (!Array.isArray(rawChildren)) {
+ return [];
+ }
+ const seenIds = new Set();
+ const children: FrontendBlockChild[] = [];
+ rawChildren.forEach((rawChild, index) => {
+ if (!rawChild || typeof rawChild !== 'object') {
+ return;
+ }
+ const child = rawChild as Record;
+ let id = slugifySchemaToken(
+ child.id
+ || child.key
+ || child.field_key
+ || child.fieldKey
+ || child.role
+ || `child_${index + 1}`,
+ ) || `child_${index + 1}`;
+ if (seenIds.has(id)) {
+ id = `${id}_${index + 1}`;
+ }
+ seenIds.add(id);
+
+ const type = normalizeSchemaBlockType(
+ child.type
+ || child.block_type
+ || child.blockType
+ || child.kind,
+ );
+ const role = slugifySchemaToken(
+ child.role
+ || child.semantic_role
+ || child.semanticRole
+ || id,
+ ) || id;
+ const items = normalizeStringList(child.items || child.bullets || child.points);
+ const content = String(
+ child.content
+ || child.text
+ || child.value
+ || child.body
+ || '',
+ ).trim();
+ const assetKey = type === 'image'
+ ? slugifySchemaToken(
+ child.asset_key
+ || child.assetKey
+ || child.image_key
+ || child.imageKey
+ || child.visual_key
+ || child.visualKey
+ || id,
+ ) || id
+ : undefined;
+ const tableData = type === 'table'
+ ? normalizeSchemaTableData(
+ child.table_data
+ || child.tableData
+ || child.table
+ )
+ : undefined;
+
+ if (type === 'table' && !tableData) {
+ return;
+ }
+ if (type !== 'image' && type !== 'list' && type !== 'table' && !content) {
+ return;
+ }
+ if (type === 'list' && items.length === 0 && !content) {
+ return;
+ }
+
+ children.push({
+ id,
+ type,
+ role,
+ content,
+ items: type === 'list' && items.length === 0 && content
+ ? content
+ .split(/\n+/)
+ .map((item) => item.replace(/^[\s\-•]+/, '').trim())
+ .filter(Boolean)
+ : items,
+ assetKey,
+ tableData,
+ });
+ });
+ return children;
+ };
+
+ const normalizeSchemaBlocks = (
+ rawBlocks: unknown,
+ visualAssets: Array<{ key: string }>,
+ ): FrontendSlideBlock[] => {
+ if (!Array.isArray(rawBlocks)) {
+ return [];
+ }
+
+ const availableAssetKeys = visualAssets
+ .map((asset) => slugifySchemaToken(asset.key) || String(asset.key || '').trim())
+ .filter(Boolean);
+ const usedBlockIds = new Set();
+ const usedAssetKeys: string[] = [];
+
+ const pickAssetKey = (preferred: string, fallbackId: string) => {
+ const normalizedPreferred = slugifySchemaToken(preferred);
+ if (normalizedPreferred) {
+ return normalizedPreferred;
+ }
+ const nextUnused = availableAssetKeys.find((key) => !usedAssetKeys.includes(key));
+ return nextUnused || fallbackId;
+ };
+
+ const normalizedBlocks: FrontendSlideBlock[] = [];
+
+ rawBlocks.forEach((rawBlock, index) => {
+ if (!rawBlock || typeof rawBlock !== 'object') {
+ return;
+ }
+ const block = rawBlock as Record;
+ const fallbackId = `block_${index + 1}`;
+ let id = slugifySchemaToken(
+ block.id
+ || block.key
+ || block.field_key
+ || block.fieldKey
+ || block.role
+ || fallbackId,
+ ) || fallbackId;
+ if (usedBlockIds.has(id)) {
+ id = `${id}_${index + 1}`;
+ }
+ usedBlockIds.add(id);
+
+ const type = normalizeSchemaBlockType(
+ block.type
+ || block.block_type
+ || block.blockType
+ || block.kind,
+ );
+ const role = slugifySchemaToken(
+ block.role
+ || block.semantic_role
+ || block.semanticRole
+ || id,
+ ) || id;
+
+ const items = normalizeStringList(
+ block.items
+ || block.bullets
+ || block.points,
+ );
+ const content = String(
+ block.content
+ || block.text
+ || block.value
+ || block.body
+ || '',
+ ).trim();
+
+ const normalizedItems = type === 'list' && items.length === 0 && content
+ ? content
+ .split(/\n+/)
+ .map((item) => item.replace(/^[\s\-•]+/, '').trim())
+ .filter(Boolean)
+ : items;
+
+ if (type === 'list' && normalizedItems.length === 0) {
+ return;
+ }
+
+ const tableData = type === 'table'
+ ? normalizeSchemaTableData(
+ block.table_data
+ || block.tableData
+ || block.table
+ )
+ : undefined;
+
+ if (type === 'table' && !tableData) {
+ return;
+ }
+ if (type !== 'image' && type !== 'table' && !content && normalizedItems.length === 0) {
+ return;
+ }
+
+ const assetKey = type === 'image'
+ ? pickAssetKey(
+ String(
+ block.asset_key
+ || block.assetKey
+ || block.image_key
+ || block.imageKey
+ || block.visual_key
+ || block.visualKey
+ || '',
+ ),
+ id,
+ )
+ : undefined;
+
+ if (assetKey && !usedAssetKeys.includes(assetKey)) {
+ usedAssetKeys.push(assetKey);
+ }
+
+ normalizedBlocks.push({
+ id,
+ type,
+ role,
+ content,
+ items: normalizedItems,
+ assetKey,
+ tableData,
+ children: normalizeSchemaBlockChildren(
+ block.children
+ || block.content_items
+ || block.contentItems
+ || block.elements,
+ ),
+ layout: normalizeSchemaBlockLayout(
+ block.layout
+ || block.layout_hint
+ || block.layoutHint,
+ index + 1,
+ ),
+ });
+ });
+
+ return normalizedBlocks
+ .sort((a, b) => a.layout.order - b.layout.order || a.id.localeCompare(b.id))
+ .map((block, index) => ({
+ ...block,
+ layout: {
+ ...block.layout,
+ order: index + 1,
+ },
+ }));
+ };
+
+ const getEditableFieldKeyForBlock = (slide: FrontendSlide, block: FrontendSlideBlock) => {
+ const fieldKeys = new Set(slide.editableFields.map((field) => field.key));
+ const candidates = [
+ PREFERRED_SCHEMA_FIELD_KEYS[block.role] || '',
+ block.role || '',
+ block.id || '',
+ slugifySchemaToken(block.role || ''),
+ slugifySchemaToken(block.id || ''),
+ ].filter(Boolean);
+ return candidates.find((candidate) => fieldKeys.has(candidate));
+ };
+
+ const buildIdleFrontendReview = (): NonNullable => ({
+ status: 'idle',
+ summary: '',
+ issues: [],
+ });
+
+ const buildUniqueBlockId = (slide: FrontendSlide, prefix: string) => {
+ const base = slugifySchemaToken(prefix) || 'block';
+ const existingIds = new Set([
+ ...slide.blocks.map((block) => block.id),
+ ...slide.blocks.flatMap((block) => (block.children || []).map((child) => child.id)),
+ ...slide.editableFields.map((field) => field.key),
+ ...slide.visualAssets.map((asset) => asset.key),
+ ]);
+ let candidate = `${base}_${Date.now().toString(36)}`;
+ let counter = 1;
+ while (existingIds.has(candidate)) {
+ counter += 1;
+ candidate = `${base}_${Date.now().toString(36)}_${counter}`;
+ }
+ return candidate;
+ };
+
+ const buildUniqueChildId = (slide: FrontendSlide, prefix: string) => {
+ const base = slugifySchemaToken(prefix) || 'item';
+ const existingIds = new Set([
+ ...slide.blocks.map((block) => block.id),
+ ...slide.blocks.flatMap((block) => (block.children || []).map((child) => child.id)),
+ ...slide.editableFields.map((field) => field.key),
+ ...slide.visualAssets.map((asset) => asset.key),
+ ]);
+ let candidate = `${base}_${Date.now().toString(36)}`;
+ let counter = 1;
+ while (existingIds.has(candidate)) {
+ counter += 1;
+ candidate = `${base}_${Date.now().toString(36)}_${counter}`;
+ }
+ return candidate;
+ };
+
+ const collectCanvasNodeIds = (node?: FrontendCanvasNode): string[] => {
+ if (!node) return [];
+ return [
+ node.id,
+ ...(node.children || []).flatMap((child) => collectCanvasNodeIds(child)),
+ ].filter(Boolean);
+ };
+
+ const buildUniqueCanvasNodeId = (slide: FrontendSlide, prefix: string) => {
+ const base = slugifySchemaToken(prefix) || 'node';
+ const existingIds = new Set([
+ ...collectCanvasNodeIds(slide.root),
+ ...slide.blocks.map((block) => block.id),
+ ...slide.editableFields.map((field) => field.key),
+ ...slide.visualAssets.map((asset) => asset.key),
+ ]);
+ let candidate = `${base}_${Date.now().toString(36)}`;
+ let counter = 1;
+ while (existingIds.has(candidate)) {
+ counter += 1;
+ candidate = `${base}_${Date.now().toString(36)}_${counter}`;
+ }
+ return candidate;
+ };
+
+ const cloneTableData = (tableData: FrontendTableData): FrontendTableData => ({
+ headers: [...tableData.headers],
+ rows: tableData.rows.map((row) => [...row]),
+ });
+
+ const normalizeContentTableData = (value: unknown): FrontendTableData | null => {
+ if (!value || typeof value !== 'object') return null;
+ const source = value as Record;
+ const headers = Array.isArray(source.headers)
+ ? source.headers.map((item) => String(item ?? ''))
+ : Array.isArray(source.columns)
+ ? source.columns.map((item) => String(item ?? ''))
+ : [];
+ const rows = Array.isArray(source.rows)
+ ? source.rows
+ .filter((row): row is unknown[] => Array.isArray(row))
+ .map((row) => row.map((cell) => String(cell ?? '')))
+ : [];
+ if (headers.length === 0 && rows.length === 0) return null;
+ const maxCols = Math.max(headers.length, ...rows.map((row) => row.length), 1);
+ return {
+ headers: Array.from({ length: maxCols }, (_, index) => headers[index] || `列 ${index + 1}`),
+ rows: rows.length > 0
+ ? rows.map((row) => Array.from({ length: maxCols }, (_, index) => row[index] || ''))
+ : [Array.from({ length: maxCols }, () => '')],
+ };
+ };
+
+ const mergeTableCellFieldIntoGroups = (
+ groups: Record,
+ fieldKey: string,
+ value: string,
+ ) => {
+ const match = /^(.+)_cell_(h|\d+)_(\d+)$/.exec(fieldKey);
+ if (!match) return false;
+ const ownerId = match[1];
+ const rowIndex = match[2] === 'h' ? 'h' : Number.parseInt(match[2], 10);
+ const colIndex = Number.parseInt(match[3], 10);
+ if (!Number.isFinite(colIndex) || (rowIndex !== 'h' && !Number.isFinite(rowIndex))) {
+ return true;
+ }
+ const tableData = groups[ownerId] || { headers: [], rows: [] };
+ if (rowIndex === 'h') {
+ while (tableData.headers.length <= colIndex) {
+ tableData.headers.push(`列 ${tableData.headers.length + 1}`);
+ }
+ tableData.headers[colIndex] = value;
+ } else {
+ while (tableData.rows.length <= rowIndex) {
+ tableData.rows.push([]);
+ }
+ while (tableData.rows[rowIndex].length <= colIndex) {
+ tableData.rows[rowIndex].push('');
+ }
+ tableData.rows[rowIndex][colIndex] = value;
+ }
+ groups[ownerId] = tableData;
+ return true;
+ };
+
+ const buildCanvasContentFromSlide = (slide: FrontendSlide): Record => {
+ const content: Record = {
+ ...(slide.content || {}),
+ };
+ Object.keys(content).forEach((key) => {
+ if (/^(.+)_cell_(h|\d+)_(\d+)$/.test(key)) {
+ delete content[key];
+ }
+ });
+ const tableFieldGroups: Record = {};
+ slide.editableFields.forEach((field) => {
+ if (mergeTableCellFieldIntoGroups(tableFieldGroups, field.key, field.value)) {
+ return;
+ }
+ content[field.key] = field.type === 'list' ? [...field.items] : field.value;
+ });
+ slide.blocks.forEach((block) => {
+ if (block.type === 'table' && block.tableData) {
+ content[block.role || block.id] = cloneTableData(block.tableData);
+ } else if (block.type === 'list' && block.items.length > 0 && !(block.role in content)) {
+ content[block.role || block.id] = [...block.items];
+ } else if (block.content && !(block.role in content)) {
+ content[block.role || block.id] = block.content;
+ }
+ });
+ Object.entries(tableFieldGroups).forEach(([ownerId, tableData]) => {
+ content[ownerId] = {
+ ...(content[ownerId] && typeof content[ownerId] === 'object' ? content[ownerId] as Record : {}),
+ ...cloneTableData(tableData),
+ };
+ });
+ content.assets = {
+ ...((content.assets && typeof content.assets === 'object') ? content.assets as Record : {}),
+ ...Object.fromEntries(
+ slide.visualAssets.map((asset) => [
+ asset.key,
+ {
+ type: 'image',
+ asset_key: asset.key,
+ src: asset.src,
+ preview_src: asset.previewSrc || asset.src,
+ original_src: asset.originalSrc || asset.storagePath || asset.src,
+ alt: asset.alt,
+ },
+ ]),
+ ),
+ };
+ return content;
+ };
+
+ const buildCanvasRootFromSlide = (slide: FrontendSlide): FrontendCanvasNode => {
+ if (slide.root) return slide.root;
+ const headerNodes: FrontendCanvasNode[] = [];
+ const mainNodes: FrontendCanvasNode[] = [];
+ const asideNodes: FrontendCanvasNode[] = [];
+ const footerNodes: FrontendCanvasNode[] = [];
+ slide.blocks.forEach((block) => {
+ const component = block.role === 'title'
+ ? 'heading'
+ : block.type === 'list'
+ ? 'bullets'
+ : block.type === 'image'
+ ? 'figure'
+ : block.type === 'table'
+ ? 'table'
+ : block.type === 'stat'
+ ? 'stat'
+ : block.type === 'callout'
+ ? 'callout'
+ : 'text';
+ const props = component === 'bullets'
+ ? { items_ref: block.role || block.id }
+ : component === 'figure'
+ ? { asset_ref: block.assetKey || block.id, asset_key: block.assetKey || block.id }
+ : component === 'table'
+ ? { table_ref: block.role || block.id }
+ : { text_ref: block.role || block.id };
+ const node: FrontendCanvasNode = {
+ type: 'component',
+ id: block.id,
+ component,
+ props,
+ };
+ const zone = block.layout?.zone || 'main';
+ if (zone === 'header') headerNodes.push(node);
+ else if (zone === 'footer') footerNodes.push(node);
+ else if (zone === 'aside' || zone === 'right') asideNodes.push(node);
+ else mainNodes.push(node);
+ });
+ const mainChildren: FrontendCanvasNode[] = [
+ {
+ type: 'container',
+ id: 'main_left',
+ style: { direction: 'column', gap: 18, weight: 1, align: 'stretch' },
+ children: mainNodes,
+ },
+ ];
+ if (asideNodes.length > 0) {
+ mainChildren.push({
+ type: 'container',
+ id: 'main_right',
+ style: { direction: 'column', gap: 18, weight: 1, align: 'stretch' },
+ children: asideNodes,
+ });
+ }
+ return {
+ type: 'container',
+ id: 'root',
+ style: { direction: 'column', gap: 24, align: 'stretch' },
+ children: [
+ ...(headerNodes.length > 0 ? [{
+ type: 'container' as const,
+ id: 'header',
+ style: { direction: 'column' as const, gap: 12, align: 'stretch' as const },
+ children: headerNodes,
+ }] : []),
+ {
+ type: 'container',
+ id: 'main',
+ style: { direction: mainChildren.length > 1 ? 'row' : 'column', gap: 24, weight: 1, align: 'stretch' },
+ children: mainChildren,
+ },
+ ...(footerNodes.length > 0 ? [{
+ type: 'container' as const,
+ id: 'footer',
+ style: { direction: 'row' as const, gap: 16, align: 'end' as const, justify: 'between' as const },
+ children: footerNodes,
+ }] : []),
+ ],
+ };
+ };
+
+ const appendCanvasNodeToContainer = (
+ root: FrontendCanvasNode,
+ node: FrontendCanvasNode,
+ targetId?: string,
+ ): FrontendCanvasNode => {
+ let inserted = false;
+ const appendToFirstMain = (current: FrontendCanvasNode): FrontendCanvasNode => {
+ if ((current.id === 'main_left' || current.id === 'main' || current.id === 'root') && current.type === 'container') {
+ inserted = true;
+ return { ...current, children: [...(current.children || []), node] };
+ }
+ return {
+ ...current,
+ children: (current.children || []).map((child) => inserted ? child : appendToFirstMain(child)),
+ };
+ };
+ const visit = (current: FrontendCanvasNode): FrontendCanvasNode => {
+ const children = current.children || [];
+ if (targetId && current.id === targetId && current.type === 'container') {
+ inserted = true;
+ return { ...current, children: [...children, node] };
+ }
+ const targetIndex = targetId ? children.findIndex((child) => child.id === targetId) : -1;
+ if (targetIndex >= 0) {
+ const target = children[targetIndex];
+ inserted = true;
+ if (target.type === 'container') {
+ const nextTarget = { ...target, children: [...(target.children || []), node] };
+ return {
+ ...current,
+ children: children.map((child, index) => index === targetIndex ? nextTarget : child),
+ };
+ }
+ return {
+ ...current,
+ children: [
+ ...children.slice(0, targetIndex + 1),
+ node,
+ ...children.slice(targetIndex + 1),
+ ],
+ };
+ }
+ return {
+ ...current,
+ children: children.map((child) => inserted ? child : visit(child)),
+ };
+ };
+ const nextRoot = targetId ? visit(root) : root;
+ return inserted ? nextRoot : appendToFirstMain(nextRoot);
+ };
+
+ const insertCanvasNode = (
+ slide: FrontendSlide,
+ node: FrontendCanvasNode,
+ options: {
+ targetId?: string;
+ contentPatch?: Record;
+ editableFields?: FrontendEditableField | FrontendEditableField[];
+ visualAssets?: FrontendSlide['visualAssets'];
+ } = {},
+ ): FrontendSlide => {
+ const editableFields = Array.isArray(options.editableFields)
+ ? options.editableFields
+ : options.editableFields
+ ? [options.editableFields]
+ : [];
+ const root = buildCanvasRootFromSlide(slide);
+ const targetId = parseFrontendInsertZoneTarget(options.targetId) ? undefined : options.targetId;
+ return {
+ ...slide,
+ renderEngine: 'canvas',
+ schemaVersion: slide.schemaVersion || 'ppt_canvas_schema_v1',
+ root: appendCanvasNodeToContainer(root, node, targetId),
+ content: {
+ ...buildCanvasContentFromSlide(slide),
+ ...(options.contentPatch || {}),
+ },
+ blocks: [],
+ editableFields: editableFields.length > 0
+ ? [...slide.editableFields, ...editableFields]
+ : slide.editableFields,
+ visualAssets: options.visualAssets || slide.visualAssets,
+ layoutIr: undefined,
+ generationNote: '当前页 Canvas 内容已手动编辑。',
+ review: buildIdleFrontendReview(),
+ };
+ };
+
+ const getDefaultInsertionBlockId = (slide: FrontendSlide) =>
+ slide.blocks.find((block) => ['main', 'aside', 'left', 'right', 'full'].includes(block.layout.zone))?.id
+ || slide.blocks[0]?.id
+ || '';
+
+ const blockToChild = (block: FrontendSlideBlock): FrontendBlockChild | null => {
+ if (block.type === 'image') {
+ return {
+ id: `${block.id}_content`,
+ type: 'image',
+ role: block.role,
+ content: '',
+ items: [],
+ assetKey: block.assetKey || block.id,
+ };
+ }
+ if (block.type === 'list') {
+ if (block.items.length === 0) {
+ return null;
+ }
+ return {
+ id: `${block.id}_content`,
+ type: 'list',
+ role: block.role,
+ content: '',
+ items: [...block.items],
+ };
+ }
+ if (block.type === 'table') {
+ if (!block.tableData) {
+ return null;
+ }
+ return {
+ id: `${block.id}_content`,
+ type: 'table',
+ role: block.role,
+ content: '',
+ items: [],
+ tableData: {
+ headers: [...block.tableData.headers],
+ rows: block.tableData.rows.map((row) => [...row]),
+ },
+ };
+ }
+ if (!block.content) {
+ return null;
+ }
+ return {
+ id: `${block.id}_content`,
+ type: block.type,
+ role: block.role,
+ content: block.content,
+ items: [],
+ };
+ };
+
+ const ensureBlockChildren = (block: FrontendSlideBlock): FrontendSlideBlock => {
+ if (block.children && block.children.length > 0) {
+ return block;
+ }
+ const legacyChild = blockToChild(block);
+ return {
+ ...block,
+ children: legacyChild ? [legacyChild] : [],
+ };
+ };
+
+ const insertChildIntoBlock = (
+ slide: FrontendSlide,
+ targetBlockId: string | undefined,
+ child: FrontendBlockChild,
+ editableField?: FrontendEditableField | FrontendEditableField[],
+ ): FrontendSlide => {
+ const fallbackBlockId = getDefaultInsertionBlockId(slide);
+ const resolvedBlockId = targetBlockId && slide.blocks.some((block) => block.id === targetBlockId)
+ ? targetBlockId
+ : fallbackBlockId;
+ if (!resolvedBlockId) {
+ return slide;
+ }
+
+ const editableFields = Array.isArray(editableField)
+ ? editableField
+ : editableField
+ ? [editableField]
+ : [];
+
+ return {
+ ...slide,
+ schemaVersion: slide.schemaVersion || 'frontend_slide_schema_v2',
+ layoutMode: slide.layoutMode || 'fluid',
+ blocks: slide.blocks.map((block) => {
+ if (block.id !== resolvedBlockId) {
+ return block;
+ }
+ const blockWithChildren = ensureBlockChildren(block);
+ return {
+ ...blockWithChildren,
+ children: [...(blockWithChildren.children || []), child],
+ };
+ }),
+ editableFields: editableFields.length > 0
+ ? [...slide.editableFields, ...editableFields]
+ : slide.editableFields,
+ generationNote: '当前页内容已手动编辑。',
+ review: buildIdleFrontendReview(),
+ };
+ };
+
+ const insertTopLevelBlockIntoZone = (
+ slide: FrontendSlide,
+ block: FrontendSlideBlock,
+ editableField?: FrontendEditableField | FrontendEditableField[],
+ visualAssets?: FrontendSlide['visualAssets'],
+ ): FrontendSlide => {
+ const editableFields = Array.isArray(editableField)
+ ? editableField
+ : editableField
+ ? [editableField]
+ : [];
+ return {
+ ...slide,
+ schemaVersion: slide.schemaVersion || 'frontend_slide_schema_v2',
+ layoutMode: slide.layoutMode || 'fluid',
+ blocks: [...slide.blocks, block],
+ editableFields: editableFields.length > 0
+ ? [...slide.editableFields, ...editableFields]
+ : slide.editableFields,
+ visualAssets: visualAssets || slide.visualAssets,
+ generationNote: '当前页内容已手动编辑。',
+ review: buildIdleFrontendReview(),
+ };
+ };
+
+ const buildInsertedBlockLayout = (
+ slide: FrontendSlide,
+ overrides: Partial = {},
+ ): FrontendSlideBlock['layout'] => ({
+ zone: 'main',
+ span: 6,
+ order: slide.blocks.length + 1,
+ preferredWidth: 'half',
+ preferredSide: 'auto',
+ emphasis: 'medium',
+ ...overrides,
+ });
+
+ const resolveTemplateAfterManualInsert = (
+ slide: FrontendSlide,
+ blocks: FrontendSlideBlock[],
+ visualAssetCount: number,
+ insertedType: FrontendSlideBlock['type'],
+ ) => {
+ const imageCount = blocks.filter((block) => block.type === 'image').length;
+ const nonImageCount = blocks.filter((block) => block.type !== 'image').length;
+ if (insertedType === 'image') {
+ const listCount = blocks.filter((block) => block.type === 'list').length;
+ if (imageCount >= 2) return 'visual_compare';
+ return listCount > 0 ? 'split_media' : 'hero_visual';
+ }
+ if (imageCount > 0) {
+ return imageCount >= 2 ? 'visual_compare' : 'split_media';
+ }
+ if (slide.templateKey === 'title_cover' || slide.templateKey === 'section_divider') {
+ return 'stacked_cards';
+ }
+ if (nonImageCount >= 5) {
+ return 'insight_grid';
+ }
+ return slide.templateKey || pickSchemaTemplateKeyFromBlocks(blocks, visualAssetCount);
+ };
+
+ const parseTableCellFieldKey = (fieldKey: string) => {
+ const match = /^(.+)_cell_(h|\d+)_(\d+)$/.exec(fieldKey);
+ if (!match) {
+ return null;
+ }
+ return {
+ ownerId: match[1],
+ row: match[2] === 'h' ? 'h' as const : Number.parseInt(match[2], 10),
+ col: Number.parseInt(match[3], 10),
+ };
+ };
+
+ const applyTableCellValue = (
+ item: T,
+ fieldKey: string,
+ value: string,
+ ): T => {
+ const parsed = parseTableCellFieldKey(fieldKey);
+ if (!parsed || parsed.ownerId !== item.id || item.type !== 'table' || !item.tableData) {
+ return item;
+ }
+ const nextTableData: FrontendTableData = {
+ headers: [...item.tableData.headers],
+ rows: item.tableData.rows.map((row) => [...row]),
+ };
+ if (parsed.row === 'h') {
+ if (parsed.col >= 0 && parsed.col < nextTableData.headers.length) {
+ nextTableData.headers[parsed.col] = value;
+ }
+ } else if (
+ parsed.row >= 0
+ && parsed.row < nextTableData.rows.length
+ && parsed.col >= 0
+ && parsed.col < nextTableData.rows[parsed.row].length
+ ) {
+ nextTableData.rows[parsed.row][parsed.col] = value;
+ }
+ return {
+ ...item,
+ tableData: nextTableData,
+ };
+ };
+
+ const applyTableCellValueToCanvasContent = (
+ content: Record,
+ fieldKey: string,
+ value: string,
+ ) => {
+ const parsed = parseTableCellFieldKey(fieldKey);
+ if (!parsed) {
+ return content;
+ }
+ const tableData = normalizeContentTableData(content[parsed.ownerId]) || { headers: [], rows: [] };
+ const nextTableData = cloneTableData(tableData);
+ const nextContent = { ...content };
+ delete nextContent[fieldKey];
+ if (parsed.row === 'h') {
+ while (nextTableData.headers.length <= parsed.col) {
+ nextTableData.headers.push(`列 ${nextTableData.headers.length + 1}`);
+ }
+ nextTableData.headers[parsed.col] = value;
+ } else {
+ while (nextTableData.rows.length <= parsed.row) {
+ nextTableData.rows.push([]);
+ }
+ while (nextTableData.rows[parsed.row].length <= parsed.col) {
+ nextTableData.rows[parsed.row].push('');
+ }
+ nextTableData.rows[parsed.row][parsed.col] = value;
+ while (nextTableData.headers.length <= parsed.col) {
+ nextTableData.headers.push(`列 ${nextTableData.headers.length + 1}`);
+ }
+ }
+ return {
+ ...nextContent,
+ [parsed.ownerId]: {
+ ...(content[parsed.ownerId] && typeof content[parsed.ownerId] === 'object'
+ ? content[parsed.ownerId] as Record
+ : {}),
+ ...nextTableData,
+ },
+ };
+ };
+
+ const syncCanvasContentWithEditableField = (
+ slide: FrontendSlide,
+ field: FrontendEditableField,
+ ) => {
+ const content = { ...(slide.content || {}) };
+ const tableSyncedContent = applyTableCellValueToCanvasContent(content, field.key, field.value);
+ if (tableSyncedContent !== content) {
+ return tableSyncedContent;
+ }
+ return {
+ ...content,
+ [field.key]: field.type === 'list' ? [...field.items] : field.value,
+ };
+ };
+
+ const syncBlockWithEditableField = (
+ block: FrontendSlideBlock,
+ field: FrontendEditableField,
+ ): FrontendSlideBlock => {
+ const tableSyncedBlock = applyTableCellValue(block, field.key, field.value);
+ if (tableSyncedBlock !== block) {
+ return tableSyncedBlock;
+ }
+
+ if (field.type === 'list') {
+ if (block.type === 'list') {
+ return {
+ ...block,
+ content: '',
+ items: [...field.items],
+ };
+ }
+ return {
+ ...block,
+ content: field.items.join(' • '),
+ items: [...field.items],
+ };
+ }
+
+ if (block.type === 'list') {
+ return {
+ ...block,
+ content: field.value,
+ items: field.value ? [field.value] : [],
+ };
+ }
+
+ return {
+ ...block,
+ content: field.value,
+ };
+ };
+
+ const applyFrontendEditableFieldMutation = (
+ slide: FrontendSlide,
+ fieldKey: string,
+ updater: (field: FrontendEditableField) => FrontendEditableField,
+ ): FrontendSlide => {
+ const currentField = slide.editableFields.find((field) => field.key === fieldKey);
+ if (!currentField) {
+ return slide;
+ }
+ const nextMatchedField = updater(currentField);
+ const nextEditableFields = slide.editableFields.map((field) => {
+ if (field.key !== fieldKey) {
+ return field;
+ }
+ return nextMatchedField;
+ });
+ const isCanvasSlide = slide.renderEngine === 'canvas';
+
+ return {
+ ...slide,
+ title: fieldKey === 'title' ? nextMatchedField.value || slide.title : slide.title,
+ content: isCanvasSlide
+ ? syncCanvasContentWithEditableField(slide, nextMatchedField)
+ : slide.content,
+ blocks: slide.blocks.map((block) =>
+ getEditableFieldKeyForBlock(slide, block) === fieldKey
+ ? syncBlockWithEditableField(block, nextMatchedField)
+ : {
+ ...block,
+ children: (block.children || []).map((child) => {
+ const tableSyncedChild = applyTableCellValue(child, fieldKey, nextMatchedField.value);
+ if (tableSyncedChild !== child) {
+ return tableSyncedChild;
+ }
+ if (child.id !== fieldKey && child.role !== fieldKey) {
+ return child;
+ }
+ if (nextMatchedField.type === 'list') {
+ return {
+ ...child,
+ content: '',
+ items: [...nextMatchedField.items],
+ };
+ }
+ return {
+ ...child,
+ content: nextMatchedField.value,
+ };
+ }),
+ },
+ ),
+ editableFields: nextEditableFields,
+ layoutIr: isCanvasSlide ? undefined : slide.layoutIr,
+ generationNote: '当前页内容已手动编辑。',
+ review: buildIdleFrontendReview(),
+ };
+ };
+
const normalizeFrontendSlides = (slides: any[]): FrontendSlide[] =>
- slides.map((slide: any, index: number) => ({
- slideId: String(slide.slide_id || slide.slideId || index + 1),
- pageNum: Number(slide.page_num || slide.pageNum || index + 1),
- title: slide.title || `第 ${index + 1} 页`,
- htmlTemplate: slide.html_template || slide.htmlTemplate || '',
- cssCode: slide.css_code || slide.cssCode || '',
- editableFields: Array.isArray(slide.editable_fields || slide.editableFields)
+ slides.map((slide: any, index: number) => {
+ const editableFields = Array.isArray(slide.editable_fields || slide.editableFields)
? (slide.editable_fields || slide.editableFields).map((field: any) => ({
key: String(field.key || ''),
label: String(field.label || field.key || ''),
type: field.type === 'list' || field.type === 'textarea' ? field.type : 'text',
value: String(field.value || ''),
- items: Array.isArray(field.items) ? field.items.map((item: any) => String(item || '')) : [],
+ items: normalizeStringList(field.items),
}))
- : [],
- visualAssets: Array.isArray(slide.visual_assets || slide.visualAssets)
+ : [];
+ const visualAssets = Array.isArray(slide.visual_assets || slide.visualAssets)
? (slide.visual_assets || slide.visualAssets).map((asset: any, assetIndex: number) => ({
key: String(asset.key || `main_visual_${assetIndex + 1}`),
label: String(asset.label || asset.key || `Image ${assetIndex + 1}`),
@@ -529,45 +1742,379 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
prompt: asset.prompt || undefined,
style: asset.style || undefined,
}))
- : [],
- generationNote: slide.generation_note || slide.generationNote || '',
- status: slide.status === 'processing' || slide.status === 'pending' ? slide.status : 'done',
- review: {
- status: 'idle',
- summary: '',
- issues: [],
- },
- }));
+ : [];
+ const blocks = normalizeSchemaBlocks(
+ slide.blocks
+ || slide.elements
+ || slide.content_blocks
+ || slide.contentBlocks,
+ visualAssets,
+ );
+ if (!blocks.some((block) => block.type === 'list')) {
+ const keyPointField = editableFields.find((field: FrontendEditableField) => field.key === 'key_points' && field.items.length > 0);
+ const contentKeyPoints = Array.isArray(slide.content?.key_points)
+ ? slide.content.key_points.map((item: unknown) => String(item || '').trim()).filter(Boolean)
+ : [];
+ const keyPointItems = keyPointField?.items?.length ? keyPointField.items : contentKeyPoints;
+ if (keyPointItems.length > 0) {
+ blocks.push({
+ id: 'key_points',
+ type: 'list',
+ role: 'key_points',
+ content: '',
+ items: keyPointItems,
+ layout: {
+ zone: visualAssets.length > 0 ? 'main' : 'full',
+ span: visualAssets.length > 0 ? 6 : 12,
+ order: blocks.length + 1,
+ preferredWidth: visualAssets.length > 0 ? 'wide' : 'full',
+ preferredSide: 'auto',
+ emphasis: 'medium',
+ },
+ });
+ }
+ }
+ const templateKey = normalizeSchemaTemplateKey(
+ slide.template_key
+ || slide.templateKey
+ || slide.layout_template
+ || slide.layoutTemplate,
+ ) || (blocks.length > 0 ? pickSchemaTemplateKeyFromBlocks(blocks, visualAssets.length) : '');
+ const rawRenderEngine = String(slide.render_engine || slide.renderEngine || '').trim().toLowerCase();
+ const renderEngine = rawRenderEngine === 'blocks' ? 'blocks' : 'canvas';
+ const visualSpec = normalizeCanvasVisualSpec(slide.visual_spec || slide.visualSpec);
+
+ return {
+ slideId: String(slide.slide_id || slide.slideId || index + 1),
+ pageNum: Number(slide.page_num || slide.pageNum || index + 1),
+ title: String(slide.title || `第 ${index + 1} 页`),
+ schemaVersion: String(slide.schema_version || slide.schemaVersion || '').trim() || undefined,
+ renderEngine,
+ templateKey: templateKey || undefined,
+ layoutMode: blocks.length > 0
+ ? normalizeSchemaLayoutMode(slide.layout_mode || slide.layoutMode)
+ : undefined,
+ blocks: renderEngine === 'canvas' ? [] : blocks,
+ layoutFamily: String(slide.layout_family || slide.layoutFamily || '').trim() || undefined,
+ root: (slide.root && typeof slide.root === 'object') ? slide.root : undefined,
+ content: (slide.content && typeof slide.content === 'object') ? slide.content : undefined,
+ visualSpec,
+ constraints: (slide.constraints && typeof slide.constraints === 'object') ? slide.constraints : undefined,
+ editableMap: (slide.editable_map || slide.editableMap) && typeof (slide.editable_map || slide.editableMap) === 'object'
+ ? (slide.editable_map || slide.editableMap)
+ : undefined,
+ canvasValidation: normalizeCanvasValidation(slide.canvas_validation || slide.canvasValidation),
+ layoutIr: (slide.layout_ir || slide.layoutIr) && typeof (slide.layout_ir || slide.layoutIr) === 'object'
+ ? (slide.layout_ir || slide.layoutIr)
+ : undefined,
+ htmlTemplate: slide.html_template || slide.htmlTemplate || '',
+ cssCode: slide.css_code || slide.cssCode || '',
+ editableFields,
+ visualAssets,
+ generationNote: slide.generation_note || slide.generationNote || '',
+ status: slide.status === 'processing' || slide.status === 'pending' ? slide.status : 'done',
+ review: buildIdleFrontendReview(),
+ };
+ });
const normalizeFrontendDeckTheme = (theme: any): FrontendDeckTheme | null => {
if (!theme || typeof theme !== 'object') {
return null;
}
- const themeLock = typeof theme.theme_lock === 'object' && theme.theme_lock ? theme.theme_lock : {};
+ const themeLockSource = theme.theme_lock || theme.themeLock;
+ const themeLock = typeof themeLockSource === 'object' && themeLockSource ? themeLockSource : {};
+ const palette = theme.palette && typeof theme.palette === 'object'
+ ? {
+ bg: String(theme.palette.bg || ''),
+ panel: String(theme.palette.panel || ''),
+ primary: String(theme.palette.primary || ''),
+ secondary: String(theme.palette.secondary || ''),
+ accent: String(theme.palette.accent || ''),
+ text: String(theme.palette.text || ''),
+ muted: String(theme.palette.muted || ''),
+ }
+ : undefined;
+ const typography = theme.typography && typeof theme.typography === 'object'
+ ? {
+ titleFontStack: String(theme.typography.title_font_stack || theme.typography.titleFontStack || ''),
+ bodyFontStack: String(theme.typography.body_font_stack || theme.typography.bodyFontStack || ''),
+ eyebrowSize: toFiniteNumber(theme.typography.eyebrow_size || theme.typography.eyebrowSize, 18),
+ titleSize: toFiniteNumber(theme.typography.title_size || theme.typography.titleSize, 56),
+ summarySize: toFiniteNumber(theme.typography.summary_size || theme.typography.summarySize, 26),
+ bodySize: toFiniteNumber(theme.typography.body_size || theme.typography.bodySize, 24),
+ }
+ : undefined;
return {
themeName: String(theme.theme_name || theme.themeName || 'locked_deck_theme'),
visualMood: String(theme.visual_mood || theme.visualMood || ''),
footerText: String(theme.footer_text || theme.footerText || ''),
sectionLabelTemplate: String(theme.section_label_template || theme.sectionLabelTemplate || ''),
+ palette,
+ typography,
+ layoutRules: normalizeStringList(theme.layout_rules || theme.layoutRules),
+ componentRules: normalizeStringList(theme.component_rules || theme.componentRules),
themeLock: {
- mustKeep: Array.isArray(themeLock.must_keep)
- ? themeLock.must_keep.map((item: unknown) => String(item || '')).filter(Boolean)
- : [],
- preferredLayoutPatterns: Array.isArray(themeLock.preferred_layout_patterns)
- ? themeLock.preferred_layout_patterns.map((item: unknown) => String(item || '')).filter(Boolean)
- : [],
- componentSignature: String(themeLock.component_signature || ''),
- avoid: Array.isArray(themeLock.avoid)
- ? themeLock.avoid.map((item: unknown) => String(item || '')).filter(Boolean)
- : [],
+ mustKeep: normalizeStringList(themeLock.must_keep || themeLock.mustKeep),
+ preferredLayoutPatterns: normalizeStringList(
+ themeLock.preferred_layout_patterns || themeLock.preferredLayoutPatterns,
+ ),
+ componentSignature: String(themeLock.component_signature || themeLock.componentSignature || ''),
+ avoid: normalizeStringList(themeLock.avoid),
},
};
};
+ const normalizeCanvasValidation = (value: any) => {
+ if (!value || typeof value !== 'object') {
+ return undefined;
+ }
+ return {
+ ok: Boolean(value.ok),
+ usedRefs: normalizeStringList(value.used_refs || value.usedRefs),
+ definedContentKeys: normalizeStringList(value.defined_content_keys || value.definedContentKeys),
+ missingRefs: normalizeStringList(value.missing_refs || value.missingRefs),
+ orphanContentKeys: normalizeStringList(value.orphan_content_keys || value.orphanContentKeys),
+ emptyComponents: normalizeStringList(value.empty_components || value.emptyComponents),
+ issues: Array.isArray(value.issues)
+ ? value.issues
+ .filter((issue: any) => issue && typeof issue === 'object')
+ .map((issue: any) => ({
+ severity: issue.severity === 'error' || issue.severity === 'warning' || issue.severity === 'info'
+ ? issue.severity
+ : 'repairable',
+ code: String(issue.code || ''),
+ nodeId: issue.node_id || issue.nodeId ? String(issue.node_id || issue.nodeId) : undefined,
+ ref: issue.ref ? String(issue.ref) : undefined,
+ suggestedRef: issue.suggested_ref || issue.suggestedRef ? String(issue.suggested_ref || issue.suggestedRef) : undefined,
+ message: String(issue.message || issue.code || ''),
+ }))
+ : [],
+ };
+ };
+
+ const normalizeCanvasVisualStyle = (value: any): FrontendCanvasVisualStyle => {
+ if (!value || typeof value !== 'object') {
+ return {};
+ }
+ const source = value as Record;
+ const textAlign = String(source.textAlign || source.text_align || '').trim().toLowerCase();
+ const fontStyle = String(source.fontStyle || source.font_style || '').trim().toLowerCase();
+ const imageFit = String(source.imageFit || source.image_fit || '').trim().toLowerCase();
+ const style: FrontendCanvasVisualStyle = {};
+ const fill = String(source.fill || source.background || source.backgroundColor || '').trim();
+ const color = String(source.color || source.textColor || source.text_color || '').trim();
+ const borderColor = String(source.borderColor || source.border_color || '').trim();
+ const fontFamily = String(source.fontFamily || source.font_family || '').trim();
+ if (fill) style.fill = fill;
+ if (color) style.color = color;
+ if (borderColor) style.borderColor = borderColor;
+ if (source.borderWidth !== undefined || source.border_width !== undefined) {
+ style.borderWidth = toFiniteNumber(source.borderWidth ?? source.border_width, 0);
+ }
+ if (source.radius !== undefined || source.borderRadius !== undefined || source.border_radius !== undefined) {
+ style.radius = toFiniteNumber(source.radius ?? source.borderRadius ?? source.border_radius, 0);
+ }
+ if (source.padding !== undefined) {
+ style.padding = toFiniteNumber(source.padding, 0);
+ }
+ if (fontFamily) style.fontFamily = fontFamily;
+ if (source.fontSize !== undefined || source.font_size !== undefined) {
+ style.fontSize = toFiniteNumber(source.fontSize ?? source.font_size, 0);
+ }
+ if (source.fontWeight !== undefined || source.font_weight !== undefined) {
+ const fontWeight = source.fontWeight ?? source.font_weight;
+ style.fontWeight = typeof fontWeight === 'number' || typeof fontWeight === 'string'
+ ? fontWeight
+ : String(fontWeight);
+ }
+ if (fontStyle === 'italic' || fontStyle === 'normal') {
+ style.fontStyle = fontStyle;
+ }
+ if (source.lineHeight !== undefined || source.line_height !== undefined) {
+ style.lineHeight = toFiniteNumber(source.lineHeight ?? source.line_height, 0);
+ }
+ if (textAlign === 'left' || textAlign === 'center' || textAlign === 'right' || textAlign === 'justify') {
+ style.textAlign = textAlign;
+ }
+ if (source.opacity !== undefined) {
+ style.opacity = toFiniteNumber(source.opacity, 1);
+ }
+ if (imageFit === 'contain' || imageFit === 'cover' || imageFit === 'fill') {
+ style.imageFit = imageFit;
+ }
+ if (source.emphasis === 'high' || source.emphasis === 'medium' || source.emphasis === 'low') {
+ style.emphasis = source.emphasis;
+ }
+ return style;
+ };
+
+ const normalizeCanvasVisualSpec = (value: any): FrontendCanvasVisualSpec | undefined => {
+ if (!value || typeof value !== 'object') {
+ return undefined;
+ }
+ const source = value as Record;
+ const spec: FrontendCanvasVisualSpec = {};
+
+ const paletteSource = source.palette && typeof source.palette === 'object'
+ ? source.palette as Record
+ : undefined;
+ if (paletteSource) {
+ spec.palette = {
+ bg: String(paletteSource.bg || '').trim() || undefined,
+ panel: String(paletteSource.panel || '').trim() || undefined,
+ primary: String(paletteSource.primary || '').trim() || undefined,
+ secondary: String(paletteSource.secondary || '').trim() || undefined,
+ accent: String(paletteSource.accent || '').trim() || undefined,
+ text: String(paletteSource.text || '').trim() || undefined,
+ muted: String(paletteSource.muted || '').trim() || undefined,
+ };
+ }
+
+ const typographySource = source.typography && typeof source.typography === 'object'
+ ? source.typography as Record
+ : undefined;
+ if (typographySource) {
+ spec.typography = {
+ titleFontStack: String(typographySource.titleFontStack || typographySource.title_font_stack || '').trim() || undefined,
+ bodyFontStack: String(typographySource.bodyFontStack || typographySource.body_font_stack || '').trim() || undefined,
+ eyebrowSize: typographySource.eyebrowSize !== undefined || typographySource.eyebrow_size !== undefined
+ ? toFiniteNumber(typographySource.eyebrowSize ?? typographySource.eyebrow_size, 18)
+ : undefined,
+ titleSize: typographySource.titleSize !== undefined || typographySource.title_size !== undefined
+ ? toFiniteNumber(typographySource.titleSize ?? typographySource.title_size, 56)
+ : undefined,
+ summarySize: typographySource.summarySize !== undefined || typographySource.summary_size !== undefined
+ ? toFiniteNumber(typographySource.summarySize ?? typographySource.summary_size, 26)
+ : undefined,
+ bodySize: typographySource.bodySize !== undefined || typographySource.body_size !== undefined
+ ? toFiniteNumber(typographySource.bodySize ?? typographySource.body_size, 24)
+ : undefined,
+ };
+ }
+
+ const surfaceSource = source.surface && typeof source.surface === 'object'
+ ? source.surface as Record
+ : undefined;
+ if (surfaceSource) {
+ spec.surface = {
+ background: String(surfaceSource.background || '').trim() || undefined,
+ panel: String(surfaceSource.panel || '').trim() || undefined,
+ primary: String(surfaceSource.primary || '').trim() || undefined,
+ secondary: String(surfaceSource.secondary || '').trim() || undefined,
+ accent: String(surfaceSource.accent || '').trim() || undefined,
+ text: String(surfaceSource.text || '').trim() || undefined,
+ muted: String(surfaceSource.muted || '').trim() || undefined,
+ cardRadius: surfaceSource.cardRadius !== undefined || surfaceSource.card_radius !== undefined
+ ? toFiniteNumber(surfaceSource.cardRadius ?? surfaceSource.card_radius, 0)
+ : undefined,
+ cardPadding: surfaceSource.cardPadding !== undefined || surfaceSource.card_padding !== undefined
+ ? toFiniteNumber(surfaceSource.cardPadding ?? surfaceSource.card_padding, 0)
+ : undefined,
+ sectionGap: surfaceSource.sectionGap !== undefined || surfaceSource.section_gap !== undefined
+ ? toFiniteNumber(surfaceSource.sectionGap ?? surfaceSource.section_gap, 0)
+ : undefined,
+ };
+ }
+
+ const layoutSource = source.layout && typeof source.layout === 'object'
+ ? source.layout as Record
+ : undefined;
+ if (layoutSource) {
+ spec.layout = {
+ safeMargin: layoutSource.safeMargin !== undefined || layoutSource.safe_margin !== undefined
+ ? toFiniteNumber(layoutSource.safeMargin ?? layoutSource.safe_margin, 72)
+ : undefined,
+ sectionGap: layoutSource.sectionGap !== undefined || layoutSource.section_gap !== undefined
+ ? toFiniteNumber(layoutSource.sectionGap ?? layoutSource.section_gap, 24)
+ : undefined,
+ contentGap: layoutSource.contentGap !== undefined || layoutSource.content_gap !== undefined
+ ? toFiniteNumber(layoutSource.contentGap ?? layoutSource.content_gap, 18)
+ : undefined,
+ maxColumns: layoutSource.maxColumns !== undefined || layoutSource.max_columns !== undefined
+ ? toFiniteNumber(layoutSource.maxColumns ?? layoutSource.max_columns, 0)
+ : undefined,
+ };
+ }
+
+ const nodeStylesSource = source.node_styles || source.nodeStyles;
+ if (nodeStylesSource && typeof nodeStylesSource === 'object') {
+ const nodeStyles = Object.fromEntries(
+ Object.entries(nodeStylesSource as Record)
+ .map(([key, raw]) => [String(key), normalizeCanvasVisualStyle(raw)])
+ .filter(([, style]) => Object.keys(style as Record).length > 0),
+ );
+ if (Object.keys(nodeStyles).length > 0) {
+ spec.nodeStyles = nodeStyles as FrontendCanvasVisualSpec['nodeStyles'];
+ }
+ }
+
+ const componentStylesSource = source.component_styles || source.componentStyles;
+ if (componentStylesSource && typeof componentStylesSource === 'object') {
+ const componentStyles = Object.fromEntries(
+ Object.entries(componentStylesSource as Record)
+ .map(([key, raw]) => [String(key), normalizeCanvasVisualStyle(raw)])
+ .filter(([, style]) => Object.keys(style as Record).length > 0),
+ );
+ if (Object.keys(componentStyles).length > 0) {
+ spec.componentStyles = componentStyles as FrontendCanvasVisualSpec['componentStyles'];
+ }
+ }
+
+ return Object.keys(spec).length > 0 ? spec : undefined;
+ };
+
const serializeFrontendSlide = (slide: FrontendSlide) => ({
slide_id: slide.slideId,
page_num: slide.pageNum,
title: slide.title,
+ schema_version: slide.schemaVersion || '',
+ render_engine: slide.renderEngine || 'canvas',
+ template_key: slide.templateKey || '',
+ layout_mode: slide.layoutMode || '',
+ layout_family: slide.layoutFamily || '',
+ root: slide.root || undefined,
+ content: slide.content || undefined,
+ visual_spec: slide.visualSpec || undefined,
+ constraints: slide.constraints || undefined,
+ editable_map: slide.editableMap || undefined,
+ canvas_validation: slide.canvasValidation || undefined,
+ layout_ir: slide.layoutIr || undefined,
+ blocks: slide.blocks.map((block) => ({
+ id: block.id,
+ type: block.type,
+ role: block.role,
+ content: block.content,
+ items: block.items,
+ asset_key: block.assetKey || '',
+ table_data: block.tableData
+ ? {
+ headers: block.tableData.headers,
+ rows: block.tableData.rows,
+ }
+ : undefined,
+ children: (block.children || []).map((child) => ({
+ id: child.id,
+ type: child.type,
+ role: child.role,
+ content: child.content,
+ items: child.items,
+ asset_key: child.assetKey || '',
+ table_data: child.tableData
+ ? {
+ headers: child.tableData.headers,
+ rows: child.tableData.rows,
+ }
+ : undefined,
+ })),
+ layout: {
+ zone: block.layout.zone,
+ span: block.layout.span,
+ order: block.layout.order,
+ preferred_width: block.layout.preferredWidth,
+ preferred_side: block.layout.preferredSide,
+ emphasis: block.layout.emphasis,
+ },
+ })),
html_template: slide.htmlTemplate,
css_code: slide.cssCode,
editable_fields: slide.editableFields.map((field) => ({
@@ -1048,7 +2595,12 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
setFrontendSlides([]);
setFrontendDeckTheme(null);
frontendCaptureRefs.current = [];
- setDownloadUrl(null);
+ setDownloadUrl((previousUrl) => {
+ if (previousUrl?.startsWith('blob:')) {
+ URL.revokeObjectURL(previousUrl);
+ }
+ return null;
+ });
setPdfPreviewUrl(null);
setResultPath(null);
setProgress(0);
@@ -1351,19 +2903,7 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
setFrontendSlides((prev) =>
prev.map((slide, idx) =>
idx === slideIndex
- ? {
- ...slide,
- generationNote: '当前页内容已手动编辑。',
- title: fieldKey === 'title' ? value : slide.title,
- review: {
- status: 'idle',
- summary: '',
- issues: [],
- },
- editableFields: slide.editableFields.map((field) =>
- field.key === fieldKey ? { ...field, value } : field,
- ),
- }
+ ? applyFrontendEditableFieldMutation(slide, fieldKey, (field) => ({ ...field, value }))
: slide,
),
);
@@ -1373,21 +2913,11 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
setFrontendSlides((prev) =>
prev.map((slide, idx) => {
if (idx !== slideIndex) return slide;
- return {
- ...slide,
- generationNote: '当前页内容已手动编辑。',
- review: {
- status: 'idle',
- summary: '',
- issues: [],
- },
- editableFields: slide.editableFields.map((field) => {
- if (field.key !== fieldKey) return field;
- const nextItems = [...field.items];
- nextItems[itemIndex] = value;
- return { ...field, items: nextItems };
- }),
- };
+ return applyFrontendEditableFieldMutation(slide, fieldKey, (field) => {
+ const nextItems = [...field.items];
+ nextItems[itemIndex] = value;
+ return { ...field, items: nextItems };
+ });
}),
);
};
@@ -1396,18 +2926,10 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
setFrontendSlides((prev) =>
prev.map((slide, idx) => {
if (idx !== slideIndex) return slide;
- return {
- ...slide,
- generationNote: '当前页内容已手动编辑。',
- review: {
- status: 'idle',
- summary: '',
- issues: [],
- },
- editableFields: slide.editableFields.map((field) =>
- field.key === fieldKey ? { ...field, items: [...field.items, ''] } : field,
- ),
- };
+ return applyFrontendEditableFieldMutation(slide, fieldKey, (field) => ({
+ ...field,
+ items: [...field.items, ''],
+ }));
}),
);
};
@@ -1416,18 +2938,10 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
setFrontendSlides((prev) =>
prev.map((slide, idx) => {
if (idx !== slideIndex) return slide;
- return {
- ...slide,
- generationNote: '当前页内容已手动编辑。',
- review: {
- status: 'idle',
- summary: '',
- issues: [],
- },
- editableFields: slide.editableFields.map((field) =>
- field.key === fieldKey ? { ...field, items } : field,
- ),
- };
+ return applyFrontendEditableFieldMutation(slide, fieldKey, (field) => ({
+ ...field,
+ items,
+ }));
}),
);
};
@@ -1436,24 +2950,400 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
setFrontendSlides((prev) =>
prev.map((slide, idx) => {
if (idx !== slideIndex) return slide;
+ return applyFrontendEditableFieldMutation(slide, fieldKey, (field) => ({
+ ...field,
+ items: field.items.filter((_, idx2) => idx2 !== itemIndex),
+ }));
+ }),
+ );
+ };
+
+ const updateFrontendLayoutIr = (slideIndex: number, layoutIr: FrontendSlide['layoutIr']) => {
+ if (!layoutIr) return;
+ setFrontendSlides((prev) =>
+ prev.map((slide, idx) => {
+ if (idx !== slideIndex) return slide;
+ const previous = slide.layoutIr ? JSON.stringify(slide.layoutIr) : '';
+ const next = JSON.stringify(layoutIr);
+ if (previous === next) return slide;
return {
...slide,
- generationNote: '当前页内容已手动编辑。',
- review: {
- status: 'idle',
- summary: '',
- issues: [],
- },
- editableFields: slide.editableFields.map((field) =>
- field.key === fieldKey
- ? { ...field, items: field.items.filter((_, idx2) => idx2 !== itemIndex) }
- : field,
- ),
+ layoutIr,
+ };
+ }),
+ );
+ };
+
+ const buildDefaultTableData = (): FrontendTableData => ({
+ headers: ['指标', '当前值', '说明'],
+ rows: [
+ ['样本量', 'N/A', '补充说明'],
+ ['效果', 'N/A', '补充说明'],
+ ],
+ });
+
+ const buildTableEditableFields = (tableId: string, tableData: FrontendTableData): FrontendEditableField[] => [
+ ...tableData.headers.map((header, colIndex) => ({
+ key: `${tableId}_cell_h_${colIndex}`,
+ label: `表头 ${colIndex + 1}`,
+ type: 'text' as const,
+ value: header,
+ items: [],
+ })),
+ ...tableData.rows.flatMap((row, rowIndex) =>
+ row.map((cell, colIndex) => ({
+ key: `${tableId}_cell_${rowIndex}_${colIndex}`,
+ label: `表格 R${rowIndex + 1}C${colIndex + 1}`,
+ type: 'text' as const,
+ value: cell,
+ items: [],
+ })),
+ ),
+ ];
+
+ const insertFrontendTableBlock = (slideIndex: number, targetBlockId?: string) => {
+ setFrontendSlides((prev) =>
+ prev.map((slide, idx) => {
+ if (idx !== slideIndex) return slide;
+ const tableData = buildDefaultTableData();
+ if (slide.renderEngine === 'canvas') {
+ const nodeId = buildUniqueCanvasNodeId(slide, 'table');
+ return insertCanvasNode(
+ slide,
+ {
+ type: 'component',
+ id: nodeId,
+ component: 'table',
+ props: { table_ref: nodeId },
+ },
+ {
+ targetId: targetBlockId,
+ contentPatch: {
+ [nodeId]: {
+ headers: tableData.headers,
+ rows: tableData.rows,
+ },
+ },
+ editableFields: buildTableEditableFields(nodeId, tableData),
+ },
+ );
+ }
+ const targetZone = parseFrontendInsertZoneTarget(targetBlockId);
+ if (targetZone) {
+ const blockId = buildUniqueBlockId(slide, 'table_block');
+ const block: FrontendSlideBlock = {
+ id: blockId,
+ type: 'table',
+ role: blockId,
+ content: '',
+ items: [],
+ tableData,
+ layout: buildInsertedBlockLayout(slide, {
+ zone: targetZone,
+ order: slide.blocks.length + 1,
+ preferredWidth: targetZone === 'full' ? 'full' : 'half',
+ preferredSide: targetZone === 'left' || targetZone === 'right' ? targetZone : 'auto',
+ }),
+ };
+ return insertTopLevelBlockIntoZone(
+ slide,
+ block,
+ buildTableEditableFields(blockId, tableData),
+ );
+ }
+ const childId = buildUniqueChildId(slide, 'table_item');
+ const child: FrontendBlockChild = {
+ id: childId,
+ type: 'table',
+ role: childId,
+ content: '',
+ items: [],
+ tableData,
+ };
+ return insertChildIntoBlock(
+ slide,
+ targetBlockId,
+ child,
+ buildTableEditableFields(childId, tableData),
+ );
+ }),
+ );
+ };
+
+ const insertFrontendTextBlock = (slideIndex: number, targetBlockId?: string) => {
+ setFrontendSlides((prev) =>
+ prev.map((slide, idx) => {
+ if (idx !== slideIndex) return slide;
+ if (slide.renderEngine === 'canvas') {
+ const nodeId = buildUniqueCanvasNodeId(slide, 'text');
+ const value = '新的文本块';
+ return insertCanvasNode(
+ slide,
+ {
+ type: 'component',
+ id: nodeId,
+ component: 'text',
+ props: { text_ref: nodeId },
+ },
+ {
+ targetId: targetBlockId,
+ contentPatch: { [nodeId]: value },
+ editableFields: {
+ key: nodeId,
+ label: '文本块',
+ type: 'textarea',
+ value,
+ items: [],
+ },
+ },
+ );
+ }
+ const targetZone = parseFrontendInsertZoneTarget(targetBlockId);
+ if (targetZone) {
+ const blockId = buildUniqueBlockId(slide, 'text_block');
+ const block: FrontendSlideBlock = {
+ id: blockId,
+ type: 'text',
+ role: blockId,
+ content: '新的文本块',
+ items: [],
+ layout: buildInsertedBlockLayout(slide, {
+ zone: targetZone,
+ order: slide.blocks.length + 1,
+ preferredSide: targetZone === 'left' || targetZone === 'right' ? targetZone : 'auto',
+ }),
+ };
+ return insertTopLevelBlockIntoZone(slide, block, {
+ key: blockId,
+ label: '文本块',
+ type: 'textarea',
+ value: block.content,
+ items: [],
+ });
+ }
+ const childId = buildUniqueChildId(slide, 'text_item');
+ const child: FrontendBlockChild = {
+ id: childId,
+ type: 'text',
+ role: childId,
+ content: '新的文本块',
+ items: [],
+ };
+ return insertChildIntoBlock(slide, targetBlockId, child, {
+ key: childId,
+ label: '文本块',
+ type: 'textarea',
+ value: child.content,
+ items: [],
+ });
+ }),
+ );
+ };
+
+ const insertFrontendCalloutBlock = (slideIndex: number, targetBlockId?: string) => {
+ setFrontendSlides((prev) =>
+ prev.map((slide, idx) => {
+ if (idx !== slideIndex) return slide;
+ if (slide.renderEngine === 'canvas') {
+ const nodeId = buildUniqueCanvasNodeId(slide, 'callout');
+ const value = '新的重点内容';
+ return insertCanvasNode(
+ slide,
+ {
+ type: 'component',
+ id: nodeId,
+ component: 'callout',
+ props: { text_ref: nodeId },
+ },
+ {
+ targetId: targetBlockId,
+ contentPatch: { [nodeId]: value },
+ editableFields: {
+ key: nodeId,
+ label: '重点内容',
+ type: 'textarea',
+ value,
+ items: [],
+ },
+ },
+ );
+ }
+ const targetZone = parseFrontendInsertZoneTarget(targetBlockId);
+ if (targetZone) {
+ const blockId = buildUniqueBlockId(slide, 'callout_block');
+ const block: FrontendSlideBlock = {
+ id: blockId,
+ type: 'callout',
+ role: blockId,
+ content: '新的重点内容',
+ items: [],
+ layout: buildInsertedBlockLayout(slide, {
+ zone: targetZone,
+ order: slide.blocks.length + 1,
+ preferredSide: targetZone === 'left' || targetZone === 'right' ? targetZone : 'auto',
+ emphasis: 'high',
+ }),
+ };
+ return insertTopLevelBlockIntoZone(slide, block, {
+ key: blockId,
+ label: '重点内容',
+ type: 'textarea',
+ value: block.content,
+ items: [],
+ });
+ }
+ const childId = buildUniqueChildId(slide, 'callout_item');
+ const child: FrontendBlockChild = {
+ id: childId,
+ type: 'callout',
+ role: childId,
+ content: '新的重点内容',
+ items: [],
};
+ return insertChildIntoBlock(slide, targetBlockId, child, {
+ key: childId,
+ label: '重点内容',
+ type: 'textarea',
+ value: child.content,
+ items: [],
+ });
}),
);
};
+ const insertFrontendImageBlock = async (slideIndex: number, file: File, targetBlockId?: string) => {
+ if (!resultPath) {
+ setError('缺少 result_path,请重新上传文件');
+ return;
+ }
+ if (!file.type.startsWith('image/')) {
+ setError('仅支持上传图片文件');
+ return;
+ }
+
+ const currentSlide = frontendSlides[slideIndex];
+ if (!currentSlide) {
+ setError('当前前端页面不存在');
+ return;
+ }
+
+ const assetKey = currentSlide.renderEngine === 'canvas'
+ ? buildUniqueCanvasNodeId(currentSlide, 'user_image')
+ : buildUniqueBlockId(currentSlide, 'user_image');
+ setError(null);
+
+ try {
+ const formData = new FormData();
+ formData.append('result_path', resultPath);
+ formData.append('asset_key', assetKey);
+ formData.append('file', file);
+
+ const res = await backendFetch('/api/v1/paper2ppt/frontend/upload-asset', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!res.ok) {
+ throw new Error(await extractErrorMessage(res, '图片上传失败'));
+ }
+
+ const data = await res.json();
+ if (!data.success || !data.asset) {
+ throw new Error(data.error || '图片上传失败');
+ }
+
+ setFrontendSlides((prev) =>
+ prev.map((slide, idx) => {
+ if (idx !== slideIndex) return slide;
+ const nextAsset = {
+ key: assetKey,
+ label: String(data.asset.label || data.asset.key || file.name || assetKey),
+ src: String(data.asset.src || ''),
+ previewSrc: String(data.asset.preview_src || data.asset.previewSrc || data.asset.src || ''),
+ originalSrc: String(data.asset.original_src || data.asset.originalSrc || data.asset.storage_path || data.asset.storagePath || data.asset.src || ''),
+ alt: String(data.asset.alt || file.name || assetKey),
+ sourceType: 'upload' as const,
+ storagePath: String(data.asset.storage_path || data.asset.storagePath || ''),
+ previewStoragePath: String(data.asset.preview_storage_path || data.asset.previewStoragePath || ''),
+ prompt: typeof data.asset.prompt === 'string' ? data.asset.prompt : undefined,
+ style: typeof data.asset.style === 'string' ? data.asset.style : undefined,
+ };
+ const child: FrontendBlockChild = {
+ id: assetKey,
+ type: 'image',
+ role: 'supporting_visual',
+ content: '',
+ items: [],
+ assetKey,
+ };
+ const visualAssets = [...slide.visualAssets, nextAsset];
+ if (slide.renderEngine === 'canvas') {
+ return insertCanvasNode(
+ slide,
+ {
+ type: 'component',
+ id: assetKey,
+ component: 'figure',
+ props: {
+ asset_ref: assetKey,
+ asset_key: assetKey,
+ fit: 'contain',
+ },
+ },
+ {
+ targetId: targetBlockId,
+ visualAssets,
+ contentPatch: {
+ assets: {
+ ...(((slide.content?.assets && typeof slide.content.assets === 'object')
+ ? slide.content.assets as Record
+ : {})),
+ [assetKey]: {
+ type: 'image',
+ asset_key: assetKey,
+ src: nextAsset.src,
+ preview_src: nextAsset.previewSrc || nextAsset.src,
+ original_src: nextAsset.originalSrc || nextAsset.storagePath || nextAsset.src,
+ alt: nextAsset.alt,
+ },
+ },
+ },
+ },
+ );
+ }
+ const targetZone = parseFrontendInsertZoneTarget(targetBlockId);
+ if (targetZone) {
+ const block: FrontendSlideBlock = {
+ id: assetKey,
+ type: 'image',
+ role: 'supporting_visual',
+ content: '',
+ items: [],
+ assetKey,
+ layout: buildInsertedBlockLayout(slide, {
+ zone: targetZone,
+ order: slide.blocks.length + 1,
+ preferredSide: targetZone === 'left' || targetZone === 'right' ? targetZone : 'auto',
+ }),
+ };
+ return {
+ ...insertTopLevelBlockIntoZone(slide, block, undefined, visualAssets),
+ generationNote: '当前页已插入图片块。',
+ };
+ }
+ return {
+ ...insertChildIntoBlock(slide, targetBlockId, child),
+ visualAssets,
+ generationNote: '当前页已插入图片块。',
+ };
+ }),
+ );
+ } catch (err) {
+ const message = err instanceof Error ? err.message : '图片上传失败';
+ setError(message);
+ }
+ };
+
const replaceFrontendVisualAsset = async (slideIndex: number, imageKey: string, file: File) => {
if (!resultPath) {
setError('缺少 result_path,请重新上传文件');
@@ -1495,28 +3385,34 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
setFrontendSlides((prev) =>
prev.map((slide, idx) => {
if (idx !== slideIndex) return slide;
+ const normalizedAsset = {
+ key: imageKey,
+ label: String(data.asset.label || data.asset.key || imageKey),
+ src: String(data.asset.src || ''),
+ previewSrc: String(data.asset.preview_src || data.asset.previewSrc || data.asset.src || ''),
+ originalSrc: String(data.asset.original_src || data.asset.originalSrc || data.asset.storage_path || data.asset.storagePath || data.asset.src || ''),
+ alt: String(data.asset.alt || file.name || imageKey),
+ sourceType: 'upload' as const,
+ storagePath: String(data.asset.storage_path || data.asset.storagePath || ''),
+ previewStoragePath: String(data.asset.preview_storage_path || data.asset.previewStoragePath || ''),
+ prompt: typeof data.asset.prompt === 'string' ? data.asset.prompt : undefined,
+ style: typeof data.asset.style === 'string' ? data.asset.style : undefined,
+ };
+ const hasExistingAsset = slide.visualAssets.some((asset) => asset.key === imageKey);
return {
...slide,
generationNote: '当前页图片已替换为用户上传版本。',
- review: {
- status: 'idle',
- summary: '',
- issues: [],
- },
- visualAssets: slide.visualAssets.map((asset) =>
- asset.key === imageKey
- ? {
- ...asset,
- src: String(data.asset.src || asset.src || ''),
- previewSrc: String(data.asset.preview_src || data.asset.previewSrc || data.asset.src || asset.previewSrc || asset.src || ''),
- originalSrc: String(data.asset.original_src || data.asset.originalSrc || data.asset.storage_path || data.asset.storagePath || asset.originalSrc || asset.storagePath || asset.src || ''),
- alt: String(data.asset.alt || file.name || asset.alt || ''),
- sourceType: 'upload',
- storagePath: String(data.asset.storage_path || data.asset.storagePath || asset.storagePath || ''),
- previewStoragePath: String(data.asset.preview_storage_path || data.asset.previewStoragePath || asset.previewStoragePath || ''),
- }
- : asset,
- ),
+ review: buildIdleFrontendReview(),
+ visualAssets: hasExistingAsset
+ ? slide.visualAssets.map((asset) =>
+ asset.key === imageKey
+ ? {
+ ...asset,
+ ...normalizedAsset,
+ }
+ : asset,
+ )
+ : [...slide.visualAssets, normalizedAsset],
};
}),
);
@@ -1599,6 +3495,7 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
slideId: slide.id,
pageNum: index + 1,
title: slide.title,
+ blocks: [],
htmlTemplate: '',
cssCode: '',
editableFields: [],
@@ -2503,74 +4400,32 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
}
setIsGeneratingFinal(true);
- setFinalTaskMessage('正在准备前端页面截图...');
+ setFinalTaskMessage('正在准备可编辑 PPTX...');
setError(null);
try {
- const screenshotFiles: File[] = [];
- for (let index = 0; index < frontendSlides.length; index += 1) {
- const node = frontendCaptureRefs.current[index];
- if (!node) {
- throw new Error(`第 ${index + 1} 页尚未渲染完成,请稍后重试`);
- }
- setFinalTaskMessage(`正在渲染第 ${index + 1}/${frontendSlides.length} 页截图...`);
- await sleep(40);
- const blob = await captureSlideToPngBlob(node, 1600, 900, {
- useOriginalAssets: true,
- });
- if (!blob) {
- throw new Error(`第 ${index + 1} 页截图失败`);
- }
- screenshotFiles.push(
- new File([blob], `page_${String(index).padStart(3, '0')}.png`, {
- type: 'image/png',
- }),
- );
- }
-
- const formData = new FormData();
- formData.append('result_path', resultPath);
- formData.append(
- 'slides',
- JSON.stringify(frontendSlides.map((slide) => serializeFrontendSlide(slide))),
+ setFinalTaskMessage('正在解析 Canvas 布局...');
+ await sleep(180);
+ const slidesForExport = normalizeFrontendSlides(
+ frontendSlides.map((slide) => serializeFrontendSlide(slide)),
);
- screenshotFiles.forEach((file) => {
- formData.append('screenshots', file);
- });
-
- setFinalTaskMessage('正在打包 PPTX / PDF...');
- const res = await backendFetch('/api/v1/paper2ppt/frontend/export', {
- method: 'POST',
- body: formData,
- });
- if (!res.ok) {
- throw new Error(await extractErrorMessage(res, '可编辑版 PPT 导出失败'));
- }
-
- const data = await res.json();
- if (!data.success) {
- throw new Error(data.error || '可编辑版 PPT 导出失败');
- }
-
- if (data.ppt_pptx_path) {
- setDownloadUrl(data.ppt_pptx_path);
- }
- if (data.ppt_pdf_path) {
- setPdfPreviewUrl(data.ppt_pdf_path);
- }
-
- const outputFilePath =
- data.ppt_pptx_path ||
- data.ppt_pdf_path ||
- data.all_output_files?.find((url: string) => url.endsWith('.pptx') || url.endsWith('.pdf'));
- if (!outputFilePath) {
- throw new Error('导出失败:未找到最终文件');
+ if (canExportCanvasSlidesToPptx(slidesForExport)) {
+ setFinalTaskMessage('正在生成可编辑 PPTX...');
+ const pptxBlob = await buildCanvasSlidesPptxBlob(slidesForExport, frontendDeckTheme);
+ const objectUrl = URL.createObjectURL(pptxBlob);
+ setDownloadUrl((previousUrl) => {
+ if (previousUrl?.startsWith('blob:')) {
+ URL.revokeObjectURL(previousUrl);
+ }
+ return objectUrl;
+ });
+ setPdfPreviewUrl(null);
+ await uploadAndSaveFile(pptxBlob, 'paper2ppt_editable.pptx', 'paper2ppt');
+ setFinalTaskMessage('');
+ return;
}
- await uploadGeneratedResultFile(
- outputFilePath,
- outputFilePath.endsWith('.pdf') ? 'paper2ppt_frontend.pdf' : 'paper2ppt_frontend.pptx',
- );
+ throw new Error('当前页面不是完整 Canvas schema,已停止导出,避免生成图片型 PPTX。请重新生成或稍后重试。');
} catch (err) {
const message = err instanceof Error ? err.message : '可编辑版 PPT 导出失败';
setError(message);
@@ -2703,6 +4558,15 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
}
try {
+ if (downloadUrl.startsWith('blob:')) {
+ const a = document.createElement('a');
+ a.href = downloadUrl;
+ a.download = 'paper2ppt_editable.pptx';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ return;
+ }
const res = await fetch(downloadUrl);
if (!res.ok) {
throw new Error('下载失败');
@@ -2730,7 +4594,12 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
setGenerateResults([]);
setFrontendSlides([]);
setFrontendDeckTheme(null);
- setDownloadUrl(null);
+ setDownloadUrl((previousUrl) => {
+ if (previousUrl?.startsWith('blob:')) {
+ URL.revokeObjectURL(previousUrl);
+ }
+ return null;
+ });
setPdfPreviewUrl(null);
setResultPath(null);
setError(null);
@@ -2844,6 +4713,11 @@ const Paper2PptPage: React.FC = ({ initialMode }) => {
addListItem={addFrontendListItem}
removeListItem={removeFrontendListItem}
replaceVisualAsset={replaceFrontendVisualAsset}
+ insertTextBlock={insertFrontendTextBlock}
+ insertCalloutBlock={insertFrontendCalloutBlock}
+ insertTableBlock={insertFrontendTableBlock}
+ insertImageBlock={insertFrontendImageBlock}
+ updateLayoutIr={updateFrontendLayoutIr}
/>
) : (
= ({ initialMode }) => {
pptMode === 'frontend' ? (
= ({ initialMode }) => {
{
frontendCaptureRefs.current[index] = node;
}}
+ onLayoutIrChange={(layoutIr) => updateFrontendLayoutIr(index, layoutIr)}
/>
))}