news 2026/10/15 14:01:38

CHM转PDF保留书签目录:Python解析.hhc与wkhtmltopdf实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CHM转PDF保留书签目录:Python解析.hhc与wkhtmltopdf实战

简介: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 --version

Python 侧只需要两个库: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 entries

recursive=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]把锚点剥掉。这个细节在解析阶段处理掉,后面就省心了。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 14:00:59

Java对象排序:Comparable与Comparator深度解析

1. 理解比较机制的核心价值在Java开发中&#xff0c;对象排序是高频操作场景。我曾参与过一个电商价格比对系统开发&#xff0c;需要处理来自200多个供应商的百万级商品数据排序。当时在选用Comparable还是Comparator接口时踩过不少坑&#xff0c;今天就把这些实战经验系统梳理…

作者头像 李华
网站建设 2026/10/15 14:00:55

SQLite FTS5上下文感知搜索:用BM25实现多角色个性化检索

1. 项目概述&#xff1a;一个被严重误读的“context-mode”到底是什么&#xff1f;最近在多个技术社区、开源项目讨论区甚至企业内部知识库中&#xff0c;“context-mode”这个词高频出现&#xff0c;但几乎没人能说清楚它具体指代什么。它既不是SQLite的官方术语&#xff0c;也…

作者头像 李华
网站建设 2026/10/15 14:00:48

DEAN双情感模型:从情感维度提升社交媒体假新闻检测

2020年前后我读arXiv上的假新闻检测论文&#xff0c;大多数都淹没在回忆里了&#xff0c;但有一篇题目让我停留了很久——「DEAN: Learning Dual Emotion for Fake News Detection on Social Media」。早期做舆情分析的人都有一个共同感受&#xff1a;搞假新闻检测的团队实在太…

作者头像 李华
网站建设 2026/10/15 14:00:10

Python读取.ttf字体文件:fontTools解析cmap与name表实战

1. 字体文件不只是"文件"&#xff0c;它是一套可编程的矢量图形数据库很多人第一次接触.ttf文件&#xff0c;是因为做海报、写文档时想换个好看的中文字体&#xff0c;或者做 PPT 时被要求"标题用某某体"。但当你开始用 Python 去读取它&#xff0c;你会发…

作者头像 李华
网站建设 2026/10/15 14:00:01

toolbox_calib相机标定实战:从原理到避坑的完整指南

简介&#xff1a;toolbox_calib 是面向 MATLAB 用户的相机标定工具箱&#xff0c;由 Joan Bouguet 开发&#xff0c;主要解决计算机视觉中相机内参、外参求解这一基础问题&#xff0c;适合从事三维重建、立体视觉与机器人视觉的开发者及研究者使用。资源包共 188 个文件&#x…

作者头像 李华