用Python解析PDF表格:从清华课程表到结构化数据
2026/9/17 14:49:10 网站建设 项目流程

简介:清华大学计算机科学与技术专业课程表.pdf整理自该校信息学院本科指导性教学计划,是一份针对计算机专业学生和考研备考者的课程规划参考。文件中仅1个PDF,却完整列出大一至大四春秋两季的必修课与选修课,包含思想道德修养、微积分、离散数学、程序设计基础、数据结构、操作系统、编译原理、计算机网络、计算机系统结构等核心课程,以及人工智能导论、数据库系统原理、模式识别、数据挖掘、嵌入式系统等方向课,并清楚标注每门课的学分、周学时、考试/考查方式和先修要求。资源整体仅96KB,下载后可方便打印或反复查阅,也便于离线随时对照。目前已有1146人浏览学习,适合用于制定大学学习计划、对比高校课程体系,也可为自学者梳理计算机核心知识提供一份清晰的路线图。

1. 一份课程表 PDF,为什么值得当成技术文档拆

“清华大学计算机科学与技术专业课程表.pdf”在搜索里出现的频率,远超一份普通课表的预期。多数人点进去是想看清华计算机本科到底上什么课——数据结构放在第几学期、有没有编译原理、AI 是不是必修。但对一线工程师来说,这份 PDF 的价值并不在“看个新鲜”,而在两件事:一是它是一份公开的、有权威性的培养方案,能反推出一个计算机专业学生从零到能干活需要经过哪些核心训练;二是它本身就是一个 PDF 表格解析的好样本——跨页、合并单元格、双栏排版、中文专业名词,几乎把 pdfplumber 和 camelot 会遇到的坑全踩一遍。

这篇文章就用“课程表 PDF”当载体,先拆它的课程体系结构,再给你一套能直接跑的 PDF 表格抽取方案,把课程表转成 CSV 和结构化 JSON。中间会讲清楚为什么某些参数必须这样设、某些输出为什么是乱的要换一条路,最后落到一个实用操作:把课程表变成你自己的学习路线图。过程里出现的清华课程名,只作为数据处理对象来用,能搜到、能下载、能复现。

2. 清华计算机课程表的培养方案结构:从通识到专业方向的四层递进

2.1 为什么先看课程结构,而不是先看课名

拿到一份课程表 PDF,第一反应往往是找“AI”“大数据”这类标题党关键词。但真正常规的计算机专业培养方案,是按“通识基础 → 数理基础 → 专业核心 → 专业方向/选修”四个层次铺开的。清华计算机的课表也遵循这个逻辑,不是把热门课堆在一起。你只有先理解这四层,才知道 PDF 里的表格该怎么按行解析——因为表格的行顺序,就是这个培养层次顺序。

课程表 PDF 里的数据特征也是按这个层次展开的:

  • 第 1 层是公共必修,政治、英语、体育,学分多、课号固定,行内容高度重复;
  • 第 2 层是数学和物理,高等数学、线性代数、概率论、大学物理,课号以数字开头,学分权重高;
  • 第 3 层是专业核心,数据结构、计算机系统、操作系统、计算机网络、编译原理、数据库,这部分课程名带明显专业词;
  • 第 4 层是专业选修和方向课,AI 入门、计算机图形学、体系结构、软件工程,课程名各异,学分浮动大。

解析 PDF 时,这四类数据的行格式几乎不一样。前两层的行里有大量公共课字段,中间是纯核心课,后面选修课经常出现“任选 X 门”“限选”这类备注。用一条规则从头拆到底,一定出错。

2.2 公开课表中常见的学分和学期标记规则

常规的清华课程表,行里会出现以下字段:课程代码、课程名称、学分、学时、开课学期、先修要求、备注。课程代码一般 5 位,比如 30240392 这类格式,中间不出现字母;学分通常保留一位小数或整数;学期标记常见写法是“春秋”“秋”“春”,或者直接给数字 1、3、5、7 代表第几学期。

这些字段规则在抽取 CSV 时是有用的映射依据。课程代码可以转成字符串保留前导零,学分必须转 float,学期要拆成可枚举的 int。代码实现时,我会先按这些规则设计一个行解析函数,再套到 PDF 表格上。网上不少解析教程喜欢一上来就用 pdftotext 扔文本正则,但课程表这种结构化数据,直接用表格抽取工具会更稳。

