企业级PPT模板引擎设计

插槽系统、样式令牌与布局算法的工程实现


1. 模板引擎的定位

在 AI 演示文稿生成管线中,模板引擎处于编排层与渲染层之间。它的职责是将结构化内容(JSON)映射到具体的视觉布局,同时保证品牌一致性和排版质量。

结构化内容 (JSON)
       |
       v
+------------------+
|    模板引擎       |
|  +-----------+   |
|  | 插槽匹配  |   |  <-- 内容填入对应区域
|  +-----------+   |
|  | 样式解析  |   |  <-- Design Token 转 CSS
|  +-----------+   |
|  | 布局计算  |   |  <-- 约束求解 + 自适应
|  +-----------+   |
+------------------+
       |
       v
   渲染树 (DOM / PPTX Node)

1.1 核心设计目标

目标 衡量标准
品牌一致性 同品牌下所有 PPT 视觉风格统一
内容自适应 文字多少、图片有无不影响排版质量
可扩展性 新增模板不修改引擎代码
高性能 单页布局计算 < 50ms
可调试 每步中间结果可视化

2. 插槽系统(Slot System)

2.1 概念模型

每个模板页面定义了若干"插槽"(Slot),每个插槽声明了它能接受的内容类型和约束条件。内容生成引擎只需要往插槽里"填东西",不需要关心具体位置。

模板页面
+------------------------------------------+
|  [SLOT: title]                           |
|  type: text                              |
|  max_chars: 40                           |
|  style: heading_1                        |
+------------------------------------------+
|                    |                     |
|  [SLOT: visual]    |  [SLOT: bullets]    |
|  type: image|chart |  type: text_list    |
|  aspect: 16:9      |  max_items: 5       |
|  min_width: 400px   |  style: body        |
|                    |                     |
+------------------------------------------+
|  [SLOT: footer]                          |
|  type: text, fixed: true                 |
+------------------------------------------+

2.2 插槽类型定义

interface SlotDefinition {
  id: string;
  type: SlotType;
  required: boolean;
  constraints: SlotConstraints;
  style_token: string;
  fallback?: FallbackStrategy;
}

type SlotType =
  | 'text'           // 纯文本
  | 'text_list'      // 有序/无序列表
  | 'image'          // 图片
  | 'chart'          // 数据图表
  | 'icon_text'      // 图标 + 文字组合
  | 'metric'         // 大数字 + 标签
  | 'code'           // 代码块
  | 'table'          // 表格
  | 'mixed';         // 混合内容

interface SlotConstraints {
  max_chars?: number;
  max_items?: number;
  max_lines?: number;
  aspect_ratio?: string;    // "16:9" | "4:3" | "1:1"
  min_width?: number;
  max_width?: number;
  overflow: 'truncate' | 'shrink_font' | 'scroll' | 'paginate';
}

type FallbackStrategy =
  | { type: 'placeholder'; content: string }
  | { type: 'hide' }
  | { type: 'expand_neighbor'; target: string };

2.3 插槽匹配算法

当 AI 生成的内容需要填入模板时,执行以下匹配:

def match_content_to_slots(
    content: PageContent,
    template: TemplateDefinition
) -> dict[str, ContentBlock]:
    """将内容块匹配到模板插槽"""
    assignments = {}
    unmatched_content = list(content.blocks)
    unmatched_slots = list(template.slots)

    # 第一轮:精确匹配(按 role 对应 slot id)
    for block in unmatched_content[:]:
        for slot in unmatched_slots[:]:
            if block.role == slot.id:
                if slot.accepts(block):
                    assignments[slot.id] = block
                    unmatched_content.remove(block)
                    unmatched_slots.remove(slot)
                    break

    # 第二轮:类型匹配(按 type 兼容性)
    for block in unmatched_content[:]:
        for slot in unmatched_slots[:]:
            if slot.type_compatible(block.type):
                if slot.constraints_satisfied(block):
                    assignments[slot.id] = block
                    unmatched_content.remove(block)
                    unmatched_slots.remove(slot)
                    break

    # 第三轮:处理未匹配的插槽
    for slot in unmatched_slots:
        if slot.required:
            assignments[slot.id] = slot.fallback.generate()
        # 非必需插槽直接隐藏

    return assignments

3. 样式令牌系统(Design Token)

3.1 三层令牌架构

全局令牌 (Global Tokens)
  |  颜色原始值、字号梯度、间距梯度
  v
