CHM转PDF完整指南:从解包到书签生成与中文乱码解决
简介CHM2PDF是一款面向技术文档阅读者与资料整理者的格式转换工具专门解决CHM帮助文件在非Windows平台阅读不便、分享受限的问题。CHM格式虽体积小、便于分发但阅读体验和跨平台兼容性欠佳而PDF作为开放标准能保留原始布局、字体与图像并支持书签、注释与加密因此将CHM转为PDF成为不少用户的实际需求。本资源包共11个文件以exe可执行程序、dll动态库、txt说明文档为主另含htm与chm帮助文件及url快捷方式压缩包约1.86MB涵盖主程序、转换组件与使用说明结构紧凑。已有715人学习下载。借助该工具用户可将CHM中的文本、图像、表格与超链接导出为PDF保留章节结构和内链便于在电脑、平板或手机上一致阅读与分享说明文档还提示了源文件完整性、图片质量、超链接维护与排版一致性等注意事项适合需要批量处理技术手册与电子书的读者参考使用。1. CHM 转 PDF 这件事卡住你的从来不是“转”这个动作手里有一份 CHM 帮助文档想把它变成能随手翻、能全文检索、能丢进平板看的 PDF——听起来是个五分钟的小需求真动手才发现处处是坑目录树点进去是空的、中文全是方块、代码块被截断、图片丢了一半。CHM 转 PDF 这个方向本质上是把一个微软早期的压缩 HTML 帮助格式还原成一套可打印的静态文档再交给排版引擎重新分页。它解决的不是“格式转换”这么简单而是“把一份带层级目录、带索引、带内嵌资源的离线文档完整搬到另一个载体上”。适合谁做技术文档归档的、要给客户交付离线手册的、手里攒了一堆老帮助文件想统一管理的从业者。下面这套路径是我自己反复跑过、能复现的方案不依赖任何在线服务。2. 先搞懂 CHM 里到底装了什么拆包比转换更重要很多人一上来就找“一键 CHM 转 PDF 工具”结果转出来目录全乱。问题出在跳过了拆包这一步。CHM 不是单一文件它是一个压缩容器内部结构决定了后面能不能还原出正确的层级和顺序。2.1 CHM 的内部结构三个关键文件决定成败一个标准 CHM 解包后你会看到一堆 .htm 文件、图片、样式表外加几个不起眼但至关重要的元数据文件文件作用缺失后果.hhc目录树定义决定章节层级和顺序转出的 PDF 没有书签章节乱序.hhk索引关键字用于全文检索锚点检索功能丢失.hhp工程文件记录编译时的默认页和窗口定义首页可能定位错误常见做法是先用命令行工具把 CHM 完整解包而不是直接喂给转换器。我一般用7z或extract_chmLib前者跨平台后者对中文路径更稳。# 用 7z 解包 CHM-o 指定输出目录 7z x manual.chm -o./chm_extracted/ # 查看解包结果确认 .hhc 和 .hhk 是否存在 ls -la ./chm_extracted/ | grep -E \.(hhc|hhk|hhp)$逻辑说明7z x是解压并保留目录结构-o后面跟输出路径注意不要有空格。解包后第一件事不是急着转而是确认.hhc文件在不在——它决定了后面 PDF 书签能不能自动生成。如果.hhc缺失说明这个 CHM 编译时就没做目录后面只能靠 HTML 里的标题层级手动重建。参数说明7z对 CHM 的支持依赖p7zip-full包部分精简系统需要先装。如果解包报错“Can not open file as archive”换extract_chmLib再试它对非标准 CHM 的兼容性更好。2.2 为什么不能直接“打印成 PDF”分页与资源路径的坑有人图省事用浏览器打开 CHM 解包后的首页然后 CtrlP 打印成 PDF。这个做法在纯文字文档上勉强能用但一旦有代码块、表格、图片就会翻车浏览器打印不会自动处理跨页表格的断行代码块会被拦腰截断图片路径如果是相对路径且没解包完整直接显示裂图。更隐蔽的问题是分页。CHM 的每个 .htm 是独立页面浏览器打印时会把它们当成连续流但页眉页脚、页码、章节起始页全都不受控。我见过最离谱的情况是一份 300 页的 CHM打印出来 280 页中间 20 页因为图片加载失败被浏览器自动跳过了。所以正确的路径是解包 → 合并 HTML → 交给排版引擎重新分页。排版引擎我推荐 WeasyPrint 或 wkhtmltopdf前者对 CSS 分页支持更好后者对老式 HTML 兼容性更强。2.3 用 Python 把解包后的 HTML 合并成单一文档解包后的 HTML 是碎片化的每个文件对应 CHM 里的一个页面。要转成 PDF先得把它们按.hhc里的顺序拼成一个完整的 HTML。下面这段脚本做三件事读.hhc解析目录顺序、按顺序读取每个 .htm、把内容塞进一个带层级标题的模板。import os import re from bs4 import BeautifulSoup # 解析 .hhc 文件提取目录顺序 def parse_hhc(hhc_path): with open(hhc_path, r, encodinggbk, errorsignore) as f: content f.read() # .hhc 本质是 HTML用 BeautifulSoup 解析 soup BeautifulSoup(content, html.parser) # 提取所有 param nameLocal valuexxx.htm 的顺序 order [] for obj in soup.find_all(object): param obj.find(param, {name: Local}) if param: order.append(param.get(value)) return order # 按顺序合并 HTML def merge_html(base_dir, order, output_file): merged [htmlheadmeta charsetutf-8/headbody] for htm_file in order: path os.path.join(base_dir, htm_file) if not os.path.exists(path): continue with open(path, r, encodinggbk, errorsignore) as f: soup BeautifulSoup(f.read(), html.parser) # 只取 body 内容去掉原有的 head 和样式冲突 body soup.find(body) if body: merged.append(str(body)) merged.append(/body/html) with open(output_file, w, encodingutf-8) as f: f.write(\n.join(merged)) # 执行 order parse_hhc(./chm_extracted/manual.hhc) merge_html(./chm_extracted, order, ./merged.html)逻辑说明.hhc文件本身是 HTML 格式里面用object标签定义每个目录项param nameLocal指向对应的 .htm 文件。按这个顺序读取就能还原出 CHM 的原始章节顺序。合并时只取body内容避免多个head冲突。参数说明encodinggbk是因为很多老 CHM 用 GBK 编码如果报 UnicodeDecodeError换成gb18030兼容性更好。errorsignore是防止个别乱码字节导致整个文件读失败。输出统一用 UTF-8方便后续排版引擎处理。3. 用 WeasyPrint 把合并后的 HTML 转成带书签的 PDF合并完 HTML下一步是排版。WeasyPrint 的优势在于它支持 CSS Paged Media 规范能自动根据h1到h6生成 PDF 书签还能控制分页、页眉页脚。wkhtmltopdf 虽然也能转但对 CSS3 分页属性支持不全书签生成需要额外参数。3.1 安装 WeasyPrint 与依赖处理WeasyPrint 依赖 GTK 库在 Linux 上需要先装系统包Windows 上建议用 MSYS2 或 WSL。我一般直接在 Linux 环境跑省去依赖折腾。# Ubuntu/Debian 安装依赖 sudo apt install -y python3-pip libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev # 安装 WeasyPrint pip install weasyprint # 验证安装 python3 -c import weasyprint; print(weasyprint.__version__)逻辑说明libpango和libpangocairo是文字渲染的核心依赖缺了会报OSError: cannot load library pango-1.0。libgdk-pixbuf负责图片加载。装完用一行 Python 验证版本能打印出版本号就说明环境通了。参数说明如果公司网络限制 pip 源加-i https://pypi.tuna.tsinghua.edu.cn/simple换国内源。WeasyPrint 版本建议 60 以上对 CSS 分页支持更完整。3.2 写一份控制分页与书签的 CSSWeasyPrint 的书签生成规则很简单HTML 里的h1到h6自动成为 PDF 书签层级按标签级别。所以合并 HTML 时要确保每个章节的标题用正确的h标签。下面这份 CSS 控制分页和页眉页脚。/* print.css */ page { size: A4; margin: 2cm 1.5cm; bottom-center { content: counter(page); font-size: 9pt; color: #666; } top-right { content: string(doctitle); font-size: 9pt; color: #999; } } /* 每个 h1 新起一页 */ h1 { page-break-before: always; string-set: doctitle content(); } /* 代码块不跨页断开 */ pre, code { page-break-inside: avoid; white-space: pre-wrap; word-wrap: break-word; } /* 表格跨页时重复表头 */ table { page-break-inside: auto; } thead { display: table-header-group; }逻辑说明page里的bottom-center和top-right是 CSS Paged Media 的页边距框counter(page)自动生成页码string(doctitle)把当前页所属的 h1 标题提取到页眉。page-break-before: always让每个一级标题从新页开始这是技术手册的常见排版要求。参数说明white-space: pre-wrap让代码块保留换行但允许自动折行避免长行溢出页面。page-break-inside: avoid防止代码块被分页截断但如果代码块本身超过一页这个属性会失效需要手动拆分。3.3 执行转换并验证书签层级CSS 准备好后用一行 Python 调用 WeasyPrint 完成转换。from weasyprint import HTML, CSS # 读取合并后的 HTML 和自定义 CSS HTML(./merged.html).write_pdf( ./output.pdf, stylesheets[CSS(./print.css)] ) print(转换完成检查 output.pdf 的书签层级)逻辑说明write_pdf的stylesheets参数接受一个 CSS 对象列表会覆盖 HTML 内联样式。转换完成后用 PDF 阅读器打开检查左侧书签面板是否按 h1/h2/h3 正确嵌套。如果书签全是平的说明合并 HTML 时标题标签用错了比如全用了h1或者用了div加粗代替。参数说明如果 HTML 里有外部图片链接WeasyPrint 默认会尝试下载离线环境会卡住。加base_url./chm_extracted/让相对路径图片能本地找到。如果图片还是丢检查解包时图片目录是否完整。4. 中文乱码与字体嵌入最容易被忽略的翻车点转出来的 PDF 打开一看中文全是方块或者问号——这是 CHM 转 PDF 最高频的翻车现场。原因通常有两个HTML 编码没统一或者 PDF 生成时没嵌入中文字体。4.1 编码统一从 GBK 到 UTF-8 的批量转换老 CHM 里的 HTML 大多是 GBK 编码合并时如果没转成 UTF-8WeasyPrint 会按默认编码解析中文直接乱码。上面的合并脚本已经用encodinggbk读取但输出时写的是 UTF-8这一步是关键。如果解包后的 HTML 本身编码混杂用chardet检测后再转。import chardet def detect_encoding(file_path): with open(file_path, rb) as f: raw f.read() result chardet.detect(raw) return result[encoding] # 对每个 htm 文件检测编码后再读取 encoding detect_encoding(./chm_extracted/page1.htm) print(f检测到编码: {encoding})逻辑说明chardet.detect返回一个字典encoding字段是推测的编码格式。对于中文文档常见结果是GB2312、GBK或UTF-8。拿到编码后用对应编码读取再以 UTF-8 写入就能统一。参数说明chardet对短文件检测可能不准建议对多个文件采样后取多数结果。如果检测结果是None说明文件可能是二进制或空文件跳过即可。4.2 字体嵌入让 PDF 在任何设备上都不乱码即使 HTML 编码正确如果生成 PDF 时没嵌入中文字体在没装对应字体的设备上打开依然会乱码。WeasyPrint 默认会嵌入字体但前提是系统里有中文字体。Linux 服务器通常没装中文字体需要手动指定。/* 在 print.css 中指定中文字体 */ body { font-family: Noto Sans CJK SC, WenQuanYi Micro Hei, sans-serif; }逻辑说明font-family按顺序查找第一个找不到就用第二个。Noto Sans CJK SC是 Google 的开源中文字体WenQuanYi Micro Hei是文泉驿微米黑两者在 Linux 上都能通过包管理器安装。WeasyPrint 会把实际使用的字体子集嵌入 PDF确保跨设备显示一致。参数说明如果系统里没有这些字体先装sudo apt install fonts-noto-cjk fonts-wqy-microhei。装完用fc-list :langzh确认中文字体已注册。如果 PDF 里中文还是方块检查 CSS 里font-family是否被其他样式覆盖。5. 避坑与排查CHM 转 PDF 的五个血泪教训这一章记录我实际踩过的坑每条按“现象 → 原因 → 解决”写方便你对照排查。5.1 目录书签全是平的没有层级现象PDF 左侧书签面板里所有条目都在同一级点进去没有嵌套。原因合并 HTML 时所有章节标题都用了h1或者原始 CHM 的.hhc里层级信息在解包时丢了。解决检查.hhc文件里的object嵌套关系ul的嵌套层数对应标题级别。如果.hhc本身是平的只能靠 HTML 里的h标签手动重建层级。我一般写个脚本根据.hhc的缩进深度给标题分配 h1/h2/h3。5.2 代码块被拦腰截断跨页后内容丢失现象PDF 里代码块在页面底部被切断下一页直接跳到新章节中间几行代码不见了。原因CSS 里没设page-break-inside: avoid或者代码块本身超过一页高度。解决给pre和code加page-break-inside: avoid。如果代码块确实超过一页手动在合适位置插入分页符或者把长代码块拆成多个短块。WeasyPrint 对超长元素的处理是直接截断不会自动续页这点要特别注意。5.3 图片全部裂图路径找不到现象PDF 里所有图片位置显示裂图图标或空白。原因合并 HTML 时图片的相对路径基于原始 .htm 文件位置合并后基准路径变了。解决合并时把img src...的相对路径改成基于合并后 HTML 的路径或者用base_url参数指定图片根目录。更稳妥的做法是把所有图片转成 base64 内嵌到 HTML 里彻底消除路径依赖。import base64 def img_to_base64(img_path): with open(img_path, rb) as f: data base64.b64encode(f.read()).decode() ext img_path.split(.)[-1] return fdata:image/{ext};base64,{data}逻辑说明base64.b64encode把图片二进制转成字符串拼成 data URI 格式直接塞进src属性。这样 HTML 自包含不依赖外部文件。参数说明base64 会让 HTML 体积增大约 33%如果图片很多很大合并后的 HTML 可能上百 MBWeasyPrint 处理会变慢。建议只对小图标用 base64大图还是用本地路径加base_url。5.4 页码从第一页就开始封面也被编号现象PDF 封面页底部显示“1”目录页显示“2”正文从“3”开始。原因CSS 里没排除封面和目录页的页码。解决用page :first排除第一页或者给封面和目录单独定义page规则设bottom-center { content: none; }。更精细的做法是用命名页page cover { ... }在 HTML 里给封面元素加page: cover。5.5 转换速度极慢几百页文档跑十几分钟现象WeasyPrint 处理 300 页以上的 CHM 时CPU 跑满进度条几乎不动。原因WeasyPrint 是纯 Python 实现对复杂 CSS 和大量图片的渲染效率不高。如果 HTML 里嵌了大量 base64 图片解析开销更大。解决分章节转每章生成独立 PDF 再用pypdf合并。或者换 wkhtmltopdf它基于 WebKit渲染速度快很多但书签生成需要额外配置。我一般对 200 页以内的用 WeasyPrint超过 200 页换 wkhtmltopdf 加--outline参数。6. 进阶技巧批量转换与自动化校验单份 CHM 转 PDF 跑通后下一步是批量处理。我手里经常有几十份 CHM 要归档手动一个个转不现实。下面这套脚本做三件事遍历目录下所有 CHM、自动解包合并转换、校验输出 PDF 的页数和书签数。import os import subprocess from weasyprint import HTML, CSS def batch_convert(chm_dir, output_dir): os.makedirs(output_dir, exist_okTrue) for file in os.listdir(chm_dir): if not file.endswith(.chm): continue base_name os.path.splitext(file)[0] work_dir os.path.join(output_dir, base_name) os.makedirs(work_dir, exist_okTrue) # 解包 subprocess.run([7z, x, os.path.join(chm_dir, file), f-o{work_dir}, -y], checkTrue) # 查找 .hhc 文件 hhc_files [f for f in os.listdir(work_dir) if f.endswith(.hhc)] if not hhc_files: print(f[跳过] {file} 没有 .hhc无法生成书签) continue # 合并与转换复用前面的函数 order parse_hhc(os.path.join(work_dir, hhc_files[0])) merged_path os.path.join(work_dir, merged.html) merge_html(work_dir, order, merged_path) pdf_path os.path.join(output_dir, f{base_name}.pdf) HTML(merged_path).write_pdf(pdf_path, stylesheets[CSS(./print.css)]) # 校验用 pypdf 读取页数和书签数 from pypdf import PdfReader reader PdfReader(pdf_path) outline reader.outline print(f[完成] {file} - {len(reader.pages)} 页, {len(outline)} 个书签) batch_convert(./chm_files, ./pdf_output)逻辑说明subprocess.run调用 7z 解包checkTrue确保解包失败时抛异常而不是静默跳过。pypdf读取生成的 PDFreader.pages是页数reader.outline是书签列表。如果书签数为 0说明 CSS 里的标题标签没生效需要回头检查合并 HTML 的标题层级。参数说明-y是 7z 的覆盖确认参数批量处理时避免卡在交互提示。pypdf需要单独安装pip install pypdf。如果 CHM 文件名有中文subprocess在 Windows 上可能乱码建议用subprocess.run(..., encodingutf-8)显式指定。校验环节我一般会加一条规则如果 PDF 页数少于 CHM 解包后 .htm 文件数的 80%说明有页面丢失需要人工检查。这个阈值不是绝对的但能筛出大部分异常。最后说个习惯我每次转完一批 CHM会随机抽三份用不同阅读器打开——系统自带的、浏览器内置的、第三方 PDF 工具——确认书签、中文、图片都正常。因为不同阅读器对 PDF 书签编码的处理有差异曾经遇到过在某个阅读器里书签正常、换一个就全乱码的情况根因是书签标题的编码没统一成 UTF-16。这个坑没有捷径只能多开几个阅读器验证。希望帮到你。本文还有配套的精品资源点击获取