1
0
Files
PPTX/openspec/changes/archive/2026-03-05-font-theme-system/design.md
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

370 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 字体主题系统设计文档
## 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 定义自己的字体配置