语义令牌 (Semantic Tokens)
  |  primary-color、heading-font-size、card-padding
  v
组件令牌 (Component Tokens)
  |  slide-title-color、bullet-icon-size、chart-axis-color
  v
最终 CSS 属性值

3.2 令牌定义

{
  "global": {
    "color": {
      "blue-50": "#EFF6FF",
      "blue-500": "#3B82F6",
      "blue-900": "#1E3A8A",
      "gray-50": "#F9FAFB",
      "gray-900": "#111827"
    },
    "font-size": {
      "xs": "12px",
      "sm": "14px",
      "base": "16px",
      "lg": "18px",
      "xl": "20px",
      "2xl": "24px",
      "3xl": "30px",
      "4xl": "36px",
      "5xl": "48px"
    },
    "spacing": {
      "1": "4px",
      "2": "8px",
      "3": "12px",
      "4": "16px",
      "6": "24px",
      "8": "32px",
      "12": "48px",
      "16": "64px"
    }
  },
  "semantic": {
    "color-primary": "{global.color.blue-500}",
    "color-on-primary": "#FFFFFF",
    "color-surface": "{global.color.gray-50}",
    "color-on-surface": "{global.color.gray-900}",
    "font-heading": "{global.font-size.4xl}",
    "font-subheading": "{global.font-size.2xl}",
    "font-body": "{global.font-size.lg}",
    "spacing-page-margin": "{global.spacing.12}",
    "spacing-section": "{global.spacing.8}",
    "spacing-element": "{global.spacing.4}"
  },
  "component": {
    "slide-title-size": "{semantic.font-heading}",
    "slide-title-color": "{semantic.color-on-surface}",
    "slide-title-weight": "700",
    "bullet-marker-color": "{semantic.color-primary}",
    "bullet-text-size": "{semantic.font-body}",
    "card-bg": "#FFFFFF",
    "card-border-radius": "12px",
    "card-shadow": "0 2px 8px rgba(0,0,0,0.08)"
  }
}

3.3 令牌解析引擎

class TokenResolver {
  private tokens: Record<string, any>;
  private cache: Map<string, string> = new Map();

  resolve(path: string): string {
    if (this.cache.has(path)) {
      return this.cache.get(path)!;
    }

    const value = this.getNestedValue(path);
    if (typeof value === 'string' && value.startsWith('{') && value.endsWith('}')) {
      // 引用其他令牌,递归解析
      const refPath = value.slice(1, -1);
      const resolved = this.resolve(refPath);
      this.cache.set(path, resolved);
      return resolved;
    }

    this.cache.set(path, value);
    return value;
  }

  toCSS(): string {
    const vars: string[] = [];
    this.walkTokens(this.tokens, [], (path, value) => {
      const cssVar = `--${path.join('-')}`;
      const resolved = this.resolve(path.join('.'));
      vars.push(`${cssVar}: ${resolved};`);
    });
    return `:root {\n  ${vars.join('\n  ')}\n}`;
  }
}

3.4 品牌覆盖机制

企业品牌配置以 overlay 方式覆盖默认令牌,只需声明差异部分:

{
  "brand": "acme_corp",
  "overrides": {
    "semantic": {
      "color-primary": "#1E40AF",
      "color-on-primary": "#FFFFFF"
    },
    "component": {
      "slide-title-weight": "600",
      "card-border-radius": "8px"
    }
  },
  "additions": {
    "component": {
      "brand-logo-height": "28px",
      "brand-footer-text": "ACME Corp Confidential"
    }
  }
}

4. 布局算法

4.1 布局类型

布局名称 适用场景 插槽结构
cover 封面 title + subtitle + background
title-content 标题 + 正文 title + body
two-cols 双栏 title + left + right
three-cards 三卡片 title + card x 3
image-text 图文混排 title + image + text
full-image 全图 image + overlay_text
data-dashboard 数据面板 title + metric x N + chart
comparison 对比 title + col_a + col_b
timeline 时间线 title + event x N
quote 引用 quote_text + attribution

4.2 自适应布局算法

核心是一个两阶段算法:先分配区域,再调整内容。

阶段一:区域分配

页面可用区域 (960 x 540, 去除页边距后)
       |
       v
布局模板声明区域比例
  例: two-cols = title(100%, 15%) + left(48%, 80%) + right(48%, 80%)
       |
       v