2.3 这张表对应的人才培养逻辑:从指令到系统

拆开清华计算机课表,你会发现专业核心课的排序是有讲究的。通常顺序是:程序设计基础 → 数据结构 → 计算机系统(或计算机系统导论)→ 操作系统 → 计算机网络 → 编译原理 → 数据库。这个顺序本身就是计算机专业的学习依赖图:先懂指令和数据,再懂运行时和系统调用,再懂跨机器通信和语言翻译。

这份顺序表完全可以作为 PDF 解析结果正确性的校验依据。你抽出来的课程顺序如果出现了“操作系统在数据结构前面”,基本可以判定是 PDF 文本流排序出了问题——双栏排版把左右两列读串了。这种校验方式,比肉眼核对每一行快得多,也是把课程表当数据用的第一步。

3. 用 Python 解析课程表 PDF:从文本抽取到结构化表格

3.1 环境准备:两个库和一个可复现的基线

处理课程表 PDF,我用的是 pdfplumber 做初步探测、camelot 做表格抽取。原因很直接:pdfplumber 能拿到每个字符的坐标,适合处理双栏错位;camelot 的 lattice 模式能识别表格线,对带框线的课程表效果稳定。安装命令如下:

pip install pdfplumber camelot-py[base] pandas openpyxl

注意 camelot-py 的包名要带连字符,import 时用camelot。装完先跑一段探测代码,确认 PDF 页面尺寸和文本量,再决定用什么模式抽。

import pdfplumber with pdfplumber.open("清华计算机课程表.pdf") as pdf: for i, page in enumerate(pdf.pages): text = page.extract_text() or "" tables = page.find_tables() print(f"第 {i+1} 页: 文本 {len(text)} 字符, 表格 {len(tables)} 个")

这段输出的用途是两个:一是确认表格在当前页能被识别到,二是看文本量和表格数量是否合理。如果某页有数据但是extract_text()返回空串,这个 PDF 很可能是扫描图片,需要走 OCR 而不是文本抽取。

3.2 用 camelot 的 lattice 模式抽带框线表格

清华课程表的公开版本多数是教务系统导出的 PDF,表格线比较规整,适合用 lattice 模式。核心参数是pagesline_scale。line_scale 控制表格线检测的敏感度,默认是 15,遇到打印后扫描的 PDF 经常需要调低到 8 或者调高到 30。

import camelot tables = camelot.read_pdf( "清华计算机课程表.pdf", pages="1-6", flavor="lattice", line_scale=15, ) print(f"共解析出 {tables.n} 张表格") # 把每张表转成 pandas DataFrame for idx, table in enumerate(tables): df = table.df print(f"表格 {idx+1}: 形状 {df.shape}") print(df.head(3).to_string())

这里的df的每一行对应 PDF 里的表格行,每一列对应单元格。问题在于表头可能不在第一行,而是一页一个重复表头。实际打印df之后你会看到,多页表格合在同一个对象里,行号是连续的,但表头会反复出现。后面清洗时要按表头行去重。

3.3 处理跨页表格和合并单元格:用 stream 模式兜底

lattice 模式碰到跨页表格合并单元格时,常常把“课程名称”列拆成两半。这时候我一般切到 stream 模式,用columns参数显式指定列的 x 坐标边界。先跑一次table.cell_text或者table.cols拿到列坐标,再回填。

tables = camelot.read_pdf( "清华计算机课程表.pdf", pages="2", flavor="stream", columns=["61, 152, 240, 320, 400"], ) df = tables[0].df

stream 模式不依赖表格线,靠文本坐标分行分列。columns里的每一组数字代表相邻两列的分界 x 坐标,单位是 PDF 的点(point),不是像素。这个值怎么拿?用 pdfplumber 找课程名那一行的x0x1

import pdfplumber with pdfplumber.open("清华计算机课程表.pdf") as pdf: page = pdf.pages[1] words = page.extract_words() # 取前 20 个词的 x0 坐标,按范围观察列位置 xs = sorted(set(round(w["x0"], 1) for w in words)) print(xs[:20])

这段代码会把当前页所有单词的左侧坐标列出来,你会看到明显的坐标簇,比如 61、152、240、320、400。每个簇就是一列的起点,把这些值填进 camelot 的columns参数即可。这是 stream 模式参数调整最常规的操作路径。

3.4 清洗逻辑:把 DataFrame 变成可用的课程表 CSV

