简介:CHM2PDF是一款面向技术文档阅读者与资料整理者的格式转换工具,专门解决CHM帮助文件在非Windows平台阅读不便、分享受限的问题。CHM格式虽体积小、便于分发,但阅读体验和跨平台兼容性欠佳,而PDF作为开放标准能保留原始布局、字体与图像,并支持书签、注释与加密,因此将CHM转为PDF成为不少用户的实际需求。本资源包共11个文件,以exe可执行程序、dll动态链接库和txt说明文档为主,另含htm、chm与url等辅助文件,压缩包约1.86MB,体积轻巧便于携带。其中主程序负责转换操作,dll组件支撑PDF生成与HTML解析,说明文档则提供安装与使用指引。转换后可保留原CHM的章节结构、内链、文本、图像与表格,适合需要批量处理技术手册、电子书并跨设备阅读的用户。目前已有715人学习下载,可作为CHM转PDF的实用参考方案。
1. CHM 转 PDF 这件事,卡住你的往往不是转换本身
手里有一份 CHM 帮助文档,想把它变成 PDF 存档、打印或者塞进平板里看,搜到最多的方案是「在线转换」和「装个阅读器另存为」。真动手才发现:在线站点要么限制页数,要么把目录结构压平成一坨;阅读器打印出来的 PDF 没有书签,几百页翻起来全靠滚轮。CHM2PDF 这类工具要解决的核心问题,其实不是「把 HTML 渲染成 PDF」这一步,而是在转换过程中保住 CHM 原有的目录树、内部跳转和层级关系。CHM 本质是一个压缩包,里面装着 HTML、CSS、图片和一个.hhc目录文件,转换的难点在于解析这个目录结构并映射到 PDF 的书签上。这篇文章面向需要批量归档技术文档、做离线资料库的从业者,从 CHM 内部结构讲起,一路落到可复现的转换脚本和参数调优。
2. 拆开 CHM:先搞懂它到底装了什么
2.1 CHM 不是单一文件,是一个带索引的压缩容器
很多人把 CHM 当成一种「文档格式」,其实它更接近一个打包好的网站。用7z或者hh.exe -decompile就能把它拆开,你会看到一堆.htm、.html、图片资源,以及几个关键的索引文件。理解这几个文件的作用,后面写转换脚本才不会瞎猜。
| 文件 | 作用 | 转换时是否关键 |
|---|---|---|
.hhc | 目录树定义,决定左侧导航结构 | 关键,决定 PDF 书签 |
.hhk | 索引关键字,用于搜索 | 一般忽略 |
.hhp | 工程文件,记录编译参数 | 参考用 |
.htm/.html | 正文内容 | 关键,决定 PDF 正文 |
.css | 样式表 | 关键,决定排版 |
.gif/.png/.jpg | 图片资源 | 关键,缺失会留白 |
.hhc文件是 HTML 格式的,里面用<UL><LI>和<OBJECT>标签描述层级。一个典型的片段长这样:
<UL> <LI><OBJECT type="text/sitemap"> <param name="Name" value="第一章 概述"> <param name="Local" value="chapter1.htm"> </OBJECT> <UL> <LI><OBJECT type="text/sitemap"> <param name="Name" value="1.1 背景"> <param name="Local" value="chapter1_1.htm"> </OBJECT> </UL> </UL>嵌套的<UL>就是层级,Name是显示标题,Local是目标文件。转换脚本要做的就是递归解析这棵树,生成「标题 → 文件路径 → 层级深度」的映射表,最后交给 PDF 生成库去写书签。
2.2 为什么直接打印网页会丢书签
用浏览器打开 CHM 解压后的index.htm,再按 Ctrl+P 打印成 PDF,这是最省事的做法,但结果通常让人想摔键盘。原因有三层:第一,浏览器打印只认当前页面的 DOM,.hhc里的目录树它根本不读;第二,CHM 里的内部链接是相对路径,打印时不会自动展开成完整文档;第三,很多 CHM 的 CSS 里写了@media print规则,打印时会把导航栏直接隐藏,你连目录都看不到。
所以正确的路径是:先解析.hhc拿到结构,再按结构顺序渲染每个 HTML,最后用支持书签的 PDF 库合成。这条路径比「打印」麻烦,但它是唯一能保住目录树的做法。
2.3 工具选型:wkhtmltopdf、WeasyPrint 还是无头浏览器
落到具体工具,常见的有三类,各有适用边界。
wkhtmltopdf基于老版 WebKit,对 CSS3 支持一般,但胜在命令行参数丰富,--outline和--outline-depth能直接根据 HTML 的h1~h6生成书签,适合结构规整的文档。缺点是遇到 flex 布局和 web font 容易翻车。
WeasyPrint是纯 Python 实现,对 CSS Paged Media 支持最好,能精确控制分页、页眉页脚,但渲染复杂页面时速度偏慢,且对 JavaScript 完全不执行。
无头浏览器方案(Playwright、Puppeteer)渲染最接近真实浏览器,JS 也能跑,但生成书签需要自己调 CDP 接口,代码量大。
我的选择是:结构规整、无 JS 的 CHM 用 wkhtmltopdf 快速出活;排版复杂、需要精细分页的用 WeasyPrint;页面依赖 JS 渲染的才上无头浏览器。下面以 wkhtmltopdf 为主线,因为它对「CHM 转 PDF」这个场景的性价比最高。
3. 用 Python 把 CHM 拆解并转成带书签的 PDF
3.1 环境准备与依赖安装
先确认系统里有7z和wkhtmltopdf,这两个是外部命令,Python 只做调度和解析。
# 检查外部工具是否就绪 7z i > /dev/null 2>&1 && echo "7z ok" || echo "7z missing" wkhtmltopdf --versionPython 侧只需要两个库:beautifulsoup4解析.hhc,lxml做解析后端加速。
pip install beautifulsoup4 lxml提示:wkhtmltopdf 在部分 Linux 发行版上需要额外装
libjpeg和fontconfig,否则会报cannot connect to X server或字体缺失。用wkhtmltopdf --version能正常输出版本号,基本就没问题。
3.2 解包 CHM 并定位目录文件
第一步是把 CHM 拆成散文件,同时找到.hhc。不同 CHM 的目录文件名不固定,有的是toc.hhc,有的是和工程同名的.hhc,所以要用通配匹配。
import subprocess import pathlib import shutil def unpack_chm(chm_path: str, out_dir: str) -> pathlib.Path: """解包 CHM 到指定目录,返回解包根路径""" out = pathlib.Path(out_dir) if out.exists(): shutil.rmtree(out) # 清掉旧内容,避免残留文件干扰 out.mkdir(parents=True) # 7z 对 CHM 的解包支持稳定,x 表示保留目录结构 subprocess.run( ["7z", "x", chm_path, f"-o{out_dir}", "-y"], check=True, stdout=subprocess.DEVNULL, ) return out def find_hhc(root: pathlib.Path) -> pathlib.Path: """在解包目录里找 .hhc 文件,取第一个匹配""" candidates = list(root.rglob("*.hhc")) if not candidates: raise FileNotFoundError("未找到 .hhc 目录文件,该 CHM 可能没有目录树") # 优先选体积最大的,通常是最完整的目录 return max(candidates, key=lambda p: p.stat().st_size)unpack_chm里先删旧目录是个容易忽略的细节:如果上一次转换失败留下半截文件,下一次解析可能读到脏数据。find_hhc用体积最大来筛选,是因为有些 CHM 会带多个.hhc,主目录通常内容最全。
3.3 解析 .hhc 生成层级映射表
拿到.hhc后,用 BeautifulSoup 递归遍历<UL>结构。这里的关键是用递归深度记录层级,因为.hhc的嵌套直接对应书签的缩进级别。
from bs4 import BeautifulSoup def parse_hhc(hhc_path: pathlib.Path) -> list[dict]: """解析 .hhc,返回 [{title, local, depth}, ...] 的扁平列表""" soup = BeautifulSoup(hhc_path.read_text(encoding="utf-8", errors="ignore"), "lxml") entries = [] def walk(ul_tag, depth): # 只处理当前 UL 的直接子 LI,避免重复遍历嵌套内容 for li in ul_tag.find_all("li", recursive=False): obj = li.find("object") if obj: name_param = obj.find("param", attrs={"name": "Name"}) local_param = obj.find("param", attrs={"name": "Local"}) if name_param and local_param: entries.append({ "title": name_param.get("value", "").strip(), "local": local_param.get("value", "").strip(), "depth": depth, }) # 如果这个 LI 下面还有 UL,说明有子层级 child_ul = li.find("ul", recursive=False) if child_ul: walk(child_ul, depth + 1) root_ul = soup.find("ul") if root_ul: walk(root_ul, 0) return entriesrecursive=False这个参数是血泪经验:不加的话,find_all("li")会把所有后代li都抓出来,导致同一个条目被重复记录多次,书签里出现大量重复项。depth从 0 开始,后面写书签时直接映射到 PDF 的层级。
3.4 按目录顺序拼接 HTML 并生成书签
有了映射表,接下来按顺序把每个 HTML 转成中间 PDF,再用pdfunite或pypdf合并。但更省事的做法是让 wkhtmltopdf 直接处理一个「总目录页」,通过--outline自动生成书签。不过 CHM 的 HTML 里标题层级未必规范,所以更可控的方式是手动构造书签。
import subprocess from pypdf import PdfWriter, PdfReader def html_to_pdf(html_path: pathlib.Path, pdf_path: pathlib.Path, base_dir: pathlib.Path): """单个 HTML 转 PDF,base_dir 用于解析相对资源路径""" subprocess.run([ "wkhtmltopdf", "--enable-local-file-access", # 允许读取本地图片和 CSS "--encoding", "utf-8", "--page-size", "A4", "--margin-top", "15mm", "--margin-bottom", "15mm", "--margin-left", "12mm", "--margin-right", "12mm", str(html_path), str(pdf_path), ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def build_pdf(entries, root: pathlib.Path, final_pdf: str): """按 entries 顺序生成 PDF 并写入书签""" writer = PdfWriter() page_offset = 0 bookmark_map = [] # (title, depth, start_page) for entry in entries: local = entry["local"].replace("\\", "/") # CHM 里可能是反斜杠 html_file = root / local if not html_file.exists(): print(f"[跳过] 文件不存在: {local}") continue tmp_pdf = root / f"__tmp_{len(bookmark_map)}.pdf" html_to_pdf(html_file, tmp_pdf, root) reader = PdfReader(str(tmp_pdf)) start_page = page_offset for page in reader.pages: writer.add_page(page) page_offset += len(reader.pages) bookmark_map.append((entry["title"], entry["depth"], start_page)) tmp_pdf.unlink() # 及时清理临时文件 # 写书签,parent 栈用来处理层级 parent_stack = [] for title, depth, page in bookmark_map: while len(parent_stack) > depth: parent_stack.pop() parent = parent_stack[-1] if parent_stack else None new_bm = writer.add_outline_item(title, page, parent=parent) if len(parent_stack) == depth: parent_stack.append(new_bm) else: parent_stack[depth] = new_bm with open(final_pdf, "wb") as f: writer.write(f)这段代码有几个参数值得说清楚。--enable-local-file-access是必须的,否则 wkhtmltopdf 会拒绝加载本地图片,PDF 里全是裂图。--encoding utf-8防止中文标题乱码。页边距设成 15mm/12mm 是打印友好的经验值,太小会顶到纸边,太大浪费版面。
书签部分用了一个parent_stack来维护层级关系:当当前条目深度小于栈长度时,弹出多余层级;add_outline_item的parent参数指向父书签,这样 PDF 阅读器里就能展开折叠。page_offset记录每个 HTML 起始页,因为合并后页码是连续的。
3.5 合并后的验证与页码校准
生成完别急着交付,先验证三件事:书签数量是否和.hhc条目数一致、每个书签跳转的页码是否正确、中文标题有没有乱码。
def verify_pdf(pdf_path: str, expected_count: int): reader = PdfReader(pdf_path) outlines = reader.outline # 递归统计书签总数 def count(items): n = 0 for it in items: if isinstance(it, list): n += count(it) else: n += 1 return n actual = count(outlines) print(f"预期书签 {expected_count} 个,实际 {actual} 个") print(f"总页数: {len(reader.pages)}") if actual < expected_count: print("警告:部分条目未生成书签,检查 .hhc 解析是否完整")如果发现书签页码偏移,通常是某个 HTML 渲染出了空白页,导致page_offset累加多了。排查方法是单独打开那个中间 PDF 看页数,确认是不是 CSS 里page-break-before导致的。
4. 转换过程中最容易翻车的五个地方
4.1 中文文件名和路径导致解包失败
现象:7z x报错No files to process,或者解包出来的文件名全是乱码。
原因:CHM 内部的编码可能是 GBK,而 7z 默认按系统编码处理,跨平台时对不上。
解决:解包时加-mcp=936指定代码页,或者先用hh.exe -decompile在 Windows 上解一遍再拷到 Linux 处理。Python 侧读.hhc时统一用errors="ignore"兜底。
4.2 图片路径大小写不一致
现象:PDF 里图片位置全是空白框,但 HTML 单独打开正常。
原因:CHM 在 Windows 上不区分大小写,Images/logo.png和images/LOGO.PNG都能找到;解包到 Linux 后文件系统区分大小写,wkhtmltopdf 就找不到了。
解决:解包后统一把资源目录和文件名转小写,同时用正则把 HTML 里的src也转小写。或者更稳妥,写个映射表做模糊匹配。
4.3 书签层级错乱或全部平铺
现象:PDF 书签里所有条目都在同一级,展开后没有嵌套。
原因:.hhc解析时depth计算错误,或者add_outline_item的parent传了None。
解决:打印entries列表检查depth值是否符合预期。常见错误是walk函数里对子UL的递归条件写成了find("ul")而不是find("ul", recursive=False),导致层级被跳过。
4.4 生成的 PDF 体积暴涨
现象:原始 CHM 只有 5MB,转出来 PDF 有 80MB。
原因:wkhtmltopdf 默认不压缩图片,且每个 HTML 单独转 PDF 时重复嵌入了相同的字体。
解决:加--image-quality 60降低图片质量,用--disable-smart-shrinking关闭智能缩放避免重复渲染。合并后用pypdf的compress_content_streams()再压一遍。
4.5 内部锚点跳转失效
现象:PDF 里点击目录条目能跳转,但正文里#section2这种锚点链接点了没反应。
原因:wkhtmltopdf 生成的是独立 PDF,跨文件锚点在合并后丢失了目标。
解决:如果锚点跳转是刚需,改用 WeasyPrint 的anchor机制,或者在合并后用pypdf的add_link手动重建链接。纯归档场景可以接受这个损失,毕竟书签已经能覆盖大部分导航需求。
5. 批量转换与增量更新的实用技巧
单文件转换跑通后,真正的工作量在于批量处理和后续维护。我一般会写一个清单文件记录每个 CHM 的转换状态,避免重复劳动。
import json import hashlib from pathlib import Path def file_fingerprint(path: Path) -> str: """用文件大小 + 修改时间做轻量指纹,比全文件哈希快""" stat = path.stat() raw = f"{stat.st_size}-{stat.st_mtime}".encode() return hashlib.md5(raw).hexdigest() def batch_convert(chm_dir: str, out_dir: str, manifest_path: str = "manifest.json"): chm_dir = Path(chm_dir) out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) manifest = {} if Path(manifest_path).exists(): manifest = json.loads(Path(manifest_path).read_text()) for chm in chm_dir.glob("*.chm"): fp = file_fingerprint(chm) if manifest.get(chm.name) == fp: print(f"[跳过] {chm.name} 未变化") continue print(f"[处理] {chm.name}") try: root = unpack_chm(str(chm), str(out_dir / chm.stem)) hhc = find_hhc(root) entries = parse_hhc(hhc) final = out_dir / f"{chm.stem}.pdf" build_pdf(entries, root, str(final)) manifest[chm.name] = fp print(f"[完成] {final},共 {len(entries)} 个书签") except Exception as e: print(f"[失败] {chm.name}: {e}") Path(manifest_path).write_text(json.dumps(manifest, ensure_ascii=False, indent=2))指纹用「大小 + 修改时间」而不是全文件 MD5,是因为 CHM 动辄几十兆,全量哈希在批量场景下太慢。这个组合在文件被替换时几乎必然变化,足够可靠。
另一个实用技巧是保留解包目录。很多人转完就把中间文件删了,结果发现某个 PDF 书签有问题想重调参数,又得重新解包。我的习惯是把解包目录留在out_dir下,只在确认最终 PDF 无误后才清理。磁盘换时间,在文档归档这种低频场景里是划算的。
如果 CHM 数量上百,还可以把build_pdf里的单文件转换改成并行。用concurrent.futures.ProcessPoolExecutor开 4 个进程,每个进程处理一个 CHM,整体耗时能压到原来的三分之一左右。但要注意 wkhtmltopdf 本身会起子进程,并行度别开太高,否则内存容易爆。
最后说一个我踩过的坑:有些 CHM 的.hhc里Local字段带#anchor,比如chapter1.htm#intro。直接拼路径会找不到文件,得先用split("#")[0]把锚点剥掉。这个细节在解析阶段处理掉,后面就省心了。希望帮到你。
本文还有配套的精品资源,点击获取