根据内容权重调整比例
  - 左栏有大图 -> 左 60%, 右 40%
  - 两栏文字量相当 -> 左 50%, 右 50%
       |
       v
计算绝对坐标(像素值)

阶段二:内容适配

def adapt_content_to_area(
    content: ContentBlock,
    area: Rectangle,
    constraints: SlotConstraints
) -> AdaptedContent:
    """将内容适配到指定区域"""

    if content.type == 'text':
        # 文字适配
        font_size = constraints.initial_font_size
        rendered = render_text(content.text, font_size, area.width)

        while rendered.height > area.height and font_size > constraints.min_font_size:
            font_size -= 1
            rendered = render_text(content.text, font_size, area.width)

        if rendered.height > area.height:
            # 字号已到最小,截断文字
            content.text = truncate_to_fit(content.text, font_size, area)

        return AdaptedContent(content, font_size, area)

    elif content.type == 'image':
        # 图片适配:保持比例,居中裁剪或留白
        img_ratio = content.width / content.height
        area_ratio = area.width / area.height

        if img_ratio > area_ratio:
            # 图片更宽,按高度适配
            scale = area.height / content.height
        else:
            # 图片更高,按宽度适配
            scale = area.width / content.width

        return AdaptedContent(content, scale, area, 'center')

4.3 约束求解器

当多个插槽存在空间竞争时,使用约束求解:

约束条件:
  C1: title.bottom + 24px <= content.top       (标题与内容间距)
  C2: left.right + 32px <= right.left           (双栏间距)
  C3: content.bottom + 16px <= footer.top       (内容与页脚间距)
  C4: all_elements within safe_area             (安全区域)
  C5: image.width / image.height == original_ratio  (图片比例)

优先级:
  REQUIRED: C4 (安全区域)
  STRONG:   C1, C2, C3 (间距约束)
  MEDIUM:   C5 (比例约束)
  WEAK:     元素居中偏好

使用 Kiwi.js(Cassowary 算法的 JavaScript 实现)求解:

import { Solver, Variable, Constraint, Strength } from 'kiwi.js';

function solveLayout(slots: SlotDefinition[], area: Rectangle): LayoutResult {
  const solver = new Solver();

  for (const slot of slots) {
    const x = new Variable(`${slot.id}_x`);
    const y = new Variable(`${slot.id}_y`);
    const w = new Variable(`${slot.id}_w`);
    const h = new Variable(`${slot.id}_h`);

    // 安全区域约束
    solver.addConstraint(new Constraint(x, Operator.Ge, area.left, Strength.required));
    solver.addConstraint(new Constraint(y, Operator.Ge, area.top, Strength.required));
    solver.addConstraint(
      new Constraint(x.plus(w), Operator.Le, area.right, Strength.required)
    );

    // 间距约束
    // ... 根据布局模板添加
  }

  solver.updateVariables();
  // 读取求解结果
}

5. 模板注册与发现

5.1 模板元数据

{
  "template_id": "two-cols-image-text",
  "version": "1.2.0",
  "name": "双栏图文",
  "description": "左图右文或左文右图的经典版式",
  "category": "content",
  "tags": ["two-column", "image", "text"],
  "slots": [
    { "id": "title", "type": "text", "required": true },
    { "id": "visual", "type": "image", "required": false },
    { "id": "content", "type": "text", "required": true }
  ],
  "variants": [
    { "id": "image-left", "description": "图片在左" },
    { "id": "image-right", "description": "图片在右" }
  ],
  "content_profile": {
    "ideal_word_count": { "min": 50, "max": 150 },
    "supports_chart": false,
    "supports_code": false
  },
  "preview_url": "/templates/two-cols-image-text/preview.png"
}

5.2 模板选择策略

def select_template(
    content: PageContent,
    brand: BrandConfig,
    context: PresentationContext
) -> TemplateDefinition:
    """根据内容特征选择最佳模板"""

    candidates = template_registry.query(
        content_types=content.element_types,
        min_slots=len(content.blocks),
    )

    scored = []
    for tmpl in candidates:
        score = 0.0
        # 内容类型匹配度 (0-30)
        score += tmpl.type_match_score(content) * 30
        # 信息密度匹配度 (0-20)
        score += tmpl.density_match_score(content.word_count) * 20
        # 品牌偏好 (0-20)
        score += brand.template_preference.get(tmpl.category, 0) * 20
        # 上下文连续性 -- 避免连续使用相同模板 (0-15)
        score += context.diversity_bonus(tmpl) * 15
        # 视觉节奏 -- 全图后接轻文字,数据后接叙述 (0-15)
        score += context.rhythm_score(tmpl) * 15

        scored.append((tmpl, score))

    return max(scored, key=lambda x: x[1])[0]

