
1. 简历这个场景为什么值得自己写一套工具1.1 手工维护三份简历的真实痛点作为一个常年用 Python 处理各种杂事的人我一直想把手头这套 Python 简历生成工具做得再顺手一点。起因和很多人一样每到投递季我都要同时维护 Word、PDF、网页版三份简历改一次工作经历就要同步三个地方。结果经常是 Word 版本更新了PDF 还是上一版投出去之后自己都分不清哪个是最新的。更别提不同公司对格式要求不一样有的只要一页有的能接受两页有的喜欢倒序展示工作经历有的希望我重点突出某些项目——每次手调样式都极其痛苦。最让人崩溃的是格式崩坏问题。在 Word 里明明调好的对齐换一台电脑打开就乱掉复制一段文字进在线简历原来的加粗和缩进全没了。时间一长同一份简历能出现十几个文件名简历_2024.docx、简历_final.docx、简历_最终版2.pdf。我们嘴上说就用这份吧心里其实根本没底。这个场景相信很多人都有共鸣。与其每次手动修不如花一个下午用 Python 把链路自动化一劳永逸。1.2 从改样式到改数据的转变工具化之后体验完全变了。我把简历内容抽成一份 JSON 数据文件模板负责排版脚本负责把两者合成最终文件。以后改简历只需要改数据不再碰样式同一个数据源可以渲染出一页精简版两页详实版网页版等多种版本文件名带上日期不会再有最终版_最终版2这种尴尬。这套模式还带来了一个意外好处内容的版本管理变成了现实。以前用 Word 的修订模式看改动非常痛苦现在每份 JSON 都可以放进 Git 仓库改过什么、什么时候改的、为什么改全部有记录。想回退到三个月前的版本一条命令就能做到。对经常求职、做自由职业、或者需要给不同客户展示不同简历的人来说这能节省大量时间也能避免很多发错附件的事故。1.3 适用人群与边界我得先把话说清楚这套工具并不是适合所有人。如果只是偶尔更新一下简历也不熟悉命令行直接去用在线简历工具可能更省事。它更适合愿意投入一小时环境配置、之后长期受益的人——尤其是程序员、数据从业者、自由职业者以及任何需要频繁针对不同岗位调整简历内容的人。而且你不需要一次性把所有功能做全。先把数据 模板 渲染这条最小链路跑通后面自然会看到可以优化和扩展的地方。接下来我会把选型、环境、代码和踩坑过程全部展开照着做就能跑起来。2. 选型对比四条主流实现路线为什么我最后选了 HTML CSS PDF2.1 路线一LaTeX 简历模板LaTeX 确实能把简历排得极其精致字体、间距、对齐都有顶级水准很多学术背景的人特别喜欢用它。但中文简历用 LaTeX 需要处理 ctex 宏包、字体配置、编译引擎xelatex等一系列问题模板一旦要按自己的想法调整布局就得去改 TeX 代码门槛不低。更重要的是LaTeX 的调试思路和普通人熟悉的所见即所得差异很大。简历往往是临到要投递了才匆匆改一版这时候再去查编译错误、装缺失宏包时间成本非常高。如果对 LaTeX 不是特别熟悉我建议慎重。2.2 路线二python-docx 直接生成 Wordpython-docx 是 Python 操作 docx 文件的标准库可以控制段落、表格、样式生成的文件能被 HR 系统直接解析这一点非常实用。对于要求必须提交 Word 版简历的公司这条路几乎是绕不开的。但它的样式控制方式比较底层。想让某个项目块不被分页拆开某条线精确缩进 0.5 厘米这类精细控制需要写很多代码文档结构一旦复杂生成逻辑会变得很绕。我并不是说它不能用只是它在高度自定义排版这件事上不够顺手更适合作为辅助输出格式而不是主渲染管线。2.3 路线三JSON Resume 等在线工具JSON Resume 的思路很好把简历内容结构化再套社区模板生成页面。它解决了数据与展示分离这个核心问题而且有现成的 JSON Schema生态里模板也不少。问题在于模板质量参差不齐能改动的范围有限经常会遇到这个元素我想再往下移一点但就是挪不动的情况。另一个顾虑是隐私把完整简历数据放到第三方服务上我始终不太放心。用于快速生成一份能看的简历没问题但很难作为长期可控的个人系统。2.4 路线四JSON 数据 Jinja2 渲染 HTML WeasyPrint 转 PDF这是我最终采用的方案也是这篇文章的主体。核心流程分三步用 JSON 保存简历内容比如个人介绍、技能列表、工作经历、项目经历、教育经历。用 Jinja2 模板把 JSON 数据渲染成 HTML 页面。用 WeasyPrint 把 HTML 转换成 PDF排版规则全部写在 CSS 里。HTML CSS 的表达能力足够覆盖简历 99% 的排版需求。你会写网页就能排版简历换主题只需要改 CSS不需要改逻辑。Jinja2 负责把数据填入模板支持if判断和for循环让同一份数据可以动态决定显示哪些板块。WeasyPrint 在渲染时遵循 CSS 分页规则比很多老式 HTML 转 PDF 工具规范得多。如果需要 Word 版可以另写一个 python-docx 脚本从同一份 JSON 生成数据源统一格式各取所需。这也是我推荐这个方案的根本原因主输出是高质量的 PDF同时保留扩展 Word 能力的空间。2.5 一张表看清决策依据我把当时的选择逻辑整理成表格路线排版自由度中文友好度学习成本输出格式长期可控性LaTeX高需额外配置高PDF较高python-docx中高中DOCX较高JSON Resume中中低HTML 为主低Jinja2 HTML WeasyPrint高高中PDF / HTML高浏览器开发者工具里可以排查无数种网页排版问题这套组合天然好调试。如果你还有多模板需求CSS 换皮肤就是换文件引用扩展最容易。这份表基本代表了我当时的全部考量。3. 环境准备Python 版本、虚拟环境与依赖安装中的两个小坑3.1 版本选择和虚拟环境这个项目不需要最新的 Python 特性3.10 或 3.11 都行。我建议不要直接用系统全局 Python 安装依赖而是用虚拟环境隔离。创建和激活虚拟环境的命令在 Windows、macOS、Linux 上我都验证过python -m venv .venv source .venv/bin/activate # macOS / Linux .venv\Scripts\activate # Windows为什么一定要用虚拟环境因为简历生成工具依赖的包版本尤其 WeasyPrint 这类涉及系统库的包很可能和你其他项目冲突。隔离之后这个工具的依赖不会污染系统 Python删掉.venv目录就能完全卸载非常干净。激活成功之后命令行提示符前面会出现(.venv)这说明当前终端正在使用虚拟环境。接下来安装的所有依赖都会装进这个目录不会影响系统其它地方。3.2 安装依赖与国内源加速我的requirements.txt长这样Jinja23.1.4 Markdown3.6 WeasyPrint62.3安装命令pip install -r requirements.txt国内网络环境下建议加上国内镜像源速度会快很多pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple如果你遇到 pip 超时这个操作基本能解决。注意版本号尽量锁定因为 WeasyPrint 大版本升级对 CSS 的支持变化不小。锁版本能保证半年后重新跑脚本输出还是一模一样而不是被一个静默升级搞得排版全部漂移。3.3 坑Windows 下 WeasyPrint 依赖系统库WeasyPrint 不是纯 Python 库它需要底层依赖。在 Linux 或 macOS 上一般用系统包管理器安装一下就能跑在 Windows 上直接pip install之后运行很可能会报错提示缺少libgobject-2.0-0.dll或类似文件。注意Windows 用户如果不想折腾 GTK 运行库最简单的替代方案是把这段逻辑放在 WSLWindows 子系统 Linux里跑或者干脆用 Python 调用本机 Chrome/Edge 的无头模式打印 PDF。我实际用下来前者最省心。我最初踩了这个坑后没有立刻去装 GTK而是先改用 headless Chrome 的方案验证模板效果等后面确实需要更精细的 CSS 分页控制才在 Linux 环境里用 WeasyPrint 做正式生成。这个思路也可以给刚起步的你参考先让链路跑通再逐步替换组件。3.4 编辑器选择与 Python 解释器用什么编辑器无所谓VS Code 配好 Python 插件就够用。主要是 F5 一键运行脚本、内置终端、格式化工具都齐全对新手友好。如果你不知道怎么配置 VS Code 的 Python 环境核心就是两点用命令面板CtrlShiftP选择Python: Select Interpreter选中虚拟环境里的那个 Python然后在终端里执行which pythonWindows 是where python确认路径指向.venv目录。这一步对了后面运行脚本就顺了。4. 核心实现数据模型、模板渲染与 PDF 导出的三段式代码4.1 数据层JSON 定义简历内容我直接用 JSON 保存所有内容。这样数据来源可以是手写文件也可以是爬虫拉取的项目信息、数据库导出结果扩展性很好。一个最小示例{ name: 张小明, title: 高级 Python 开发工程师, contact: { email: zhangxmexample.com, phone: 138-0000-0000, location: 上海 }, summary: 7 年后端开发经验专注于 Python 服务端架构、数据管道和性能优化。, skills: [Python, FastAPI, PostgreSQL, Redis, Docker], experience: [ { company: 某科技公司, position: 资深开发工程师, period: 2021.07 - 至今, points: [ 负责交易核心服务重构将接口平均响应时间降低 40%, 搭建基于 Celery 的异步任务平台日均处理消息量超过 200 万, 推动自动化测试覆盖率从 35% 提升到 78% ] }, { company: 某互联网公司, position: Python 开发工程师, period: 2018.03 - 2021.06, points: [ 参与数据采集与清洗平台开发支持日增量 50GB 的数据处理, 设计并实现定时调度系统替代人工巡检流程节省每周约 8 人时 ] } ], education: [ { school: 某大学, degree: 计算机科学与技术 本科, period: 2014.09 - 2018.06 } ] }写这个文件时我习惯保持字段稳定不要今天叫company明天叫org否则模板和脚本都要跟着改。如果有多份简历变体直接复制 JSON 再改差异字段就好。4.2 模板层Jinja2 渲染 HTMLJinja2 模板的核心价值是用结构控制显示结果。例如让项目经历和工作经历共用一个数据数组但在模板里按不同顺序输出或者通过if判断决定是否显示某个板块!DOCTYPE html html langzh-CN head meta charsetUTF-8 title{{ name }} - 个人简历/title link relstylesheet hrefstyle.css /head body header classheader h1{{ name }}/h1 p classtitle{{ title }}/p p classcontact{{ contact.phone }} · {{ contact.email }} · {{ contact.location }}/p /header {% if summary %} section classsection h2个人简介/h2 p{{ summary }}/p /section {% endif %} section classsection h2工作经历/h2 {% for job in experience %} div classentry div classentry-head span classorg{{ job.company }}/span span classposition{{ job.position }}/span span classperiod{{ job.period }}/span /div ul {% for point in job.points %} li{{ point }}/li {% endfor %} /ul /div {% endfor %} /section section classsection h2教育经历/h2 {% for edu in education %} p{{ edu.school }} · {{ edu.degree }} · {{ edu.period }}/p {% endfor %} /section /body /html这里有个原则性的建议不要在模板里写死内容哪怕某个板块目前只有一条数据也尽量用循环输出。这样以后加第二条教育经历、第三条项目经历数据文件里加数组项就够了模板一行不用动。4.3 渲染层Python 脚本串起来接下来是核心脚本build_resume.pyimport argparse import json from pathlib import Path from jinja2 import Environment, FileSystemLoader from weasyprint import HTML def load_data(data_path: Path) - dict: with data_path.open(r, encodingutf-8) as f: return json.load(f) def render_html(data: dict, template_name: str) - str: env Environment(loaderFileSystemLoader(templates)) template env.get_template(template_name) return template.render(**data) def export_pdf(html_str: str, output_path: Path) - None: HTML(stringhtml_str, base_url.).write_pdf(str(output_path)) def main(): parser argparse.ArgumentParser(descriptionBuild resume PDF from JSON data) parser.add_argument(--data, defaultresume.json) parser.add_argument(--template, defaultresume.html) parser.add_argument(--output, defaultresume.pdf) args parser.parse_args() data load_data(Path(args.data)) html_str render_html(data, args.template) export_pdf(html_str, Path(args.output)) print(fdone: {args.output}) if __name__ __main__: main()运行方式python build_resume.py --data resume.json --output my_resume.pdf这条链路的重点在HTML(stringhtml_str, base_url.)。base_url.是让 WeasyPrint 可以按照相对路径找到 CSS 文件。如果你把 CSS 直接写在 HTML 里的style标签也可以不要这个参数但我建议外链样式方便换主题。4.4 CSS 设计一套样式控制整体视觉CSS 主要负责两件事屏幕显示和打印分页。基础的简历样式大概是这样的body { font-family: Noto Sans CJK SC, Microsoft YaHei, PingFang SC, sans-serif; font-size: 10.5pt; line-height: 1.6; color: #333; max-width: 210mm; margin: 0 auto; padding: 12mm 14mm; } h1 { font-size: 22pt; margin: 0 0 4pt; } .section { margin-top: 10pt; } .section h2 { font-size: 11pt; border-bottom: 1px solid #ddd; padding-bottom: 2pt; margin-bottom: 6pt; } .entry { margin-bottom: 8pt; page-break-inside: avoid; } .entry-head { display: flex; justify-content: space-between; }page-break-inside: avoid是我强烈建议加的一条它告诉渲染引擎这个块尽量别被分页切开。后面我会详细讲为什么这条属性在简历生成里几乎不可缺少。4.5 数据源稳定展示层才能随意换整个架构的精髓是内容全在 JSON结构全在模板视觉全在 CSS。三者边界清楚之后任何一层都可以独立修改。想换模板不改 JSON想换字体和主题色只改 CSS想加一段经历打开 JSON 加几行。这个稳定性是手工维护 Word 时代完全不敢想的。如果你后续想输出 Word 版也只要复用同一个 JSON写一个 python-docx 渲染器即可不需要复制数据。5. 中文字体与分页控制我在实际生成简历时踩过的三个坑5.1 坑一PDF 里的中文变成方框这是所有从 HTML 转 PDF 的工具都绕不过去的问题。第一次用 WeasyPrint 生成 PDF打开一看中文全是方框我当时整个人都懵了。原因很简单渲染环境里没有可用的中文字体或者字体名写错了引擎只能用默认字体去渲染而默认字体不含中文字形。解决分两步。第一步确认系统装了中文字体。Linux 上可以用fc-list :langzh查看中文字体没有就安装sudo apt install fonts-noto-cjkWindows / macOS 一般自带微软雅黑、苹方等但也需要在 CSS 里显式声明。第二步把字体族写在最前面font-family: Noto Sans CJK SC, Microsoft YaHei, PingFang SC, sans-serif;这样在哪个平台跑都能优先匹配到系统中文字体。技巧如果改了字体名还是方框先执行fc-list | grep -i 你的字体名看看实际名称有时候字体文件里的 name 和你输入的名字不完全一致。5.2 坑二一段工作经历被分页切成两半内容超过一页后简历最尴尬的情况就是某科技公司 2021.07 - 至今这一块公司名和第一条职责在第一页剩下三条职责跑到第二页。HR 看起来就像残缺的两段非常不专业。解决方案就是前面提到的分页控制属性。除了给.entry加page-break-inside: avoid还可以对.section h2加page-break-after: avoid避免小节标题孤零零地留在页面底部。WeasyPrint 对标准 CSS 分页属性的支持比很多老式工具好但个别属性在不同版本里行为有差异。升级版本前记得重新生成一份对比不要想当然认为输出还和原来一样。5.3 坑三模板里的花括号被 Markdown 库二次转义如果你像我一样先让 Markdown 库把一段文本转成 HTML再塞进 Jinja2 模板就会遇到一个隐蔽问题Markdown 内容里如果包含{{或{%Jinja2 会尝试把它们当模板语法解析导致渲染报错反过来如果先用 Jinja2 渲染再进 MarkdownCSS 里的{}又可能被某些库转义。我最后定下的规范是内容全部用 JSON 保存Jinja2 只负责结构不做 Markdown 转换如果确实需要富文本在数据准备阶段先把 Markdown 转成 HTML 字段再交给模板输出。这个约定避免了大量莫名其妙的渲染错误。5.4 我固定的四步排错工作流排查这类问题也有套路。我把自己的排查顺序固定成四步确认 JSON 能被读取且字段完整必要时先print(data)看结构。渲染出 HTML粘贴到浏览器里看结构确认模板逻辑没问题。在浏览器里按打印预览确认分页效果是否和预期一致。再用 WeasyPrint 转 PDF检查最终输出。每一步都有明确输出。浏览器显示没问题而 PDF 有问题问题大多出在 WeasyPrint 的 CSS 支持上浏览器都显示不对就要回头检查数据或模板逻辑。这比到处乱试高效得多。6. 批量生成与多模板扩展把一次性的脚本变成长期系统6.1 针对不同岗位批量生成当简历成为数据后针对不同 JD 做微调就从手工劳动变成了简单的数据切换。我在data/目录下放多份 JSONdata/ default.json backend.json data_engineer.json consultant.json每份 JSON 只改动自己需要变化的字段比如把summary换一种说法、把skills顺序调整一下、把某段和岗位更匹配的项目经历提到前面。然后批量生成for f in data/*.json; do python build_resume.py --data $f --output output/$(basename $f .json)_resume.pdf done在 Windows 上你可以在 Git Bash 里跑同样的命令或者写一个简单的 Python 批处理脚本。生成的 PDF 文件命名带上岗位标识投递时不会搞混。6.2 命令行参数继续扩展脚本里已经有--template和--data我后来又加了--theme参数用它来选择不同的 CSS 文件再加--lang参数切换中文 / 英文模板。这些扩展都不影响数据层只往模板和样式方向加维护成本很低。同样的数据中文模板和英文模板分别输出是许多外企求职场景的真实需求。加一个参数就能实现核心原因还是数据与展示分离。6.3 用 Git 管理简历版本简历每次修改都是一次提交这是工具化给我最大的意外收获。git diff能看到这次改了哪几个字哪个时间点把某段项目经历挪到了前面投递前还能打个 tag 标记2025 春招版。想想以前手动维护最终版_v3.docx这种体验完全是两个世界。这个习惯还有一层价值它可以帮你复盘哪个版本的简历回复率更高。同一家公司在不同时间投递用的是什么岗位标识、什么内容侧重Git 里都有记录。数据积累几个投递周期后你对自己的简历效果会有更理性的判断。6.4 后续还可以怎么玩这个工具做好了扩展方向很多。你可以写一个 Python 脚本直接从项目管理平台或 GitHub API 拉取项目数据自动更新简历里的项目列表也可以接入一个简单的定时任务每周自动把当前版本渲染成 PDF 存档想要 Word 版时从同一份 JSON 数据出发再写一个 python-docx 生成脚本即可。核心不变所有内容围绕数据源头展示层是可替换的。我还试过在生成 PDF 后把 PDF 里的文本抽取出来做关键词密度检查看这次改的简历和 JD 匹配度到底有多少。这些都是在数据模型稳定之后很容易做的小功能属于做了会觉得很值的周边能力。如果你也经常被简历版本折腾得头大不妨照这个思路搭一套自己的 Python 简历生成工具。不用一次性做完先把数据 模板 渲染这条最小链路跑通后面自然会发现很多可以优化和扩展的地方。我第一次跑通 PDF 输出时就一个感觉以后再也不用手工调整简历的样式和版本了。