PPT 模板系统架构
原创
灵阙教研团队
B 基础 提升 |
约 11 分钟阅读
更新于 2026-02-28 AI 导读
PPT 模板系统架构 模板 Schema 设计、变量替换、条件布局、响应式缩放与批量生成的工程实践 一、为什么需要模板系统 直接用 LLM 生成完整的 PPT 样式是不可靠的——字体大小会漂移、颜色会随机、间距会不一致。模板系统的价值在于将"可变内容"和"固定设计"分离: 模板:定义视觉规则(配色、字体、布局、间距) 内容:填充数据(标题、正文、图片、图表) 引擎:将内容注入模板,输出最终幻灯片...
PPT 模板系统架构
模板 Schema 设计、变量替换、条件布局、响应式缩放与批量生成的工程实践
一、为什么需要模板系统
直接用 LLM 生成完整的 PPT 样式是不可靠的——字体大小会漂移、颜色会随机、间距会不一致。模板系统的价值在于将"可变内容"和"固定设计"分离:
- 模板:定义视觉规则(配色、字体、布局、间距)
- 内容:填充数据(标题、正文、图片、图表)
- 引擎:将内容注入模板,输出最终幻灯片
这种分离使得设计师可以专注设计、LLM 专注内容生成、工程师专注渲染引擎,互不干扰。
模板系统架构全景
┌─────────────────────────────────────────────────────────┐
│ Template System │
├──────────────┬──────────────┬──────────────────────────┤
│ Template │ Content │ Engine │
│ Registry │ Provider │ │
│ │ │ ┌─────────────────────┐ │
│ [schema] │ [LLM] │ │ Variable Resolver │ │
│ [layouts] │ [API] │ │ Layout Selector │ │
│ [styles] │ [Static] │ │ Conditional Logic │ │
│ [variants] │ │ │ Responsive Scaler │ │
│ │ │ │ Format Exporter │ │
│ │ │ └─────────────────────┘ │
└──────────────┴──────────────┴──────────────────────────┘
二、模板 Schema 设计
2.1 三层模板结构
// template-schema.ts
/**
* Level 1: Theme (global visual identity)
* Level 2: Layout (per-slide structure)
* Level 3: Element (individual components)
*/
// Level 1: Theme
interface Theme {
id: string;
name: string;
version: string;
author: string;
// Global design tokens
colors: ThemeColors;
typography: ThemeTypography;
spacing: ThemeSpacing;
effects: ThemeEffects;
// Available layouts
layouts: LayoutDefinition[];
// Style prompt for AI image generation
stylePrompt: string;
keywords: string[];
}
interface ThemeColors {
primary: string;
secondary: string;
accent: string;
background: string;
surface: string; // Card/box backgrounds
textPrimary: string;
textSecondary: string;
border: string;
success: string;
warning: string;
error: string;
// Extended palette for charts
chartPalette: string[];
}
interface ThemeTypography {
fontFamilyHeading: string;
fontFamilyBody: string;
fontFamilyMono: string;
// Type scale (in px)
sizeH1: number; // 56-72
sizeH2: number; // 36-48
sizeH3: number; // 24-32
sizeBody: number; // 18-24
sizeCaption: number; // 14-16
lineHeightHeading: number; // 1.1-1.3
lineHeightBody: number; // 1.4-1.6
weightHeading: number; // 600-800
weightBody: number; // 400
}
interface ThemeSpacing {
unit: number; // Base unit (default: 8)
pageMargin: number; // Page edge margin (in units)
elementGap: number; // Gap between elements (in units)
sectionGap: number; // Gap between sections (in units)
}
interface ThemeEffects {
borderRadius: number;
shadowLevel: 'none' | 'subtle' | 'medium' | 'strong';
backgroundPattern?: 'none' | 'dots' | 'grid' | 'gradient';
}
// Level 2: Layout
interface LayoutDefinition {
id: string;
type: string; // 'title', 'content', 'two-column', etc.
description: string;
applicability: LayoutApplicability;
zones: ZoneDefinition[];
background?: BackgroundConfig;
}
interface LayoutApplicability {
slideTypes: string[]; // Which slide types can use this layout
minBullets?: number; // Minimum bullets for this layout
maxBullets?: number; // Maximum bullets
requiresImage?: boolean;
requiresChart?: boolean;
}
// Level 3: Element zones
interface ZoneDefinition {
id: string;
role: 'title' | 'subtitle' | 'body' | 'image' | 'chart'
| 'icon' | 'decoration' | 'page-number';
// Position (normalized 0-1, relative to safe area)
bounds: {
x: number;
y: number;
width: number;
height: number;
};
// Conditional visibility
condition?: string; // e.g., "hasImage", "bulletCount > 3"
// Element-specific config
config?: Record<string, unknown>;
// Override theme styles
styleOverrides?: Partial<{
fontSize: number;
fontWeight: number;
color: string;
textAlign: 'left' | 'center' | 'right';
backgroundColor: string;
borderRadius: number;
padding: number;
}>;
}
2.2 模板注册表
// template-registry.ts
interface TemplateRegistry {
templates: TemplateEntry[];
defaultTemplateId: string;
version: string;
}
interface TemplateEntry {
id: string;
name: string;
category: string;
tags: string[];
thumbnail: string; // URL to preview image
path: string; // Path to full template definition
isDefault?: boolean;
}
class TemplateStore {
private registry: Map<string, Theme> = new Map();
async load(registryPath: string): Promise<void> {
const data = JSON.parse(await readFile(registryPath, 'utf-8'));
for (const entry of data.templates) {
const theme = JSON.parse(await readFile(entry.path, 'utf-8'));
this.registry.set(entry.id, theme);
}
}
get(id: string): Theme {
const theme = this.registry.get(id);
if (!theme) {
throw new Error(`Template "${id}" not found in registry`);
}
return theme;
}
findByCategory(category: string): Theme[] {
return Array.from(this.registry.values())
.filter(t => t.id.includes(category));
}
list(): TemplateEntry[] {
return Array.from(this.registry.entries()).map(([id, theme]) => ({
id,
name: theme.name,
category: 'general',
tags: theme.keywords,
thumbnail: '',
path: '',
}));
}
}
三、变量替换系统
3.1 模板变量语法
// variable-resolver.ts
/**
* Template variables use double-brace syntax: {{ variableName }}
* Supports:
* - Simple: {{ title }}
* - Nested: {{ section.heading }}
* - Filtered: {{ title | truncate:50 }}
* - Default: {{ subtitle | default:"No subtitle" }}
* - Loop: {{# bullets }}{{ . }}{{/ bullets }}
*/
type FilterFn = (value: string, ...args: string[]) => string;
class VariableResolver {
private filters: Map<string, FilterFn> = new Map();
constructor() {
// Built-in filters
this.filters.set('truncate', (val, maxLen) =>
val.length > Number(maxLen)
? val.slice(0, Number(maxLen)) + '...'
: val
);
this.filters.set('upper', (val) => val.toUpperCase());
this.filters.set('lower', (val) => val.toLowerCase());
this.filters.set('default', (val, fallback) => val || fallback);
this.filters.set('lineCount', (val) =>
String(val.split('\n').length)
);
}
resolve(template: string, data: Record<string, unknown>): string {
// Handle loops: {{# array }}...{{ . }}...{{/ array }}
let result = this.resolveLoops(template, data);
// Handle conditionals: {{? condition }}...{{/ condition }}
result = this.resolveConditionals(result, data);
// Handle simple variables: {{ var | filter }}
result = result.replace(
/\{\{\s*([^}]+)\s*\}\}/g,
(match, expr) => {
const parts = expr.split('|').map((s: string) => s.trim());
const path = parts[0];
let value = this.getNestedValue(data, path);
if (value === undefined || value === null) {
// Check for default filter
const defaultFilter = parts.find(
(p: string) => p.startsWith('default:')
);
if (defaultFilter) {
return defaultFilter.split(':').slice(1).join(':').trim().replace(/^"|"$/g, '');
}
return '';
}
// Apply filters
let stringValue = String(value);
for (let i = 1; i < parts.length; i++) {
const [filterName, ...args] = parts[i].split(':');
const filter = this.filters.get(filterName.trim());
if (filter) {
stringValue = filter(stringValue, ...args);
}
}
return stringValue;
}
);
return result;
}
private getNestedValue(
obj: Record<string, unknown>,
path: string,
): unknown {
return path.split('.').reduce(
(current: any, key) => current?.[key],
obj
);
}
private resolveLoops(
template: string,
data: Record<string, unknown>,
): string {
const loopRegex = /\{\{#\s*(\w+)\s*\}\}([\s\S]*?)\{\{\/\s*\1\s*\}\}/g;
return template.replace(loopRegex, (_, key, body) => {
const arr = this.getNestedValue(data, key);
if (!Array.isArray(arr)) return '';
return arr
.map((item, index) =>
body
.replace(/\{\{\s*\.\s*\}\}/g, String(item))
.replace(/\{\{\s*@index\s*\}\}/g, String(index))
)
.join('');
});
}
private resolveConditionals(
template: string,
data: Record<string, unknown>,
): string {
const condRegex = /\{\{\?\s*(\w+)\s*\}\}([\s\S]*?)\{\{\/\s*\1\s*\}\}/g;
return template.replace(condRegex, (_, key, body) => {
const value = this.getNestedValue(data, key);
return value ? body : '';
});
}
}
四、条件布局
4.1 基于内容的布局条件
// conditional-layout.ts
interface LayoutCondition {
field: string;
operator: '==' | '!=' | '>' | '<' | '>=' | '<=' | 'exists' | 'empty';
value?: unknown;
}
interface ConditionalLayout {
conditions: LayoutCondition[];
logic: 'and' | 'or';
layout: LayoutDefinition;
}
class ConditionalLayoutEngine {
evaluate(
conditions: LayoutCondition[],
logic: 'and' | 'or',
content: SlideContent,
): boolean {
const results = conditions.map(c => this.evaluateOne(c, content));
return logic === 'and'
? results.every(Boolean)
: results.some(Boolean);
}
private evaluateOne(
condition: LayoutCondition,
content: SlideContent,
): boolean {
const actual = this.extractField(condition.field, content);
switch (condition.operator) {
case '==': return actual === condition.value;
case '!=': return actual !== condition.value;
case '>': return Number(actual) > Number(condition.value);
case '<': return Number(actual) < Number(condition.value);
case '>=': return Number(actual) >= Number(condition.value);
case '<=': return Number(actual) <= Number(condition.value);
case 'exists': return actual !== undefined && actual !== null;
case 'empty':
return actual === undefined || actual === null
|| (Array.isArray(actual) && actual.length === 0)
|| actual === '';
default: return false;
}
}
private extractField(
field: string,
content: SlideContent,
): unknown {
// Built-in computed fields
switch (field) {
case 'bulletCount':
return content.bullets?.length ?? 0;
case 'hasImage':
return !!content.image || !!content.imageHint;
case 'hasChart':
return !!content.chartData;
case 'textLength':
return (content.bullets ?? []).reduce((s, b) => s + b.length, 0);
case 'slideType':
return content.slideType;
default:
return (content as any)[field];
}
}
selectLayout(
conditionalLayouts: ConditionalLayout[],
content: SlideContent,
fallback: LayoutDefinition,
): LayoutDefinition {
for (const cl of conditionalLayouts) {
if (this.evaluate(cl.conditions, cl.logic, content)) {
return cl.layout;
}
}
return fallback;
}
}
4.2 条件布局示例
{
"conditionalLayouts": [
{
"conditions": [
{ "field": "hasChart", "operator": "==", "value": true },
{ "field": "bulletCount", "operator": "<=", "value": 3 }
],
"logic": "and",
"layout": { "id": "chart-with-notes", "type": "data" }
},
{
"conditions": [
{ "field": "bulletCount", "operator": ">", "value": 6 }
],
"logic": "and",
"layout": { "id": "two-column-dense", "type": "two-column" }
},
{
"conditions": [
{ "field": "hasImage", "operator": "==", "value": true },
{ "field": "textLength", "operator": "<", "value": 100 }
],
"logic": "and",
"layout": { "id": "full-bleed-image", "type": "full-image" }
}
]
}
五、响应式缩放
5.1 多尺寸适配
同一套模板需要支持不同的输出尺寸:
| 场景 | 尺寸 | 宽高比 |
|---|---|---|
| 标准 PPT | 1920 x 1080 | 16:9 |
| 4K PPT | 3840 x 2160 | 16:9 |
| 竖屏海报 | 1080 x 1920 | 9:16 |
| 正方形 | 1080 x 1080 | 1:1 |
| A4 打印 | 2480 x 3508 | ~7:10 |
// responsive-scaler.ts
interface OutputSpec {
width: number;
height: number;
dpi: number;
}
class ResponsiveScaler {
private baseWidth: number = 1920;
private baseHeight: number = 1080;
scale(
theme: Theme,
layout: LayoutDefinition,
target: OutputSpec,
): LayoutDefinition {
const scaleX = target.width / this.baseWidth;
const scaleY = target.height / this.baseHeight;
const scaleFactor = Math.min(scaleX, scaleY);
// Scale all zones
const scaledZones = layout.zones.map(zone => ({
...zone,
bounds: this.scaleBounds(zone.bounds, scaleX, scaleY),
styleOverrides: zone.styleOverrides ? {
...zone.styleOverrides,
fontSize: zone.styleOverrides.fontSize
? Math.round(zone.styleOverrides.fontSize * scaleFactor)
: undefined,
padding: zone.styleOverrides.padding
? Math.round(zone.styleOverrides.padding * scaleFactor)
: undefined,
borderRadius: zone.styleOverrides.borderRadius
? Math.round(zone.styleOverrides.borderRadius * scaleFactor)
: undefined,
} : undefined,
}));
return {
...layout,
zones: scaledZones,
padding: {
top: Math.round(layout.padding.top * scaleY),
right: Math.round(layout.padding.right * scaleX),
bottom: Math.round(layout.padding.bottom * scaleY),
left: Math.round(layout.padding.left * scaleX),
},
};
}
scaleTypography(
typography: ThemeTypography,
scaleFactor: number,
): ThemeTypography {
return {
...typography,
sizeH1: Math.round(typography.sizeH1 * scaleFactor),
sizeH2: Math.round(typography.sizeH2 * scaleFactor),
sizeH3: Math.round(typography.sizeH3 * scaleFactor),
sizeBody: Math.round(typography.sizeBody * scaleFactor),
sizeCaption: Math.round(typography.sizeCaption * scaleFactor),
};
}
private scaleBounds(
bounds: ZoneDefinition['bounds'],
scaleX: number,
scaleY: number,
): ZoneDefinition['bounds'] {
// Bounds are normalized (0-1), so they scale automatically
// Only need adjustment for aspect ratio changes
return bounds;
}
}
六、批量生成
6.1 数据驱动的批量 PPT
// batch-generator.ts
interface BatchJob {
templateId: string;
data: Record<string, unknown>;
outputPath: string;
format: 'pptx' | 'pdf' | 'png';
}
class BatchGenerator {
private templateStore: TemplateStore;
private variableResolver: VariableResolver;
private maxConcurrency: number;
constructor(templateStore: TemplateStore, maxConcurrency: number = 4) {
this.templateStore = templateStore;
this.variableResolver = new VariableResolver();
this.maxConcurrency = maxConcurrency;
}
async generateBatch(
jobs: BatchJob[],
onProgress?: (completed: number, total: number) => void,
): Promise<BatchResult[]> {
const results: BatchResult[] = [];
let completed = 0;
// Process in chunks
for (let i = 0; i < jobs.length; i += this.maxConcurrency) {
const chunk = jobs.slice(i, i + this.maxConcurrency);
const chunkResults = await Promise.allSettled(
chunk.map(job => this.generateSingle(job))
);
for (const result of chunkResults) {
completed++;
if (result.status === 'fulfilled') {
results.push(result.value);
} else {
results.push({
status: 'failed',
error: result.reason.message,
} as BatchResult);
}
onProgress?.(completed, jobs.length);
}
}
return results;
}
private async generateSingle(job: BatchJob): Promise<BatchResult> {
const template = this.templateStore.get(job.templateId);
// ... template rendering logic
return { status: 'completed', outputPath: job.outputPath };
}
}
6.2 批量场景举例
| 场景 | 数据源 | 模板 | 输出量 |
|---|---|---|---|
| 月度报告 | 数据库指标 | report-monthly | 12/年 |
| 产品介绍 | 产品目录 CSV | product-showcase | 100+ |
| 活动邀请 | 嘉宾名单 | event-invite | 50-200 |
| 教学课件 | 知识库 Markdown | lecture-standard | 按章节 |
| 数据分析 | API 查询结果 | data-dashboard | 按需 |
七、模板开发工作流
设计师
|
v
[Figma 设计模板] -> 导出 Design Tokens (JSON)
|
v
[编写 Theme JSON] -> colors + typography + spacing + effects
|
v
[定义 Layouts] -> zones + conditions + applicability
|
v
[注册到 Registry] -> template-registry.json
|
v
[预览验证] -> 用测试数据生成样本 PPT
|
v
[发布] -> 可供 API/用户选择
模板质量检查清单
Template Quality Checklist:
- [ ] 所有必需 zones 有 fallback 值
- [ ] 颜色对比度符合 WCAG AA (4.5:1)
- [ ] 标题、正文字号至少有 2 级差距
- [ ] 布局在 16:9 和 4:3 下都可用
- [ ] 中英文混排测试通过
- [ ] 空数据不会导致空白页
- [ ] stylePrompt 生成的图片与模板视觉一致
- [ ] 10 页以上 PPT 的视觉节奏不单调
八、经验总结
模板 vs 自由生成
模板系统的哲学是"约束即自由"——通过限制视觉选择空间,反而保证了输出的专业度。LLM 的角色是在模板框架内做内容决策,而不是凌驾于模板之上。
关键架构决策
- Schema-driven:模板是数据(JSON),不是代码。新模板不需要写代码
- 分层设计:Theme -> Layout -> Zone 三层分离,复用度高
- 条件布局:让内容决定布局,而非强制套用
- 响应式优先:一次设计,多尺寸输出
常见陷阱
findTemplateById()缺失:前端只发{ id }而后端忘记解析完整模板undefinedspread:覆盖式合并时undefined会吞掉默认值- 字体缺失:服务器上没装模板指定的字体,PDF 导出时文字消失
- 图片分辨率不匹配:1080p 模板生成了 512px 的图片
Maurice | maurice_wen@proton.me