先说结论
我把上游的 markitdown(MIT,Python 库 / CLI)封装成了一个 Windows 桌面应用 :把 Word、PDF、PPT、Excel 拖进窗口,点开始,拿到干净的 .md。
不需要装 Python,不需要开命令行,不需要记参数。
项目地址:github.com/it-kxw/mark... (MIT,自行取用)

为什么会做这个
上游 markitdown 是个相当扎实的库,API 设计也干净。但它只有两种用法:import markitdown 或者命令行。这两种用法都预设了一件事------你是个会装 Python 环境的人。
问题在于,真正需要"把 PDF 转成 Markdown"的人,往往不是开发者:
- 产品同学要把竞品文档喂给大模型
- 法务同事要把合同转成可编辑格式
- 家里人问你这台电脑能不能"把那个 word 变成能粘贴的"
每次你都得出马,或者远程指导对方装 Python、配 pip 源、开 PowerShell、cd 到目录、敲参数。这件事的 ROI 低得离谱。
我去上游仓库翻了一下,发现官方把桌面 GUI 明确列为 out of scope ,但明确鼓励 以「独立项目 + 依赖 PyPI 上的 markitdown」的方式来做。这条路是通的,那就自己动手。
顺便说一句:这个工具全程本地转换,文档不出本机。市面上那些在线转换站,你得先把文件传上去------对一个正在被大模型投喂的文档来说,这个代价挺高的。

长什么样
三步,没有第四步:

支持的格式跟着上游走,PDF、Word、PowerPoint、Excel、CSV、HTML、EPUB、Outlook MSG、JSON、XML/RSS、Jupyter Notebook 都在内。

