PDF 排版字体问题排查记录 | XXXX报价单 v1~v6 全流程

PDF 排版字体问题排查记录 | XXXX报价单 v1~v6 全流程

一、项目概述

用户要求生成一份带XXXX公司 logo 的产品报价单(PDF
格式),软件产品名称为「XX数据库一体机管理系统」,单价 10
万元/节点。在生成过程中陆续遇到中文字体乱码、数字消失、全角括号显示方块、封面
logo 不显示等问题,历经 6 个版本迭代后全部解决。

二、版本问题总览

下表列出 v1~v6 各版本的核心症状和根本原因:

版本 症状 根本原因
v1 中文乱码(字体缺失) ReportLab 未注册任何中文字体,所有文字走内置 Helvetica(不含 CJK
字形)
v2 标题乱码(正文正常) 只修复了 Helvetica(正文),漏掉了 Times-Bold(标题)
v3 数字消失(正文乱码) 将 DroidSansFallback 覆盖注册到 Helvetica 名字,导致 ASCII
数字字形映射错乱
v4 数字恢复,页眉中文 + 全角括号方块 引入 CJK 分段函数,但覆盖 Helvetica 导致数字失效;全角标点未纳入 CJK
范围;页眉未走新渲染路径
v5 字体全正常,Logo 不显示 封面 split 模板不支持 logo,PDF_COVER_IMAGE 环境变量未传入
cover.py
v6 [OK] 全部正常 字体分离 + CJK 分段函数 + _draw_cjk_string + 环境变量 + split logo
全部到位

三、根本原因分析

问题 说明
DroidSansFallback 覆盖注册 Helvetica 将 TrueType CJK 字体「覆盖注册」到 Type 1 内置字体 Helvetica
的名字下。两者字形映射机制不同(Type 1 用标准化索引,TrueType 用 Unicode
cmap),导致 Latin/ASCII 数字通过 Helvetica 查找时全部失效
CJK 字符 Unicode 范围不完整 早期只检测 U+2E80-U+9FFF(汉字)和 U+F900-U+FAFF(CJK
兼容字符),漏掉了
U+FF00-U+FFEF(全角标点),导致中文括号「」、【】、()、引号等全部渲染为方块
封面 split 模板不支持 logo cover.py 的 _pattern_split() 函数 HTML 中没有 <img>
标签;make.sh 传入 PDF_COVER_IMAGE 环境变量但 cover.py 没有读取
DejaVu Sans 与 DroidSansFallback 混用失败 两个字体各自擅长不同:DroidSansFallback 主攻 CJK,DejaVu Sans 负责
Latin/数字。必须各注册独立名字,不能互相覆盖

四、各版本详细排查过程

v1 — 初始版本:中文全部乱码

症状:PDF 中所有中文字符显示为乱码或空白方块。

根本原因:

  • 系统中有 DroidSansFallbackFull.ttf(中文字体),但 render_body.py
    的 register_fonts() 从未注册任何中文字体

  • 正文使用 Helvetica(内置 Type 1 字体),Helvetica
    不包含中日韩字形

  • 字体注册函数只处理 font_paths 中的路径,没有兜底
    fallback

处理:

  • 在 register_fonts() 中加入 fallback:检测
    DroidSansFallbackFull.ttf 并注册到 Helvetica 名字下

v2 — 标题乱码(正文正常了)

症状:正文中文正常了,但「报价信息」「产品报价明细」等标题仍是乱码。

根本原因:

  • v1 的修复只覆盖了 Helvetica(正文),标题用的是
    Times-Bold,两者独立注册

  • Times-Bold 仍然指向内置 Type 1 字体,无 CJK 支持

处理:

  • 将 Times-Bold 也注册为 DroidSansFallbackFull.ttf(与 Helvetica
    相同的覆盖策略)

v3 — 数字消失

症状:中文和标题都正常了,但数字「100,000.00」「HT-2026-0401」全部消失(空白)。

根本原因:

  • DroidSansFallback 是 TrueType 字体,Helvetica 是 Type 1
    字体,字形映射机制完全不同

  • 将 TrueType 覆盖注册到 Type 1 内置字体名后,ReportLab 仍按 Type 1
    方式查找 glyph,但 DroidSans 的 TrueType cmap 与 Type 1 索引不兼容,导致
    Latin/ASCII 字符全部渲染失败

  • pdftotext 能提取出「HT-2026-0401」和「100,000」说明内容存在,只是
    PDF 视觉渲染失效

处理方向:

  • 将 DejaVu Sans(拉丁/数字字体)注册为
    Helvetica,DroidSansFallback 注册为独立名字
    DroidSans,两者不再互相覆盖

v4 — 数字恢复,页眉中文和全角括号仍有问题

症状:数字和西文恢复正常,但页眉/页脚的中文仍是方块;中文括号「」、【】、()、引号等显示异常。

根本原因:

  • 引入 _cjk_font_split() 函数,根据 Unicode 范围判断字符类型,用
    <font name=”DroidSans”> 包装 CJK 字符,<font
    name=”Helvetica”> 包装 Latin 字符(实际指向 DejaVu Sans)

  • CJK 字符检测范围只包含 U+2E80-U+9FFF 和
    U+F900-U+FAFF,漏掉了全角标点 U+FF00-U+FFEF

  • 页眉/页脚使用 canv.drawString() 直接渲染,完全绕过了 Paragraph +
    _cjk_font_split 路径

