323 lines
11 KiB
Markdown
323 lines
11 KiB
Markdown
# 1. 问题
|
||
|
||
图片生成模块中存在大量重复代码,两个核心函数 `create_webzine_image` 和 `create_combined_webzine_image` 包含相似的文本解析、字体处理和图片渲染逻辑,导致代码维护困难且容易出错。
|
||
|
||
## 1.1. **代码重复严重**
|
||
|
||
`scripts/modules/image_generator.py` 文件中的两个函数 `create_webzine_image`(第13-131行)和 `create_combined_webzine_image`(第134-239行)存在大量重复代码:
|
||
|
||
* 都包含相同的文本解析逻辑,识别"标题:"、"正文:"、"价值点:"等前缀
|
||
|
||
* 都使用相同的字体加载和配置代码
|
||
|
||
* 都包含相似的文本换行和宽度计算逻辑
|
||
|
||
* 都有重复的图片绘制命令处理
|
||
|
||
这种重复导致:
|
||
|
||
* 代码行数冗余,两个函数共约230行,其中至少60%是重复逻辑
|
||
|
||
* 修改渲染逻辑时需要在两个地方同步修改,容易遗漏
|
||
|
||
* 增加了代码理解和维护的成本
|
||
|
||
## 1.2. **缺乏抽象层次**
|
||
|
||
当前实现没有将共同的渲染逻辑抽象为独立的函数,导致:
|
||
|
||
* 文本解析逻辑散落在两个函数中,形成"面条代码"
|
||
|
||
* 字体配置和图片尺寸参数硬编码在多个位置
|
||
|
||
* 渲染命令的构建逻辑重复,没有统一的处理接口
|
||
|
||
## 1.3. **错误处理不一致**
|
||
|
||
两个函数在异常处理上存在细微差异:
|
||
|
||
* `create_webzine_image` 在PIL未安装时返回None并记录警告
|
||
|
||
* `create_combined_webzine_image` 有类似的处理但日志信息略有不同
|
||
|
||
* 缺乏统一的错误处理策略,难以保证行为一致性
|
||
|
||
# 2. 收益
|
||
|
||
通过重构图片生成模块,提取公共逻辑并建立清晰的抽象层次,可以显著提升代码质量和开发效率。
|
||
|
||
## 2.1. **减少代码重复**
|
||
|
||
重构后可以将重复代码从约140行减少到约30行,代码总量减少约40%。通过提取公共函数,两个核心函数的代码行数都将显著减少,提升代码的简洁性和可读性。
|
||
|
||
## 2.2. **提升可维护性**
|
||
|
||
统一的渲染逻辑意味着:
|
||
|
||
* 修改文本解析规则时只需修改一处
|
||
|
||
* 调整字体或样式时可以集中配置
|
||
|
||
* 添加新的渲染特性时可以复用现有抽象
|
||
|
||
* 降低因修改不同步导致的bug风险
|
||
|
||
## 2.3. **增强可测试性**
|
||
|
||
提取后的公共函数可以独立进行单元测试,不再需要依赖完整的图片生成流程。这样可以更容易地验证文本解析、换行逻辑等核心功能的正确性。
|
||
|
||
## 2.4. **改善代码可读性**
|
||
|
||
通过合理的函数命名和职责分离,代码的自解释性将显著提升。新的开发者可以更快理解图片生成的流程,降低学习成本。
|
||
|
||
# 3. 方案
|
||
|
||
系统性地重构图片生成模块,通过提取公共函数、建立渲染抽象层次,消除代码重复并提升代码质量。
|
||
|
||
## 3.1. **提取文本解析函数**
|
||
|
||
将重复的文本前缀识别和内容提取逻辑抽象为独立函数:
|
||
|
||
```python
|
||
def _parse_webzine_line(line):
|
||
"""解析网摘单行文本,返回 (前缀类型, 前缀文本, 内容文本)"""
|
||
if not line.strip():
|
||
return ('empty', '', '')
|
||
|
||
prefixes = ['标题:', '原标题:', '发布日期:', '正文:', '价值点:']
|
||
for prefix in prefixes:
|
||
if line.startswith(prefix):
|
||
return (prefix.rstrip(':'), prefix, line[len(prefix):])
|
||
|
||
return ('normal', '', line)
|
||
```
|
||
|
||
这个函数统一处理文本解析逻辑,消除了两个函数中的重复代码。
|
||
|
||
## 3.2. **提取字体配置函数**
|
||
|
||
将字体加载和配置逻辑集中管理:
|
||
|
||
```python
|
||
def _load_webzine_fonts():
|
||
"""加载网摘图片所需的所有字体"""
|
||
return {
|
||
'banner': get_chinese_font(size=40, bold=True),
|
||
'article_title': get_chinese_font(size=36, bold=True),
|
||
'bold': get_chinese_font(size=32, bold=True),
|
||
'content': get_chinese_font(size=32, bold=False)
|
||
}
|
||
```
|
||
|
||
这样可以确保字体配置的一致性,并便于集中调整样式。
|
||
|
||
## 3.3. **提取渲染命令构建函数**
|
||
|
||
将文本换行和渲染命令生成逻辑抽象为独立函数:
|
||
|
||
```python
|
||
def _build_render_commands(webzine_content, fonts, content_width, temp_draw):
|
||
"""将网摘文本转换为渲染命令列表"""
|
||
commands = []
|
||
|
||
for line in webzine_content.split('\n'):
|
||
line_type, prefix, content = _parse_webzine_line(line)
|
||
|
||
if line_type == 'empty':
|
||
commands.append(('empty',))
|
||
elif line_type == '标题':
|
||
full_title = prefix + content
|
||
wrapped = wrap_text(full_title, fonts['article_title'], content_width, temp_draw)
|
||
for wrapped_line in wrapped:
|
||
commands.append(('article_title', wrapped_line))
|
||
elif line_type in ['原标题', '发布日期', '正文', '价值点']:
|
||
test_line = prefix + content
|
||
test_bbox = temp_draw.textbbox((0, 0), test_line, font=fonts['content'])
|
||
test_width = test_bbox[2] - test_bbox[0]
|
||
|
||
if test_width <= content_width:
|
||
commands.append(('prefix_sameline', prefix, content))
|
||
else:
|
||
commands.append(('prefix_only', prefix))
|
||
wrapped = wrap_text(content, fonts['content'], content_width, temp_draw)
|
||
for wrapped_line in wrapped:
|
||
commands.append(('content_indented', wrapped_line))
|
||
else:
|
||
wrapped = wrap_text(content, fonts['content'], content_width, temp_draw)
|
||
for wrapped_line in wrapped:
|
||
commands.append(('normal', wrapped_line))
|
||
|
||
return commands
|
||
```
|
||
|
||
这个函数将复杂的文本处理逻辑封装起来,使主函数更加清晰。
|
||
|
||
## 3.4. **提取渲染执行函数**
|
||
|
||
将图片绘制的具体执行逻辑抽象为独立函数:
|
||
|
||
```python
|
||
def _execute_render_commands(draw, commands, fonts, padding, line_height, content_width):
|
||
"""执行渲染命令列表,在指定画布上绘制内容"""
|
||
y = 135
|
||
|
||
for cmd in commands:
|
||
cmd_type = cmd[0]
|
||
|
||
if cmd_type == 'empty':
|
||
y += line_height
|
||
elif cmd_type == 'normal':
|
||
draw.text((padding, y), cmd[1], fill=(0, 0, 0), font=fonts['content'])
|
||
y += line_height
|
||
elif cmd_type == 'article_title':
|
||
draw.text((padding, y), cmd[1], fill=(0, 0, 0), font=fonts['article_title'])
|
||
y += line_height
|
||
elif cmd_type == 'prefix_sameline':
|
||
label_bbox = draw.textbbox((0, 0), cmd[1], font=fonts['bold'])
|
||
label_width = label_bbox[2] - label_bbox[0]
|
||
draw.text((padding, y), cmd[1], fill=(0, 0, 0), font=fonts['bold'])
|
||
draw.text((padding + label_width, y), cmd[2], fill=(0, 0, 0), font=fonts['content'])
|
||
y += line_height
|
||
elif cmd_type == 'prefix_only':
|
||
draw.text((padding, y), cmd[1], fill=(0, 0, 0), font=fonts['bold'])
|
||
y += line_height
|
||
elif cmd_type == 'content_indented':
|
||
draw.text((padding, y), cmd[1], fill=(0, 0, 0), font=fonts['content'])
|
||
y += line_height
|
||
elif cmd_type == 'separator':
|
||
y += 30
|
||
draw.line([(padding, y), (content_width + padding, y)], fill=(200, 200, 200), width=1)
|
||
y += 30
|
||
|
||
return y
|
||
```
|
||
|
||
这样可以将绘制逻辑与业务逻辑分离,提升代码的模块化程度。
|
||
|
||
## 3.5. **重构后的主函数**
|
||
|
||
重构后的两个主函数将变得简洁清晰:
|
||
|
||
```python
|
||
def create_webzine_image(webzine_content, output_path, title_text):
|
||
"""将网摘文本内容渲染为PNG图片(800px宽,自适应高度)"""
|
||
logger = get_logger()
|
||
if Image is None:
|
||
logger.warning("PIL 未安装,跳过图片生成")
|
||
return None
|
||
|
||
try:
|
||
width = 800
|
||
padding = 50
|
||
content_width = width - padding * 2
|
||
line_height = 40
|
||
|
||
# 解析日期
|
||
date_match = re.search(r'\d{4}年\d{2}月\d{2}日', webzine_content)
|
||
if date_match:
|
||
date_str = date_match.group(0).replace('年', '.').replace('月', '.').replace('日', '')
|
||
else:
|
||
date_str = datetime.now().strftime('%Y.%m.%d')
|
||
banner_text = f'网摘 {date_str}'
|
||
|
||
# 加载字体
|
||
fonts = _load_webzine_fonts()
|
||
|
||
# 构建渲染命令
|
||
temp_img = Image.new('RGB', (100, 100), color=(255, 255, 255))
|
||
temp_draw = ImageDraw.Draw(temp_img)
|
||
commands = _build_render_commands(webzine_content, fonts, content_width, temp_draw)
|
||
|
||
# 计算图片高度
|
||
actual_lines_count = len([cmd for cmd in commands if cmd[0] != 'separator'])
|
||
img_height = 140 + actual_lines_count * line_height + padding * 2
|
||
img_height = int(img_height * 1.15)
|
||
|
||
# 创建图片并绘制
|
||
img = Image.new('RGB', (width, img_height), color=(255, 255, 255))
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# 绘制标题栏
|
||
draw.rectangle([(0, 0), (width, 90)], fill=(26, 72, 144))
|
||
draw.text((padding, 25), banner_text, fill=(255, 255, 255), font=fonts['banner'])
|
||
draw.line([(padding, 100), (width - padding, 100)], fill=(180, 180, 180), width=2)
|
||
|
||
# 执行渲染命令
|
||
_execute_render_commands(draw, commands, fonts, padding, line_height, content_width)
|
||
|
||
img.save(output_path, 'PNG', quality=95)
|
||
logger.info("网摘图片已保存: %s (%d条命令, %dpx)", output_path, len(commands), img_height)
|
||
return output_path
|
||
|
||
except Exception as e:
|
||
logger.warning("图片生成失败: %s", e)
|
||
return None
|
||
```
|
||
|
||
重构后的函数结构清晰,职责单一,易于理解和维护。
|
||
|
||
# 4. 回归范围
|
||
|
||
本次重构主要影响图片生成功能,需要重点测试网摘图片生成的正确性和稳定性。
|
||
|
||
## 4.1. 主链路
|
||
|
||
1. **完整日报生成流程**
|
||
|
||
* 从RSS抓取到最终图片生成的完整流程
|
||
|
||
* 验证生成的图片格式、尺寸、内容正确性
|
||
|
||
* 确认图片文件能正常保存到指定路径
|
||
|
||
2. **网摘图片生成**
|
||
|
||
* 单篇网摘图片生成功能
|
||
|
||
* TOP3合并长图生成功能
|
||
|
||
* 验证图片中的文本内容、格式、样式符合预期
|
||
|
||
3. **异常情况处理**
|
||
|
||
* PIL未安装时的降级处理
|
||
|
||
* 图片生成失败时的错误处理和日志记录
|
||
|
||
* 确认异常情况下不影响主流程继续执行
|
||
|
||
## 4.2. 边界情况
|
||
|
||
1. **特殊文本内容**
|
||
|
||
* 包含超长标题的文章
|
||
|
||
* 包含特殊字符或表情符号的文本
|
||
|
||
* 空内容或格式异常的网摘文本
|
||
|
||
2. **字体和样式**
|
||
|
||
* 不同操作系统的字体兼容性
|
||
|
||
* 中英文混合内容的正确渲染
|
||
|
||
* 文本换行和边界情况的处理
|
||
|
||
3. **性能和资源**
|
||
|
||
* 生成大量图片时的内存使用情况
|
||
|
||
* 并发生成图片时的线程安全性
|
||
|
||
* 大文本内容的处理性能
|
||
|
||
4. **缓存和复用**
|
||
|
||
* 图片已存在时的跳过逻辑
|
||
|
||
* 缓存机制与重构后的兼容性
|
||
|
||
* 增量生成时的正确性
|
||
|