简介:《BOOK OF WISDOM 2》(智慧之书第二册)是一份围绕“现实本质与心智关系”展开的英文电子文档,适合对意识哲学、形而上思考以及光与物质交互关系感兴趣的读者作为扩展研读材料。文档并非循序渐进的技术教程,而是采用短句式观点输出,围绕“宇宙是心智的产物”“现实是电磁全息投影”“光波宇宙”等核心命题展开,并结合《创世纪》中的“要有光”与沃尔特·罗素的相关论述做阐释。其中以视觉感知为例,说明光被吸入视网膜后经大脑视觉皮层处理,人对世界的观察其实经过心智加工;又借“万有单一性原理”强调一切创造物本质上都由光构成,并讨论心智如何像频率调谐器一样左右我们对现实的接收与表现,比如不同频率对应不同颜色,人的个体属性也被视为某种色彩的体现。资源以 PDF 格式封装,仅含 1 个完整文档,压缩包大小约 115.35MB,适合直接阅读原文,也能配合个人笔记做对照摘录。目前已有 630 人学习/下载,对想快速接触这组非主流哲学命题、构建关于意识与实相关系的知识框架,或在主题写作中寻找参照材料的读者都具有一定的参考价值。
1. 一本「BOOK OF WISDOM 2-Emule.pdf」式的技术手册,真正难的从来不是打开它
拿到「BOOK OF WISDOM 2-Emule.pdf」这种文件,第一反应通常是"PDF 而已,直接把文字抠出来呗"。真上手你会发现,这类技术手册型 PDF 里塞满了多级标题、跨页表格、代码块、页眉页脚,普通文本提取拿到的只是一坨顺序错乱、空格被压平的字符串。与其说这是 PDF 解析问题,不如说是文档工程问题:你要先判断它能不能抽文字,再决定用什么库、按什么策略抽,最后把抽出来的内容还原成章节结构,做成可检索的索引。对正在做文档入库、翻译辅助、语料清洗的开发者来说,这套流程是避不开的,新手可以照着命令一步步跑,熟手可以直接看参数和踩坑点。
2. 下刀前先验伤:判断 PDF 是文本版、扫描版还是混合版
2.1 三种形态决定三条路
PDF 只是外壳,内部结构差别极大。文本版 PDF 带文字层,可以直接用解析库抽取,运气好连字号、颜色、坐标都保留着;扫描版 PDF 本质是整页图片,解析库抽出来是空字符串,必须先做 OCR;混合版则是在图片上叠了一层透明文字,肉眼看到的和抽出来的对不上,这类最阴,看起来能抽,抽完全是错位。
所以我拿到文件的第一步不是急着装库,而是先跑一个探伤脚本,按页统计文本长度,把 PDF 翻个底朝天。这一步最多花两分钟,却能避开后面一整天的弯路了。
import fitz doc = fitz.open("BOOK OF WISDOM 2-Emule.pdf") for pno in range(doc.page_count): raw = doc[pno].get_text("text").strip() print(f"page {pno + 1:>3d}: chars={len(raw):>6d}") doc.close()这段代码用 PyMuPDF(即fitz)逐页取纯文本,只打印字符数,不做任何清洗。判断标准很简单:如果绝大多数页面的字符数在几百到几千,说明是文本版;如果某几页字符数接近 0 但页面上明显有内容,这几页是扫描图;如果所有页字符数都接近 0,整份文件就是扫描版。
提示:
get_text("text")拿到的是没有任何格式信息的纯文本,但它最快,适合做探伤。字符数只是第一道筛子,如果某页抽出来全是乱码符号,字符数照样很大,后面要再结合内容判断,不能只看数字。
2.2 解析库选型:PyMuPDF、pdfplumber 与 pdfminer.six 怎么分工
市面上能解析 PDF 的库不少,但真正适合技术手册型文档处理的主流派系就三个,各有各的脾气。PyMuPDF 输出速度快,能拿到每个文本块的坐标、字号、字体名,是做结构还原的主力;pdfplumber 的表格提取精细,能设置横竖线策略,适合处理跨页表格;pdfminer.six 更底层,输出的是 LTTextBox 这类对象,API 繁琐但遇上疑难杂症时最可能兜住。三者不是竞争关系,我一般是按层混用。
| 工具 | 擅长 | 不适合 | 适用场景 |
|---|---|---|---|
| PyMuPDF(fitz) | 文本快取、坐标字号提取、页面渲染 | 复杂表格结构还原 | 正文抽取、标题识别、切块 |
| pdfplumber | 表格边框与单元格识别 | 大文件时速度慢、内存高 | 表格区域定位、表格内容提取 |
| pdfminer.six | 底层版面对象输出 | 日常脚本开发效率低 | 前两者都失败的疑难页面 |
| OCR 工具(PaddleOCR / Tesseract) | 无文本层页面的文字识别 | 对清晰度敏感,速度慢 | 扫描版 PDF 唯一的出路 |
选型结论收拢一下:正文和章节结构用 PyMuPDF,表格用 pdfplumber,扫描页用 OCR,遇到解析库集体翻车的页面再掏 pdfminer.six 单页诊断。顺序一定不要搞反,很多人一上来就把整本扫描版丢给 OCR,结果速度慢、错误率高,其实先验伤就能避免。
3. 用 PyMuPDF 跑通第一版抽取:按坐标和字号把章节结构捡回来
3.1 最小抽取脚本:把整册文本连同字号坐标一起倒出来
验完伤,确认是文本版 PDF 后,第一版抽取我用 PyMuPDF 的dict模式来完成。它的输出结构从 page 到 block,再到 line,最后到 span,每一层都带坐标信息,span 里还能看到字体名和字号。这个粒度对后续章节识别是最合适的,用纯文本模式拿不到这些信息。
下面这段脚本会把每一行文字连同它的页码、起始 x 坐标、y 坐标和字号一起打印出来,相当于给整本文档做了一次带坐标的透视。不必一次跑完整个文件,可以先用前 20 页做试验,确认效果后再放开。
import fitz doc = fitz.open("BOOK OF WISDOM 2-Emule.pdf") flags = fitz.TEXTFLAGS_TEXT for pno in range(doc.page_count): page = doc[pno] d = page.get_text("dict", flags=flags) for block in d["blocks"]: if block["type"] != 0: # 跳过图片块 continue for line in block["lines"]: line_text = [] max_size = 0 min_x = min(span["bbox"][0] for span in line["spans"]) base_y = line["bbox"][3] for span in line["spans"]: if not span["text"].strip(): continue line_text.append(span["text"]) max_size = max(max_size, round(span["size"], 1)) if line_text: print(f"p{pno + 1:>3d} x={min_x:>6.1f} y={base_y:>7.1f} sz={max_size:>5.1f} " f"{''.join(line_text)}") doc.close()逻辑上分三步:先按页取dict结构;然后只处理文本块,跳过图片;最后把每一行里的多个 span 拼接成完整文本,同时记录这个行的最小 x 坐标、y 坐标和最大字号。
参数说明:flags=fitz.TEXTFLAGS_TEXT表示按默认文本模式提取,索引下标、图片信息不写入输出,这样速度更快,干扰也少。span["bbox"][0]是文本左边界,line["bbox"][3]是行的底边 y 坐标,这两个值后面重建缩进和定位标题时都会用到。
3.2 从字号和基线坐标里识别「小标题」
纯文本抽出来后,章节标题和正文混在一起,必须用字号把它们分开。常见做法是统计正文的主体字号,再按倍数关系圈定标题。比如正文是 10pt,那么 16pt 以上的基本是一级标题,13~15pt 是二级标题,10pt 但字体名带Bold的可能是条目标题。
我一般不会硬编码一个字号阈值,而是先跑一段统计脚本,把全书的字号分布打出来看。技术手册的正文常常在一个字号附近形成尖峰,标题字号就是尖峰周围的高位离群值。别小看这一步,不同排版软件的基准字号有差异,靠频率分布自适应比拍脑袋靠谱。
统计字号分布的代码本身不复杂:
from collections import Counter size_counter = Counter() for pno in range(doc.page_count): d = doc[pno].get_text("dict") for block in d["blocks"]: if block["type"] != 0: continue for line in block["lines"]: for span in line["spans"]: t = span["text"].strip() if not t: continue size_counter[round(span["size"], 1)] += len(t) for size, cnt in size_counter.most_common(10): print(f"size={size:>5.1f} chars={cnt:>7d}")这段会把字符数最多的前 10 个字号列出来,正文字号一定排在前几列。拿到最大众数的字号body_size后,再按规则切标题:字号大于等于body_size * 1.5的文本行视为一级标题,大于等于body_size * 1.2的视为二级标题,同时要求这一行文本长度不超过 30 个字,排除掉那些只是字号偏大的整段正文。
注意:字体名字段也值得做一次统计。某些手册里正文和标题的字号完全一样,只靠
Bold字体名区分,遇到这种情况就把字体名纳入标题判定条件里。我之前就因为只认字号不认字体名,把一整章的加粗段落当成了标题,切块结果惨不忍睹。
3.3 按标题切块,生成中间产物 JSON
标题识别出来后,下一步是把文档切成以标题为边界的块。这个过程我建议生成一个中间 JSON 文件,而不是直接奔着 Markdown 去。JSON 是中立格式,后面想做翻译、入库、检索都可以复用,中途发现标题判断错了改起来也容易。
import json blocks = [] current = None def flush(current): if current and current["lines"]: blocks.append(current) for pno in range(doc.page_count): d = doc[pno].get_text("dict") for block in d["blocks"]: if block["type"] != 0: continue for line in block["lines"]: text = "".join(span["text"] for span in line["spans"]).strip() if not text: continue size = max(span["size"] for span in line["spans"]) if is_headline(size, text): flush(current) current = {"title": text, "level": 1 if size >= body_size * 1.5 else 2, "lines": []} else: if current is None: current = {"title": "", "level": 0, "lines": []} current["lines"].append({"text": text, "x": line["bbox"][0], "y": line["bbox"][3]}) flush(current) with open("book_blocks.json", "w", encoding="utf-8") as f: json.dump(blocks, f, ensure_ascii=False, indent=2)这里的核心逻辑是:每遇到一个标题行,就把之前累积的内容块压入结果列表,然后另起新块;非标题行直接追加到当前块的lines里。flush函数负责把只有标题没有正文的空块过滤掉,避免末尾出现一批空壳标题。
切块时有个细节要留意:页末标题后面往往没有正文,跨页时正文在下一页,如果直接按标题即切,很容易产生大量只有一个标题的空块。解决办法是让切块只发生在书名页标题之后,或者等下一行确认是正文后再决定是否落块。上面代码里的flush实际上已经排除了空块,这是处理跨页标题的关键。
4. 表格和代码片段最容易翻车:pdfplumber 做保真提取
4.1 表格识别:有线表的显式策略与无线表的文本策略
正文抽取跑通后,重头戏在表格和代码片段上。技术手册里这两类元素最吃排版细节,而 PDF 解析库对它们的能力参差不齐。我先说表格。
pdfplumber 的默认表格策略是找线,即通过页面上的横线和竖线确定单元格边界。遇到带完整框线的表格,默认参数就够用,代码很简单:
import pdfplumber with pdfplumber.open("BOOK OF WISDOM 2-Emule.pdf") as pdf: page = pdf.pages[12] tables = page.find_tables() for i, tb in enumerate(tables): rows = tb.extract() for row in rows: cleaned = [cell.replace("\n", " ") if cell else "" for cell in row] print("| " + " | ".join(cleaned) + " |")find_tables()返回表格区域,extract()把每个单元格的文本取出来,cleaned那一行把单元格里的换行符替换成空格,避免打印出来的 Markdown 表格塌掉。
但很多手册里的表格并没有完整框线,尤其是跨页续表,只有横向线没有竖向线。这种情况下默认策略会漏列,必须切换到文本策略,让解析器根据文字在页面上的 x 坐标分布来自动判断列边界:
settings = { "vertical_strategy": "text", "horizontal_strategy": "text", "snap_tolerance": 3, "join_tolerance": 5, "text_tolerance": 3, } tables = page.find_tables(table_settings=settings)参数说明:snap_tolerance控制在多少像素内的线视为同一条线,太大会把相邻表格合并;join_tolerance控制线段之间多少距离内可以连接成完整的单元格边框;text_tolerance控制文字与线之间多少距离内算作归属于同一单元格。这三个值不是越大越好,改的时候一次只动一个,用 5 页数据做验证。
提示:如果表格区域里混入了页眉页脚的文字,先把页眉区域从页面里裁掉再提取。pdfplumber 支持
page.crop((x0, top, x1, bottom)),先裁剪后提取,比提取后再清洗省事得多。
4.2 代码块缩进重建:用左侧坐标换回空格
PDF 的文本层对代码块极不友好。排版时本来靠空格缩进表达的层级,在文本层里可能被替换成绝对坐标,抽出来全挤在左对齐。所以我的原则很简单:绝不信任文本层里的空格数量,只信任每个 span 的左侧 x 坐标。
重建缩进的思路是:统计一个代码块内所有行的 x 坐标分布,找出最靠左的大量重复坐标作为基准列,然后计算每行 x 坐标与基准列的差值,再把差值换算成空格数。换算比例从页面上已有的缩进间距里取众数,不硬编码。
from collections import Counter def rebuild_indent(block_lines, indent_px=8.0): xs = [int(round(line["x"] / 5) * 5) for line in block_lines if line["text"].strip()] if not xs: return [] base = Counter(xs).most_common(1)[0][0] output = [] for line in block_lines: if not line["text"].strip(): output.append("") continue gap = int((line["x"] - base) / indent_px) output.append(" " * max(0, gap) + line["text"]) return output这里的indent_px=8.0是经验值,表示代码区里每级缩进大约占 8 个像素。可靠做法是先对一个已知的代码块跑一次,统计行与行之间 x 坐标差值的众数,用这个众数反推indent_px。如果统计出来是 7.5,就填 7.5,不要硬凑整数。
int(round(line["x"] / 5) * 5)这行的目的是把 x 坐标做粗粒度对齐,消除细微抖动。PDF 中同一级缩进的行 x 坐标常有一两个像素的误差,直接拿原始值聚类容易把同一级拆成两级。
4.3 把抽取结果拼成干净的 Markdown 文档
文本、表格、代码块都还原之后,最后一步是把 JSON 中间产物拼成 Markdown。这一阶段我把解析逻辑和渲染逻辑分开:解析脚本负责产出结构化的块列表,渲染脚本只负责翻译成 Markdown,两者互不干扰。这样调表格参数时不需要重复跑全书抽取。
def to_markdown(blocks): lines = [] for item in blocks: if item.get("type") == "table": lines.append(table_to_md(item["data"])) continue if item.get("type") == "code": lines.append("```") lines.extend(item["lines"]) lines.append("```") continue if item.get("level") == 1: lines.append(f"## {item['title']}") elif item.get("level") == 2: lines.append(f"### {item['title']}") else: lines.extend(item["lines"]) lines.append("") return "\n".join(lines)表格转 Markdown 时有一个容易忽略的细节:单元格内容里如果含竖线|,必须转义成\|否则表格直接坏掉。代码块则要注意原文里是否已经存在三反引号,技术手册里偶尔会出现嵌入式的代码展示,遇到这种情况要把外层的围栏改成四个反引号。逻辑不复杂,但都是实际跑库时会踩到的点。
5. 文本抽取的 5 个典型翻车点:现象、原因与处理
5.1 页眉页脚混进正文
现象是抽取结果里每一页开头都有相同的书名字样,结尾都混入页码数字,切块后这些噪音跟着正文一起入库,检索时一搜书名能搜出几百条。
原因是 PDF 的正文流里本身就包含页眉页脚,它们和正文在文本层里没有区分标记。解决方法分两层:页码用正则^\d{1,3}$直接过滤,页眉则先统计全书高频行,凡是出现次数接近页数、且长度固定的文本行,直接判定为页眉剔除。注意页眉偶尔左右页交替出现,高频统计不能只看首行,要同时看行尾坐标相同的行。
5.2 中文内容抽出来是乱码,不是编码问题而是字体映射问题
现象是抽出来的中文变成 "锟斤拷" 或者一排问号,英文和数字却一切正常。
原因通常是 PDF 用自定义 CID 字体,字体里的 ToUnicode 映射表缺失或有误,解析库拿到字形编号但翻译不回 Unicode。这类问题换解析库偶尔能解决,因为 pdfminer.six 对字体的处理路径与 PyMuPDF 不同,但更多时候两者都翻车。
解决方法是先定位乱码集中在哪些页,然后只对这些页做局部 OCR。步骤是先用 PyMuPDF 把指定页渲染成高分辨率 PNG,再交给 OCR 识别,最后拼回原文。整个过程不复杂,但要注意渲染 dpi 至少 300,否则中文字形细节丢失,OCR 错误率会明显上升。
5.3 一页文本抽出来是空字符串
现象是探伤脚本显示第 20 页字符数为 0,但用阅读器打开这页明明有图表文字。
原因是这一页本质上是张整页图片,排版时直接以扫描图形态嵌入 PDF,没有文字层。这种情况在附录和技术插图较多的手册中很常见。处理办法是先渲染再 OCR,渲染时可以先做灰度化和对比度增强,把背景噪点压掉一层再识别。如果只有少数几页悬挂这个状态,不值得为它搭一整套 OCR 流程,单独跑这几页即可。
5.4 英文和数字被连字符拦腰截断
现象是文本里出现 "docu-ment"、"data-base" 这类被断行拆开的单词,后续做切块或检索时它们变成了两个词。
原因是英文排版中的音节断行,源 PDF 本来就在那个位置插入了连字符。解决方法是检测行尾的单个连字符,并判断下一行首字符是否是小写字母或数字,满足条件就把两行拼起来、去掉连字符。
但这里有个大坑:代码块里的减号、参数前面的负号、或者本就属于代码语法的连字符也会命中规则。我的做法是先识别出代码块区域,对代码块内部的连字符一律不处理,只处理正文行。脚本层面加一个状态判断即可,但这正是容易被忽略的地方。
5.5 代码区的空格和跳格被文本层压平
现象是代码块抽出来后全部左对齐,原本四级缩进的逻辑结构全丢了,甚至嵌套的if和print跑到了同一列。
原因是 PDF 文本层里的空格经由排版压缩后,以绝对坐标方式存在,解析器默认丢弃空格数量。解决方式在 4.2 已经写了,就是按左侧坐标重建缩进。这里补充一个判断技巧:先用肉眼对比原 PDF 的截图,确认每一级缩进的像素间距是稳定的,然后再让脚本按统计众数重排。如果原 PDF 的代码区域缩进间距不统一,重建出来的代码也是错的,这时候只能人工介入。
6. 进阶:给整册文档做一个可检索的本地索引并验证结果
6.1 轻量级校验脚本:抽完心里有底
切块完成之后,别急着入库,先跑一遍校验。校验的目标是确认抽取没有出现结构性丢块,比如某个章节的标题在但正文没了,或者两个标题之间出现大量空内容。
最轻量的校验方式是把原始 PDF 的目录页解析出来,取标题文本集合,再把重建 Markdown 里的##和###标题文本集合拿出来,用difflib.SequenceMatcher算相似度。低于阈值就说明标题级别判断有问题,回到第 3 章调字号倍数规则。
import difflib, re def extract_headings(md_text): return re.findall(r"^#{2,3}\s+(.+)$", md_text, flags=re.MULTILINE) md = open("book.md", encoding="utf-8").read() extracted = extract_headings(md) # toc_titles 来自原始 PDF 目录页抽取结果 ratio = difflib.SequenceMatcher(None, toc_titles, extracted).ratio() print(f"heading match ratio: {ratio:.2f}") if ratio < 0.85: print("check heading level rules")SequenceMatcher的比值在 0.85 以上基本可以安心,越低越要回头查标题识别规则。re.findall用MULTILINE模式从每行开头找##或###,这样标题和正文区分得很干净。
6.2 用 SQLite 建一个极简关键词索引
验证通过后再做检索索引,我用 SQLite 就够了,不需要引入额外服务。数据模型就两张表:一张存章节块,一张存分词后的词项与章节的映射关系。中文分词用jieba,英文直接按空格切。
import sqlite3, jieba conn = sqlite3.connect("book_index.db") conn.execute("CREATE TABLE IF NOT EXISTS sections(id INTEGER PRIMARY KEY, title TEXT, content TEXT)") conn.execute("CREATE TABLE IF NOT EXISTS terms(term TEXT, section_id INTEGER)") for sid, sec in enumerate(sections): conn.execute("INSERT INTO sections VALUES (?,?,?)", (sid, sec["title"], sec["content"])) tokens = set(jieba.lcut(sec["title"])) | set(jieba.lcut(sec["content"][:200])) for token in tokens: if len(token.strip()) > 1: conn.execute("INSERT INTO terms VALUES (?,?)", (token, sid)) conn.commit()这里的索引只取每节内容的前 200 字做分词,这让索引建立速度大幅提升,代价是长尾关键词覆盖不全。当作轻量检索够用,要做全文级别搜索还是得上更完整的方案。
索引建立后,查询一个关键词时先查terms表拿到候选章节 id,再去sections表取标题和摘要,按章节顺序展示结果。整个流程从探伤到索引,跑顺了也就几十行脚本的事。
我以前被"PDF 直接抽文本"这句话坑过不少次,后来养成了一个习惯:不管拿到什么文档,先建探伤脚本,再定解析策略,最后才写正式抽取程序。这个流程能复用到任何 PDF 处理项目上,也是我判断一个文档处理任务值不值得投入的标准——如果探伤下来全册都是文本版,半天能跑完全流程;如果扫描页占比超过三成,考虑值不值得为它投入 OCR 算力。希望帮到你。
本文还有配套的精品资源,点击获取