处理:

  • 扩展 CJK 字符检测范围,加入
    U+FF00-U+FFEF(全角标点:中文括号、引号等)

  • 新增 _draw_cjk_string() 函数,在 Canvas
    层面逐字符切换正确字体

  • 修改 _decorate(),页眉/页脚改用 _draw_cjk_string() 渲染

v5 — Logo 不显示

症状:字体全部正常,但封面左上角没有显示XXXX的 logo。

根本原因:

  • 封面模板使用「split」版式(proposal 类型的默认风格),该模板没有
    <img> 标签

  • make.sh 脚本设置了 PDF_COVER_IMAGE 环境变量,但 cover.py 的
    main() 函数没有读取它,导致 logo 路径从未进入 HTML

处理:

  • 在 cover.py main() 中显式读取 PDF_COVER_IMAGE 环境变量并写入
    tokens[“cover_image”]

  • 在 _pattern_split() 中加入 logo 的 <img>
    标签,设置为标题上方、60px 高

v6 — 全部正常 [OK]

综合所有修复,PDF 生成完全正常:

  • 封面:标题、上方 logo、作者、日期全部正确渲染

  • 正文:中文、ASCII 数字、西文、标点、全角括号均正确

  • 页眉/页脚:中文和数字均正确

  • 字体:Latin 用 DejaVu Sans(注册为 Helvetica),CJK 用
    DroidSansFallback(注册为独立名 DroidSans),互不干扰

五、最终修复方案汇总

修复项 具体做法
字体分离注册 DejaVu Sans 注册为 Helvetica(Latin/数字) DroidSansFallback
注册为独立名字 DroidSans(不覆盖任何内置名)
_cjk_font_split() 函数(新增) 自动检测字符串中 CJK
字符(U+2E80-U+9FFF、U+F900-U+FAFF、U+FF00-U+FFEF),为每段分配正确字体标签
_draw_cjk_string() 函数(新增) 为页眉/页脚 Canvas
直接渲染实现字体切换,逐字符判断类型并调用正确字体的 drawString
CJK 字符范围扩展 增加 U+FF00-U+FFEF 全角标点范围,覆盖中文括号、引号等常见标点
cover.py logo 支持(新增) main() 读取 PDF_COVER_IMAGE 环境变量写入 tokens;_pattern_split()
输出 <img> 标签

六、涉及修改的文件

文件 修改内容
render_body.py register_fonts() — 字体分离注册策略(DejaVu/DroidSans 不互相覆盖)
_cjk_font_split() — 新增 CJK 分段函数(Unicode 范围含全角标点)
_draw_cjk_string() — 新增 Canvas 字体切换渲染函数 _decorate() —
页眉/页脚改用 _draw_cjk_string()
所有文字渲染点(Paragraph、Table、Callout 等)— 接入
_cjk_font_split()
cover.py import os — 新增系统库导入 main() — 读取 PDF_COVER_IMAGE
环境变量,写入 tokens[“cover_image”] _pattern_split() — HTML 模板加入
logo <img> 标签

七、技术要点总结

1. ReportLab 字体注册机制

ReportLab 支持 TrueType(TTFont)和 Type 1(CIDFont)两种字体。TTFont
可以注册到任意名字下覆盖内置字体,但 CIDFont 只能通过 UnicodeCIDFont
方式使用。当同一个名字被多次注册时,以最后一次注册为准。STSong-Light、MSung-Light、HeiseiMin-W3
等CJK字体均为 CIDFont,ReportLab
内置了它们的宽度表但需要字体文件支持。

2. 为什么不能「覆盖 Helvetica」注册 CJK

Helvetica 是 ReportLab 内置的 Type 1 字体,Latin/ASCII 字符的 glyph
索引是固定的。用 TrueType(DroidSansFallback)覆盖后,ReportLab 仍按
Type 1 方式查找 glyph,但 TrueType cmap 表格式与 Type 1 完全不同,导致
Latin 字符全部失效。解决方案:两个字体各注册独立名字,互不覆盖。

3. CJK Unicode 范围参考

范围 内容
U+2E80 – U+9FFF CJK 统一汉字(常用汉字约 2 万字)
U+F900 – U+FAFF CJK 兼容字符(兼容汉字、表意文字描述字符等)
U+FE30 – U+FE4F CJK 标点兼容(竖排标点等)
U+FF00 – U+FFEF 全角标点:中文括号「」()【】、《》()和全角 ASCII 标点

4. Playwright 封面渲染 logo 缺失的根因

封面通过 Playwright(Chromium 无头浏览器)将 HTML 渲染为
PDF。cover.py 读取 tokens[“cover_image”] 来决定是否在 HTML 中输出
<img> 标签。make.sh 注入的是环境变量 PDF_COVER_IMAGE,但 cover.py
的 main() 函数从未读取该环境变量,导致 logo 路径从未进入 HTML
模板,Playwright 渲染 PDF 时自然找不到图片。修复方式是在 main()
中显式读取并写入 tokens。

Comments are closed.