6. 实现架构

6.1 目录结构

template-engine/
  src/
    core/
      slot-matcher.ts        # 插槽匹配算法
      token-resolver.ts      # 令牌解析器
      layout-solver.ts       # 布局约束求解
      content-adapter.ts     # 内容适配器
    templates/
      registry.ts            # 模板注册表
      loader.ts              # 模板加载器
      schemas/               # 模板 JSON Schema
    renderers/
      html-renderer.ts       # HTML/CSS 输出
      pptx-renderer.ts       # PPTX 输出
      svg-renderer.ts        # SVG 缩略图
    brands/
      loader.ts              # 品牌配置加载
      validator.ts           # 品牌配置校验
  templates/
    cover-centered/
      definition.json        # 模板定义
      layout.json            # 布局规则
      preview.png            # 预览图
    two-cols-balanced/
      definition.json
      layout.json
      preview.png
    data-dashboard/
      definition.json
      layout.json
      preview.png
  tokens/
    default.json             # 默认令牌
    brands/
      acme.json              # 品牌覆盖
  tests/
    slot-matcher.test.ts
    layout-solver.test.ts
    token-resolver.test.ts

6.2 处理流水线

async function renderPage(
  content: PageContent,
  brand: BrandConfig,
  context: PresentationContext
): Promise<RenderedSlide> {
  // 1. 选择模板
  const template = selectTemplate(content, brand, context);

  // 2. 匹配插槽
  const assignments = matchContentToSlots(content, template);

  // 3. 解析样式令牌
  const tokens = resolveTokens(template.tokens, brand.overrides);

  // 4. 计算布局
  const layout = solveLayout(template.layout, assignments, tokens);

  // 5. 适配内容
  const adapted = adaptContents(assignments, layout);

  // 6. 渲染输出
  return renderer.render(adapted, tokens);
}

7. 溢出处理策略

当内容超出插槽容量时,按优先级依次尝试:

策略 适用场景 实现方式
缩小字号 文字略多 逐级缩小直到 min_font_size
精简内容 文字过多 LLM 重新生成更简洁的版本
切换模板 内容类型不匹配 回退到更大容量的模板
自动分页 内容远超容量 拆分为多页,保持逻辑连贯
折叠细节 层级过深 只显示前两级,附录放完整版
function handleOverflow(
  content: ContentBlock,
  slot: SlotDefinition,
  area: Rectangle
): OverflowResult {
  // 策略1:缩小字号
  const shrunk = tryShrinkFont(content, slot, area);
  if (shrunk.fits) return shrunk;

  // 策略2:精简内容
  const condensed = tryCondense(content, slot, area);
  if (condensed.fits) return condensed;

  // 策略3:切换模板
  return { action: 'switch_template', reason: 'content_overflow' };
}

8. 质量保障

8.1 自动化检查清单

检查项 规则 严重度
文字溢出 无文字超出安全区域 ERROR
最小字号 正文 >= 14px ERROR
对比度 文字/背景对比度 >= 4.5:1 ERROR
图片分辨率 显示尺寸下 DPI >= 150 WARN
元素重叠 无元素遮挡 ERROR
间距一致 同级元素间距偏差 < 4px WARN
配色数量 单页不超过 5 种颜色 WARN
对齐检查 相邻元素对齐偏差 < 2px WARN

8.2 视觉回归测试

每次模板引擎更新后,对所有模板运行视觉回归:

# 生成基准截图
node scripts/generate-baselines.js --templates all --output baselines/

# 对比当前渲染与基准
node scripts/visual-diff.js --baselines baselines/ --current output/
# 输出: diff 报告 + 差异热力图

9. 性能基准

操作 目标延迟 实测 备注
令牌解析 < 5ms ~2ms 缓存后首次约 2ms
插槽匹配 < 10ms ~5ms 简单模板 < 1ms
布局求解 < 50ms ~20ms Kiwi.js 单页
内容适配 < 30ms ~15ms 含文字渲染估算
HTML 渲染 < 20ms ~10ms 不含图片加载
PPTX 节点 < 30ms ~25ms PptxGenJS 单页
完整单页 < 100ms ~70ms 以上总计

Maurice | maurice_wen@proton.me