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。