抽取出来的 DataFrame 是原始单元格,要变成可用的课程表 CSV,至少要做三步清洗:第一行是表头时要识别并设置为 column names;空行和全 NaN 行删掉;课程代码占位类似“30240392”的转成字符串。

import pandas as pd def clean_table(df: pd.DataFrame) -> pd.DataFrame: # 第 0 行是否表头,用是否包含"课程"关键词判断 if df.iloc[0].astype(str).str.contains("课程|学期|学分").any(): df.columns = df.iloc[0] df = df.drop(index=0) df = df.dropna(how="all") df = df.replace(r"^\s*$", pd.NA, regex=True).dropna(how="all") # 课程代码列保持字符串,去掉 .0 if "课程代码" in df.columns: df["课程代码"] = df["课程代码"].astype(str).str.replace(r"\.0$", "", regex=True) return df.reset_index(drop=True) # 应用到所有解析出的表 dfs = [clean_table(t.df) for t in tables] result = pd.concat(dfs, ignore_index=True) result.to_csv("清华计算机课程表.csv", index=False, encoding="utf-8-sig")

清洗逻辑里有两处容易踩坑。如果表头行不在第 0 行,而是在第 1 行或第 2 行,上面的判断会失败。稳妥做法是遍历前 5 行,找到第一行同时包含“课程代码”和“学分”的行当表头。另一个坑是编码,直接存to_csv默认 utf-8,在 Windows 上 Excel 打开会乱码,必须用utf-8-sig

3.5 从 CSV 转结构化 JSON:按学期聚合课程

CSV 是给人看的,给程序用还是得 JSON。把清洗后的 DataFrame 按开课学期聚合成嵌套结构,这样前端展示或后续做学习路线图都方便。

import json from collections import OrderedDict def df_to_json(df: pd.DataFrame) -> str: by_semester = OrderedDict() for _, row in df.iterrows(): sem = str(row.get("开课学期", "未知")).strip() by_semester.setdefault(sem, []).append({ "code": row.get("课程代码", ""), "name": row.get("课程名称", ""), "credit": row.get("学分", ""), "hours": row.get("学时", ""), }) return json.dumps(by_semester, ensure_ascii=False, indent=2) with open("清华计算机课程表.json", "w", encoding="utf-8") as f: f.write(df_to_json(result))

这个步骤里ensure_ascii=False是关键,不写的话中文全变成\uXXXX。输出的 JSON 结构是一个字典,key 是学期名,value 是课程对象列表。后续你用前端渲染课表、做课程依赖分析、甚至算每个学期的学分总和,都是基于这个结构。

4. PDF 表格解析的 4 个高发坑:双栏、公式、坐标和 OCR 兜底

4.1 双栏排版导致文本流串行,x 坐标必须参与排序

课程表 PDF 最常见的排版是首页横向双栏:左侧是公共课,右侧是专业课。pdfplumber 的extract_text()返回文本时,会先按 y 坐标排序、再按 x 坐标排序,但碰到双栏且 y 坐标交错的表格,输出的文本流会左右穿插。比如“高等数学”和“数据结构”在同一行,解析结果会变成“高等数据结构数学”。

处理方式是绕开extract_text(),用extract_words()按坐标手动组装行:

with pdfplumber.open("清华计算机课程表.pdf") as pdf: page = pdf.pages[0] words = page.extract_words() # 按 y 坐标聚类成行,同行内按 x0 排序 rows = {} for w in words: key = round(w["top"] / 5) # 5 个单位内的词视为同一行 rows.setdefault(key, []).append(w) for key in sorted(rows): line = [w["text"] for w in sorted(rows[key], key=lambda w: w["x0"])] print(" | ".join(line))

这里把top坐标除以 5 再取整,起到聚类效果。课程表的行高一般在 10 到 15 个单位,除以 5 能保证同一行里的词分到同一组,又不会让相邻行并组。这个阈值要看实际 PDF,课程表紧凑的用 3,松散的用 8。输出检查没问题后,再用同样的坐标逻辑生成新的 DataFrame。

4.2 表格线不完整,line_scale 和 threshold 怎么调

带框线 PDF 转扫描件后,表格线会变淡甚至断裂。lattice 模式识别不到完整闭合线,表格解析出来是一坨文本。调参顺序是:先调line_scale,从 15 往下调到 10、8,观察t[0].df的 shape 是否变规整。

