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 的角色是在模板框架内做内容决策,而不是凌驾于模板之上。

关键架构决策

  1. Schema-driven:模板是数据(JSON),不是代码。新模板不需要写代码
  2. 分层设计:Theme -> Layout -> Zone 三层分离,复用度高
  3. 条件布局:让内容决定布局,而非强制套用
  4. 响应式优先:一次设计,多尺寸输出

常见陷阱

  • findTemplateById() 缺失:前端只发 { id } 而后端忘记解析完整模板
  • undefined spread:覆盖式合并时 undefined 会吞掉默认值
  • 字体缺失:服务器上没装模板指定的字体,PDF 导出时文字消失
  • 图片分辨率不匹配:1080p 模板生成了 512px 的图片

Maurice | maurice_wen@proton.me