1
0
Files
lanyuanxiaoyao bd12fce14b feat: 实现字体主题系统和东亚字体支持
实现完整的字体主题系统,支持可复用字体配置、预设类别和扩展属性。
同时修复中文字体渲染问题,确保 Source Han Sans 等东亚字体正确显示。

核心功能:
- 字体主题配置:metadata.fonts 和 fonts_default
- 三种引用方式:整体引用、继承覆盖、独立定义
- 预设字体类别:sans、serif、mono、cjk-sans、cjk-serif
- 扩展字体属性:family、underline、strikethrough、line_spacing、
  space_before、space_after、baseline、caps
- 表格字体字段:font 和 header_font 替代旧的 style.font_size
- 引用循环检测和属性继承链
- 模板字体继承支持

东亚字体修复:
- 添加 _set_font_with_eastasian() 方法
- 同时设置拉丁字体、东亚字体和复杂脚本字体
- 修复中文字符使用默认字体的问题

测试:
- 58 个单元测试覆盖所有字体系统功能
- 3 个集成测试验证端到端场景
- 移除旧语法相关测试

文档:
- 更新 README.md 添加字体主题系统使用说明
- 更新 README_DEV.md 添加技术文档
- 创建 4 个示例 YAML 文件
- 同步 delta specs 到主 specs

归档:
- 归档 font-theme-system 变更到 openspec/changes/archive/

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-05 10:38:59 +08:00

13 KiB
Raw Permalink Blame History

字体主题系统设计文档

Context

当前系统在 core/elements.py 中定义 TextElement.font 为字典类型,支持 size、bold、italic、color、align 五个属性。渲染器 renderers/pptx_renderer.py_render_text() 方法直接访问这些字典字段并应用到 python-pptx 对象。

表格元素 TableElement 通过 style.font_sizestyle.header_color 两个属性控制字体,与文本元素的 font 字典设计不一致。

系统不存在字体主题抽象,所有字体配置都在元素级别定义,导致重复配置和缺乏统一的样式管理。

约束条件

  • 继续使用 python-pptx 库,不能引入新的 PowerPoint 生成依赖
  • 字体检测不能污染主机环境配置,不能安装系统字体
  • 主要面向中文用户,必须优先处理 CJK 字体支持
  • 不考虑向后兼容性,使用新的字体配置语法
  • temp 目录仅用于临时文件和测试中间文件,正式示例文件放在 tests/fixtures/ 下

Goals / Non-Goals

Goals:

  • 引入 fonts 和 fonts_default 元数据字段,支持定义可复用的字体配置
  • 实现三种字体引用方式:整体引用、继承覆盖、独立定义
  • 提供五种预设字体类别sans、serif、mono、cjk-sans、cjk-serif直接映射到推荐字体名称
  • 扩展字体属性集合,新增 family、underline、strikethrough、line_spacing、space_before、space_after、baseline、caps
  • 统一表格元素的字体配置方式,新增 font 和 header_font 字段,移除旧语法 style.font_size 和 style.header_color