for scale in [15, 12, 10, 8]: t = camelot.read_pdf( "清华计算机课程表.pdf", pages="3", flavor="lattice", line_scale=scale, ) if t.n > 0: print(f"line_scale={scale}: {t[0].df.shape}")

如果 line_scale 调到 8 还是拆不开,就改用 join 模式,用threshold参数控制文本行合并的容差。camelot 内部把文本按纵坐标聚类,threshold默认是 1,调大可以让零散文本更容易被归为一行。常见做法是按档位递进,每次只改一个参数,别同时调两三个,否则出了问题无法定位是哪一步造成的。

4.3 中文符号和全角空格清洗

从 PDF 抽出来的文本经常混入全角空格、破折号、课程名称里的“-”和“—”。这些字符不会让解析崩溃,但会让 CSV 里的课程名称对不上、JSON 里出现肉眼不可见的脏数据。清洗规则我放在一个函数里统一处理:

import re def normalize_text(s) -> str: if not isinstance(s, str): return str(s) s = s.replace("\u3000", " ") # 全角空格 s = s.replace("—", "-") # 破折号统一 s = re.sub(r"\s+", " ", s) # 多个空格压一个 s = s.replace("课程名称\n", "") # 单元格内换行去掉 return s.strip()

全角空格的 Unicode 是\u3000,肉眼看不出来,用print(repr(text))才能发现。处理这个需要把清洗函数在 DataFrame 上 apply 到所有列,而不是只处理“课程名称”列。

4.4 扫描版 PDF 无文本层,OCR 是最后一道兜底

如果 pdfplumber 对某个页面返回空文本,说明这一页没有文本层,是纯扫描图。这时文本解析路径全线失效,需要先 OCR 再解析。常用方案是先用 OCR 工具把 PDF 变成带文本层的 PDF,再重新走 camelot。我自己一般会先取一页转成 300 DPI 的 PNG,看下清晰度,再决定要不要整本做 OCR。

更便宜的做法是只用 OCR 提取文本,不做表格恢复。课程表的格子对 OCR 来说太细,文字叠线、跨格字符都是常态,恢复表格的代价比手工录一遍还高。所以扫描版课程表我通常走两条路:要么只抽文本关键词,用正则找课程名和学分;要么标记为“低质量数据源”,不纳入后续的数据分析。

5. 把课程表变成学习路线图:用解析结果做选课和技能映射

解析完成的课程表 JSON 已经有了全部课程数据,把它变成可用的学习路线图,是最后一公里的工作。常规做法是给课程加两个维度的标签:先修依赖和技能映射。先修依赖决定学习顺序,技能映射决定这门课对应工程里的什么能力。比如“数据结构”对应的工程技能是算法设计和代码抽象,“编译原理”对应的是文本解析和状态机设计,而“操作系统”对应的是并发、内存管理和系统调用理解。

具体实现我推荐用同一个 JSON 文件,加一个skills字段,然后按学期统计你每个技能域覆盖多少门课:

courses = json.load(open("清华计算机课程表.json", encoding="utf-8")) skill_map = { "数据结构": ["算法", "抽象", "复杂度"], "编译原理": ["解析", "状态机", "中间表示"], "操作系统": ["并发", "内存", "系统调用"], } def add_skills(courses, skill_map): for sem, items in courses.items(): for c in items: c["skills"] = skill_map.get(c["name"], []) return courses courses = add_skills(courses, skill_map) # 统计每个学期知识点数量 for sem, items in courses.items(): skill_count = sum(len(c.get("skills", [])) for c in items) print(f"{sem}: 课程 {len(items)} 门, 技能点 {skill_count}")

这个思路的核心是把你从“看 PDF”变成“用 PDF”。同样的数据,你可以继续往 JSON 里追加“推荐教材”“prerequisite 课程代码”这些字段,甚至可以把它接进本地知识库,用课程名和文本块做向量检索。不过要注意,清华课程表里少数课程是见实习、毕业设计,没有明确学分对应,聚合统计时建议单独过滤。

到这一步,最初那份课程表 PDF 已经成了结构化数据,可以支撑选课规划、技能自测、甚至简历项目选型。整个流程从pdfplumber探测到camelot抽取,再到清洗和 JSON 落地,所有步骤和参数都能直接在任意课程表 PDF 上复现。你拿到的不是一份课程表,而是一条把任何 PDF 表格变成数据的流水线。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询