真正让我有底气写这篇文章的,是下面三个坑。
坑一:为什么放弃了 Electron
第一个念头当然是 Electron------界面好写,生态成熟。算了一下就放弃了。
转换内核是 Python 写的 markitdown。 用 Electron 的话,我得同时塞进一个 Node 运行时和一个 Python 运行时,打包体积直接起飞,冷启动也更慢。
所以选型收敛到 Python 原生 GUI,最终用 PySide6-Essentials(Qt 的官方 Python 绑定,LGPL-3.0,动态链接即可合规)。
| 方案 | 运行时 | 主要问题 |
|---|---|---|
| Electron | Node + 额外的 Python | 双运行时,体积与启动都吃亏 |
| Tauri | Rust + 系统 WebView | 体积小,但仍要外挂 Python,且 WebView2 不是所有机器都有 |
| PySide6 | 纯 Python | 与转换内核同进程语言,无额外运行时 |
选 PySide6 还有一个隐性好处:上游库抛出的异常、返回的数据结构,可以直接用,不需要跨进程序列化再翻译一遍。
坑二:QThread 做不出「真取消」
批量转换是耗时操作,所以必须有取消按钮。一开始我用的是教科书式的 QThread------然后发现取消按钮是个摆设。
现象:点取消,界面提示"正在取消",然后......等当前文件跑完才真的停。遇到一个 200 页的 PDF,用户要盯着进度条干等十几秒。
原因 :QThread 的 terminate() 会留下未释放的锁和句柄,属于 UB,没人敢用;而"优雅停止"只能靠协作式检查标志位------但转换是一次上游库的同步调用,中途没有检查点。 你只能在它返回之后才知道要停。
解法 :把转换挪进 multiprocessing(spawn) 进程池。进程可以硬杀。
python
pool.terminate() # 直接杀 worker,0.03 s 生效
代价是引入了新的问题:
- 子进程需要可序列化 ------用
spawn而不是fork,跨平台更稳,但要求入口做好保护; - 孤儿进程------主程序被强杀(哪怕是启动那一瞬间)时,worker 会残留下来啃 CPU。
第 2 个问题的解法是加看门狗:子进程启动后盯着父进程 PID,父进程消失就自己退出。实测 1~2 秒内自退,任务管理器里不留残骸。
最终取消响应:0.03 秒。这个数字是实测的,不是理论值。
坑三:PySide6 和 pandas 的导入顺序冲突
这是整个项目里最隐蔽的一个坑,值得单独说。
症状:PySide6 先于 pandas 导入时,程序直接崩。
powershell
python -c "from PySide6.QtWidgets import QApplication; import pandas"
报错长这样:
csharp
AttributeError: '_SixMetaPathImporter' object has no attribute '_path'
根因 :shiboken6 的导入钩子内部会调用 inspect.getsource();而 inspect 触发模块查找时,会走到 six 注册的元路径导入器 _SixMetaPathImporter,它缺少 _path 属性------于是整个导入链断掉。
为什么打包后更隐蔽 :PyInstaller 的 pyi_rth_pyside6 运行时钩子会在你的代码之前先导入 PySide6,于是源码里跑得好好的,打包后一启动就崩。
解法 是在自己的包里做兼容垫片,给 six._importer 补上缺的属性:
python
# _compat.py
import six
if not hasattr(six._importer, "_path"):
six._importer._path = None
并且把"先 pandas 后 PySide6"的导入顺序作为第二道保险。这个修法与导入顺序无关,源码和冻结包同时生效。
如果你也撞上了,建议顺手给上游 PySide6 提个 issue------这个坑不该由每个使用者各修一遍。
坑四:安装包从 233MB 裁到 64MB
PyInstaller 一把梭出来的 one-folder 是 233MB。一个文档转换工具,这个体积说不过去。
裁剪原则:只减冗余,不减功能。 格式支持和未裁剪基线必须完全一致,同一套用例全过。
| 档位 | one-folder | 裁掉的东西 |
|---|---|---|
none |
233 MB | 基线,用于对比 |
safe(默认) |
213 MB | Qt 翻译(保留 en/zh_CN)、Pillow AVIF、QtNetwork、sqlite3、Qt TLS/网络信息插件 |
aggressive |
192 MB | 再裁 opengl32sw.dll(20MB 软件 OpenGL 回退)与 qdirect2d |
依赖层面的取舍比裁文件更有效:
toml
# 刻意不用 markitdown[all]:会拖进 Azure SDK 和约 31MB 的 SpeechRecognition
"markitdown[pdf,docx,pptx,xlsx,xls,outlook,youtube-transcription]>=0.1.8,<0.2.0"
"PySide6-Essentials>=6.8" # 不用 PySide6-Addons,省约 39 MB
注意 magika/onnxruntime(内容类型嗅探)、pandas/numpy(Excel)、PDF 栈、Office 栈一个都没动------裁的是 UI 渲染用不到的东西,不是功能。
再往上走就是 Inno Setup 的活:per-user 安装、免 UAC,压出 64.32 MB 的安装包;免安装 zip 是 93.20 MB。
一个容易踩的细节:Inno Setup 的
.iss脚本若含中文,必须存成 UTF-8 with BOM,否则编译器按 ANSI 读,中文全乱。
实测数据
Windows 11 x64,Python 3.12,PyInstaller 6.22.3:
| 指标 | 实测值 |
|---|---|
| one-folder 产物 | 211.5 MB(safe 档) |
| 安装包 / 免安装 zip | 64.32 MB / 93.20 MB |
| 冷启动(进程启动 → 窗口可交互) | 0.947 s |
| 常驻内存 | 160.2 MB(并发 2 峰值 438.4 MB) |
| 取消响应 | 0.03 s |
| 格式矩阵 | 13/13 夹具通过,输出与上游逐字节一致 |
最后一行是我最在意的:转换结果和上游逐字节一致。封装层不该偷偷改变任何行为,格式解析的正确性和缺陷都归上游。
还没做完的
写在文章里,免得有人下载后觉得被坑:
- 没有代码签名 → 首次运行会弹 SmartScreen,要点「更多信息 → 仍要运行」。这是当前最大的信任问题,签名路线已定为 SignPath Foundation(对开源免费)。
- 没有自动更新------涉及 UAC 与回滚,风险大于收益。
- 没有跨平台------目前只在 Windows 11 x64 实测过。
- 音视频转写、图片 OCR 未做,这些要么需要外部二进制,要么需要密钥存储。
最后
项目地址:github.com/it-kxw/mark...
MIT 许可,独立第三方项目,与 Microsoft 无关。转换能力全部来自上游 microsoft/markitdown。
如果你正好也被"帮别人装 Python"这件事烦过,可以试试。觉得有用的话点个 Star,遇到问题直接开 issue,我看到会回。
如果这篇文章里的某个坑帮到了你------尤其是那个 shiboken6 × six.moves 的导入冲突------那就是它最大的价值了。