Non-Goals:

  • 不实现富文本混合样式(同一段落多种字体)
  • 不支持字体嵌入到 PPTX 文件(依赖系统字体)
  • 不实现多语言字体回退链(如英文用 Arial、中文用微软雅黑的自动切换
  • 不验证字体是否存在(依赖 PowerPoint 自动回退)
  • 不考虑向后兼容性(直接使用新语法)

Decisions

1. 数据结构设计

决策:使用 FontConfig 数据类替代原始字典

为保持与现有 TextElement 设计一致,创建 FontConfig 数据类封装字体配置:

@dataclass
class FontConfig:
    parent: Optional[str] = None
    family: Optional[str] = None
    size: Optional[int] = None
    bold: Optional[bool] = None
    italic: Optional[bool] = None
    underline: Optional[bool] = None
    strikethrough: Optional[bool] = None
    color: Optional[str] = None
    align: Optional[str] = None
    line_spacing: Optional[float] = None
    space_before: Optional[int] = None
    space_after: Optional[int] = None
    baseline: Optional[str] = None
    caps: Optional[str] = None

理由:

  • 数据类提供类型提示和验证入口
  • 与现有 TextElement、ImageElement 设计保持一致
  • 便于序列化和反序列化

替代方案:

  • 继续使用字典:失去类型安全,验证逻辑分散
  • 继承 dict 的自定义类:复杂度增加,收益有限

2. 元素 font 字段类型

决策font 字段支持 FontConfig | str 两种类型

@dataclass
class TextElement:
    font: FontConfig | str | dict = field(default_factory=dict)

理由:

  • 字符串形式 font: "@title" 支持简洁的整体引用
  • 字典形式兼容现有 YAML 语法
  • FontConfig 对象作为内部表示

处理流程:

  1. YAML 加载时保持原始类型str 或 dict
  2. 元素创建前解析为 FontConfig 对象
  3. 渲染时使用 FontConfig 对象

3. 预设字体类别映射

决策:直接映射到推荐字体名称,不进行字体验证

# 预设字体类别映射(跨平台推荐)
PRESET_FONT_MAPPING = {
    "sans": "Arial",              # 西文无衬线,跨平台通用
    "serif": "Times New Roman",   # 西文衬线,跨平台通用
    "mono": "Courier New",        # 等宽字体,跨平台通用
    "cjk-sans": "Microsoft YaHei",  # 中文无衬线Windows 推荐)
    "cjk-serif": "SimSun",         # 中文衬线Windows 推荐)
}

理由:

  • 零依赖:不需要任何额外的字体检测库
  • python-pptx 行为:测试表明 font.name 可以接受任意字符串,不会报错
  • PowerPoint 回退:字体不存在时,由 PowerPoint 应用程序在打开文件时自动回退到系统默认字体
  • 简化实现:避免复杂的平台检测和字体扫描逻辑

为什么不验证字体:

  • python-pptx 不会验证字体是否存在
  • 验证需要额外依赖或复杂的平台特定代码
  • 字体不存在不会阻止转换,仅影响最终显示效果
  • 用户可以在 PowerPoint 中看到字体回退情况

4. 字体引用解析顺序

决策:三阶段解析链(不验证字体)

1. parent 解析(如果存在)
   → 复制 fonts[parent] 的所有属性
   → 用当前定义的属性覆盖

2. family 解析
   → "@xxx" 格式:仅验证是有效的引用格式
   → "sans/..." 格式:映射到预设字体类别
   → "FontName" 格式:直接使用字体名称(不验证)

3. 未指定属性继承
   → 继承 fonts_default 指向的配置
   → 继承完成后仍为 None 的属性使用系统默认值

理由:

  • 明确的优先级避免歧义
  • parent 优先处理,确保覆盖逻辑正确
  • 不验证字体存在,依赖 PowerPoint 自动回退

5. 表格字体配置

决策:使用 font 和 header_font 字段,提供完整字体配置

@dataclass
class TableElement:
    # 表格整体字体(应用于数据单元格)
    font: FontConfig | str | None = None

    # 表头字体(可选,未定义时继承 font
    header_font: FontConfig | str | None = None

    # 表格样式(非字体相关,如背景色、边框等)
    style: dict = field(default_factory=dict)

字体解析优先级:

数据单元格:

  1. font如果定义
  2. fonts_default如果 font 未定义)
  3. 系统 默认

表头单元格:

  1. header_font如果定义
  2. font如果 header_font 未定义)
  3. fonts_default如果都未定义
  4. 系统 默认

继承和覆盖:

  • header_font 可以通过 parent: "@font" 继承表格字体,然后覆盖特定属性
  • fontheader_font 都支持引用方式:整体引用 font: "@body" 或继承覆盖 font: {parent: "@body", size: 14}

示例:

# 示例1: 表格整体字体
- type: table
  font:
    family: "Microsoft YaHei"
    size: 14
    color: "#333333"
  # 表头和数据单元格都使用相同样式

# 示例2: 表头使用不同样式
- type: table
  font:
    family: "Microsoft YaHei"
    size: 14
  header_font:
    parent: "@font"      # 继承表格字体
    bold: true          # 覆盖:表头加粗
    color: "#ffffff"    # 覆盖:表头白色
  style:
    header_bg: "#4a90e2"

# 示例3: 引用字体主题
- type: table
  font: "@table-body"
  header_font: "@table-header"

# 示例4: 仅定义表头字体
- type: table
  header_font:
    bold: true
    color: "#ffffff"
  # 数据单元格继承 fonts_default

理由:

  • 统一的字体配置方式,与文本元素保持一致
  • header_font 明确表示表头样式,语义清晰
  • 支持继承覆盖,避免重复配置
  • 移除 style.font_sizestyle.header_color,简化字段用途

6. 字体解析器模块化

决策:创建独立的 FontResolver 类

class FontResolver:
    def __init__(self, fonts: dict, fonts_default: Optional[str]):
        self.fonts = fonts
        self.default = fonts_default

    def resolve(self, font_config: FontConfig | str | dict) -> ResolvedFont:
        # 解析引用、继承、验证
        pass

    def resolve_family(self, family: str) -> str:
        # 处理预设类别和字体名称
        pass

    def validate_font(self, family: str) -> bool:
        # 检查字体可用性
        pass

理由:

  • 集中字体解析逻辑,便于测试和维护
  • 解耦渲染器和字体验证逻辑
  • 便于单元测试各种引用场景

7. 模板字体继承

决策:模板元素未定义 font 时继承 fonts_default

模板渲染流程中:

  1. 解析模板变量和条件渲染
  2. 对每个元素检查 font 字段
  3. 如果 font 未定义,将 fonts_default 的配置注入到元素 font 字段
  4. 传递给渲染器处理

