本系列:把这两年用 Python 写的十几个小项目挨个开源、挨个说清楚。不讲”我做了什么”,讲”做的过程中被什么打脸、最后怎么想明白的”。 第 12 篇 · 奥赛题库 OlympiadMath(上一篇:核痕 KeHen) 源码:github.com/ghostgorge/OlympaidMath Python + Tkinter 桌面程序,单文件 exe,小/初/高三档共 72 道题
一、先说这是个什么东西
小学 / 初中 / 高中三档数学奥赛题库,公式用 LaTeX 显示,全部题目笔算即可(不需要计算器、不需要编程),每题给提示 / 思路 / 完整解答 / 答案四层帮助。
| 学段 | 题数 | 难度分布 | 覆盖专题 |
|---|---|---|---|
| 小学 | 24 | ★1 / ★★11 / ★★★9 / ★★★★3 | 巧算、行程、工程、数论、周期、抽屉、计数、几何、数阵图、逻辑推理、最值 |
| 初中 | 24 | ★★9 / ★★★10 / ★★★★4 / ★★★★★1 | 代数变形、数论、平面几何、组合、不等式 |
| 高中 | 24 | ★★5 / ★★★10 / ★★★★6 / ★★★★★3 | 三角、数列、复数、函数方程、数论、组合几何、立体几何、解析几何、概率、不等式 |
难度对照:★★ 及以下是热身/课内拔高,★★★ 是联赛一试水平,★★★★ 是二试水平,★★★★★ 是 IMO / 二试压轴(比如 IMO 1990-3、IMO 1995-2、Sylvester–Gallai)。
界面默认只显示题面,提示、思路、解答、答案全部要手动展开。这是刻意的——先动笔,再看。一个把答案默认摊开的题库,会让人不知不觉变成”看解答”而不是”做题”。
二、这个项目真正的技术活:中文和公式排在一行
Tkinter 没有公式排版能力。常见的几种做法都有硬伤:
| 做法 | 问题 |
|---|---|
| 整段丢给 matplotlib 渲染成图 | 中文要给 matplotlib 配中文字体(打包后经常缺);整段变成图,不能选中、不能复制、不能自动换行 |
| 嵌一个浏览器控件跑 MathJax | 拖进来一个几十 MB 的运行时,单文件 exe 直接废掉 |
| 公式退化成纯文本 | 稍微复杂一点就没法看 |
我的方案是混排,一句话概括:
中文不进 matplotlib。
具体做法:
- 整行文本按
$切开 - 中文和普通字符用 Tk 原生字体直接写进
Text控件——可选中、可复制、自动换行 - 只有
$...$里的公式交给 matplotlib 的 mathtext 光栅化,贴成行内图片 - 用 mathtext 返回的
depth和当前字体的descent做基线对齐
第 4 步是关键。行内公式如果不做基线对齐,分数线和汉字底部会错开一两个像素——看起来说不出哪里怪,但就是很怪。
这个方案的连带好处:
- 不需要 matplotlib 去找中文字体(打包后最容易出问题的一环直接绕开)
- 图片只包含公式,体积小、缓存命中率高(同一个公式在提示和解答里出现两次,只渲染一次)
- 不依赖 Pillow——自己拼 PNG 转 Tk
PhotoImage,少一个依赖
三、mathtext 只支持 LaTeX 的一个子集
matplotlib 的 mathtext 不是完整的 LaTeX,很多常见写法它不认:\ge、\le、\pmod、\frac1x(不带花括号)、\underbrace、\displaystyle、\tfrac、\bigl……
两个选择:要求题库按 mathtext 的规矩写,或者写一个兼容层。
我选了后者(app/latexfix.py),理由很实际:题库是要给人写的,而人会按正常 LaTeX 习惯写。 一个”必须记住哪些命令不能用”的题库格式,会在第 10 道题的时候被放弃。
兼容层的成本是一次性的,而”要求所有人记住限制”的成本是每次都要付的。
四、降级要能降得下去
matplotlib 装不上也能跑——公式会退化成可读的纯文本((1)/(2)、√(3) 这种),程序不崩。
这条看起来是小事,但它决定了这个 exe 能不能随手发给一个不懂技术的人。一个”缺了某个库就白屏”的工具,等于要求每个用户都会配环境。
五、打包这一节,可能是全系列最有复用价值的
这个项目的 build_exe.bat 和 spec 里预先规避的坑,几乎是我这两年所有 PyInstaller 经验的合集。挨条列一下:
关于 matplotlib(这条是这个项目独有的)
MPLCONFIGDIR必须固定到%APPDATA%\OlympiadMath\mplcache。 否则单文件 exe 每次启动都要重建 matplotlib 字体缓存,首次渲染公式要等十几秒——用户会以为程序卡死了。
单文件 exe 每次运行都解压到一个新的临时目录,matplotlib 找不到上次的缓存,于是每次都重建。这个问题只在打包后出现,源码运行时完全正常。
关于 pywin32
spec 里排除 pythoncom / pywintypes / win32com / win32api,并用 pyi_hooks/ 里的空 hook 覆盖官方 hook。原因:机器上 pywin32 装了但没跑 postinstall 时,官方 hook 会在 Analysis 阶段直接崩(报 Failed to retrieve attribute __file__ from module pythoncom),跟你的程序毫无关系。
关于 excludes 的边界
不能排除标准库和 setuptools——PyInstaller 的运行时 hook 依赖它们,排掉后 exe 一启动就崩在
pyi_rth_pkgres.py。 excludes 只用来排 matplotlib/scipy/PyQt 这类重型第三方库。
关于”双击没反应”
console=False 下 sys.stdout 可能是 None,入口处任何一句 print() 都会 AttributeError——又一种”双击没反应”。所以入口装了黑洞流兜底,启动全程包在 try 里,出错写日志(%APPDATA%\OlympiadMath\startup.log)并弹 MessageBox。
排查手段是打一个 build_exe_debug.bat(onedir + 带控制台):能跑而单文件不能 → 问题在解压到 %TEMP%;调试版也挂 → 控制台直接给 traceback。
关于挑解释器和装依赖
按 py -3.12 → 3.11 → 3.9 → 3.8 → python 的顺序挑(PATH 里的 python 通常是 Anaconda,排最后),可用 set OM_PY= 覆盖。
依赖判断用 import 而不是 pip 的退出码——某些机器上 pip 会因为坏掉的包元数据直接崩溃(比如 pyodbc 的版本号写成 4.0.0-unsupported,不合 PEP 440,导致该环境下任何 pip 命令在”列出已安装包”阶段就退出码 2),跟你的项目毫无关系。
装依赖先试 --user 再试普通安装——python 装在系统目录、当前账号没写权限时会报 WinError 5。失败了给出三条能照着做的修法,而不是甩一段 traceback。
关于产物校验
打完校验体积,低于 8MB 直接判失败。(这条的来历见烬塔那篇:.bat 收尾横幅里一个裸的 > 被 cmd 当成重定向,把刚打好的 exe 覆盖成了 11 字节文本。)
六、一个很划算的自检项
打包前的题库自检里有一条:真的光栅化一个样例公式。
它守的不是题库,是依赖——matplotlib 换个版本、mathtext 的接口变了,这条会在打包之前就红,而不是等到用户双击 exe、点开第一道题、发现公式全变成红叉。
凡是依赖第三方渲染/解析的功能,自检里要有一个真正跑通端到端的样例。 只检查”能 import”是不够的。
自检也能在程序里跑(帮助 → 题库自检),它会把所有渲染不了的公式列出来——所以自己加题的人不用等报错,写完直接自检一遍。
七、其他功能
- 筛选:学段 / 专题 / 难度 / 状态四个维度 + 全文搜索(搜题面、标题、标签、思路)
- 组卷:按学段和难度区间随机抽 N 题,生成”题目卷 + 解答卷“两个文件。可选 Markdown 或可打印 HTML——HTML 版能留答题空白,公式用 MathJax 渲染,直接
Ctrl+P打印 - 错题本:标记错题后,「练习 → 只看错题」,或组卷时把抽题范围选成”错题”,就是一份错题重做卷
- 笔记:每题可以记自己的做法和卡住的地方
- 自己加题:把
.json丢进%APPDATA%\OlympiadMath\我的题库\,按F5就加载进来,和内置题库一起筛选、组卷
做题记录存在 %APPDATA%\OlympiadMath\progress.json——换版本、重新打包都不会丢。
快捷键:←/→ 翻题 · R 随机 · H/I/S/A 提示/思路/解答/答案 · F 收藏 · 1/2/3 标记已掌握/待复习/错题 · Ctrl+P 组卷 · F5 重载题库
pip install matplotlib
python main.py
python main.py --selftest # 题库结构 + 全部公式能否渲染
双击 build_exe.bat # 出 dist\OlympiadMath.exe
尾巴
两条带得走的:
一、”哪部分交给谁”比”用什么库”更重要。 公式排版这件事,整段丢给 matplotlib 是最省事的写法,但它同时把中文字体问题、可选中、自动换行全都赔进去了。按
$切开、各交各的,才是那个刚好的边界。二、兼容层的成本是一次性的,”要求所有人记住限制”的成本是每次都要付的。
另外,这篇里的打包清单如果你在用 PyInstaller,建议单独存一份——尤其 MPLCONFIGDIR 那条,它只在打包后出现、只表现为”首次渲染要等十几秒”,很难从现象反推到原因。
📦 源码:https://github.com/ghostgorge/OlympaidMath
下一篇:心一网络助手 XinYiNetTool——Python/tkinter 网络运维工具箱。回复「继续」。
