Files
my-daily/02图片生成代码重复问题.md
T

11 KiB
Raw Blame History

1. 问题

图片生成模块中存在大量重复代码,两个核心函数 create_webzine_imagecreate_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. 提取文本解析函数

将重复的文本前缀识别和内容提取逻辑抽象为独立函数:

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. 提取字体配置函数

将字体加载和配置逻辑集中管理:

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. 提取渲染命令构建函数

将文本换行和渲染命令生成逻辑抽象为独立函数:

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. 提取渲染执行函数

将图片绘制的具体执行逻辑抽象为独立函数:

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. 重构后的主函数

重构后的两个主函数将变得简洁清晰:

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. 缓存和复用

    • 图片已存在时的跳过逻辑

    • 缓存机制与重构后的兼容性

    • 增量生成时的正确性