理由:

  • 保持模板简洁,不需要为每个元素定义 font
  • 允许通过 fonts_default 统一控制模板样式
  • 模板仍可以覆盖特定元素的字体

Risks / Trade-offs

Risk 1: 字体不存在时显示效果不符合预期

风险: 用户指定了系统不存在的字体PowerPoint 回退后显示效果与预期不符

缓解措施:

  • 在文档中说明预设字体类别的推荐字体
  • 建议用户使用预设字体类别以提高跨平台兼容性
  • 可选:在转换日志中列出所有使用的字体名称供用户检查

Risk 2: 引用循环导致无限递归

风险: fonts 配置中存在循环引用,如 fonts.a.parent: "@b"fonts.b.parent: "@a"

决策:检测到引用循环直接抛出 ERROR

在解析过程中维护已访问的引用链,检测到重复引用时立即抛出 ERROR 并提示循环引用路径。例如:

ERROR: 检测到字体引用循环: fonts.title -> fonts.subtitle -> fonts.title

缓解措施:

  • 使用集合记录当前解析链中的引用名称
  • 限制最大引用深度(如 10 层),超出时也抛出 ERROR
  • 在错误信息中显示完整的引用路径,便于用户定位问题

Risk 3: 预设字体类别在不同平台表现不一致

风险: 预设字体类别映射到特定字体名称,不同平台的可用字体不同

缓解措施:

  • 选择跨平台通用性最高的字体(如 Arial、Times New Roman
  • 在文档中说明各预设类别的推荐字体
  • 用户可以通过 fonts 定义自己的字体配置来覆盖预设行为

Risk 4: 旧 YAML 文件需要迁移

风险: 使用旧语法的 YAML 文件需要更新才能使用新功能

缓解措施:

  • 提供清晰的迁移文档和示例
  • 新语法更加直观和强大,值得迁移成本
  • 可以提供迁移脚本或工具辅助转换

Trade-off 1: 简洁性 vs 表达能力

权衡: 三种引用方式增加学习成本,但提供更强大的复用能力

决策: 优先表达能力,提供清晰的文档和示例

Trade-off 2: 字体验证 vs 简洁性

权衡: 验证字体是否存在增加复杂度vs 直接设置字体名(依赖 PowerPoint 回退)

决策: 不验证字体,直接设置字体名称。理由:

  • python-pptx 不验证字体,可以接受任意字符串
  • 字体不存在时 PowerPoint 会自动回退到系统默认字体
  • 避免引入额外依赖或复杂的平台检测代码
  • 用户可以在 PowerPoint 中查看最终效果

Migration Plan

阶段 1: 数据结构更新

  1. core/elements.py 中新增 FontConfig 数据类
  2. 更新 TextElementTableElementfont 字段支持 FontConfig | str | dict
  3. 支持 dict 作为输入格式(在元素创建前转换为 FontConfig 对象)

阶段 2: 解析器实现

  1. 创建 utils/font_resolver.py,实现 FontResolver 类
  2. 实现预设字体类别映射sans → Arial 等)
  3. 实现字体引用解析逻辑(整体引用、继承覆盖、独立定义)
  4. 实现引用循环检测和错误提示

阶段 3: 加载器集成

  1. loaders/yaml_loader.py 中解析 metadata.fonts 和 metadata.fonts_default
  2. 将元素 font 字段从原始类型转换为 FontConfig 对象
  3. 将 metadata.fonts 字典转换为 FontConfig 对象字典

阶段 4: 渲染器更新

  1. 更新 renderers/pptx_renderer.py_render_text() 方法:
    • 使用 FontResolver 解析 font 配置
    • 应用扩展字体属性underline、strikethrough、line_spacing 等)
  2. 更新 _render_table() 方法:
    • 支持 font 和 header_font 字段
    • 移除 style.font_size 和 style.header_color 的处理逻辑
  3. 实现段落属性line_spacing、space_before、space_after

阶段 5: 模板集成

  1. 更新 core/template.py,模板渲染时注入 fonts_default
  2. 确保内联模板和外部模板都支持字体继承

阶段 6: 测试和文档

  1. 新增单元测试覆盖字体解析、引用、继承逻辑
  2. 新增集成测试覆盖各种 YAML 语法
  3. 更新 README.md 和 README_DEV.md说明预设字体类别
  4. 添加示例 YAML 文件展示字体主题系统用法

Open Questions

  1. fonts_default 未定义时的行为: 是否完全等同于当前行为,还是提供最小默认字体?

    • 倾向完全等同当前行为font 未设置时使用 python-pptx 的默认字体
  2. 表格单元格级字体: 是否需要支持不同行/列使用不同字体?

    • 决策:不在当前范围,仅支持表头和数据单元格两种字体
  3. 预设字体类别扩展: 是否允许用户自定义预设类别映射?

    • 决策:不在当前范围,预设类别保持系统内置。用户可以通过 fonts 定义自己的字体配置