# 字体主题系统设计文档 ## Context 当前系统在 `core/elements.py` 中定义 `TextElement.font` 为字典类型,支持 size、bold、italic、color、align 五个属性。渲染器 `renderers/pptx_renderer.py` 的 `_render_text()` 方法直接访问这些字典字段并应用到 python-pptx 对象。 表格元素 `TableElement` 通过 `style.font_size` 和 `style.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` 数据类封装字体配置: ```python @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 两种类型** ```python @dataclass class TextElement: font: FontConfig | str | dict = field(default_factory=dict) ``` **理由:** - 字符串形式 `font: "@title"` 支持简洁的整体引用 - 字典形式兼容现有 YAML 语法 - FontConfig 对象作为内部表示 **处理流程:** 1. YAML 加载时保持原始类型(str 或 dict) 2. 元素创建前解析为 FontConfig 对象 3. 渲染时使用 FontConfig 对象 ### 3. 预设字体类别映射 **决策:直接映射到推荐字体名称,不进行字体验证** ```python # 预设字体类别映射(跨平台推荐) 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 字段,提供完整字体配置** ```python @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"` 继承表格字体,然后覆盖特定属性 - `font` 和 `header_font` 都支持引用方式:整体引用 `font: "@body"` 或继承覆盖 `font: {parent: "@body", size: 14}` **示例:** ```yaml # 示例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_size` 和 `style.header_color`,简化字段用途 ### 6. 字体解析器模块化 **决策:创建独立的 FontResolver 类** ```python 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. 更新 `TextElement` 和 `TableElement`,font 字段支持 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 定义自己的字体配置