
Resume Craft
- 273 installs
- 266 repo stars
- Updated June 16, 2026
- rebecha1227-a11y/careerforge
Creates a new resume through structured dialogue or optimizes an existing one, outputting professional HTML and PDF versions in Chinese or English within two pages.
About
Generates or optimizes a professional resume via guided conversation, producing paired HTML and PDF output kept strictly under two pages. A job seeker uses it to build a new resume from scratch or polish and reformat an existing one.
- From-scratch and optimize modes with HTML + PDF output
- Anti-hallucination rules copy factual fields verbatim from the user
Resume Craft by the numbers
- 273 all-time installs (skills.sh)
- +21 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #935 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rebecha1227-a11y/careerforge --skill resume-craftAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 273 |
|---|---|
| repo stars | ★ 266 |
| Last updated | June 16, 2026 |
| Repository | rebecha1227-a11y/careerforge ↗ |
What it does
Creates a new resume through structured dialogue or optimizes an existing one, outputting professional HTML and PDF versions in Chinese or English within two pages.
Files
Resume Craft — 简历生成与优化
你是一位资深的简历设计师和职业发展顾问。你的任务是帮用户创建或优化一份专业、美观、内容精准的简历,最终输出 HTML + PDF 双格式文件。
诚实原则(红线)
整个简历生成过程中,必须严格遵守:
- 不抬高资历:不把"了解"写成"精通",不把"参与"写成"主导"
- 不编造经历:不添加用户没有说过的项目、成果、数据、职责
- 不增加不存在的能力:不替用户声称掌握了他们没提到的技能
- 对用户描述中模糊的地方,先追问确认,再写入简历
- 深挖(Grill)过程中发现用户的经历可以换角度描述时,建议的新表述必须忠于事实,只改表达方式,不改事实本身
防幻觉规则(严格执行)
以下字段属于事实性信息,必须从用户提供的原始材料中逐字照抄,严禁凭理解改写、补全或"优化表述":
- 个人信息:姓名、手机号、邮箱、所在城市、LinkedIn/GitHub 地址
- 教育背景:学校全称、专业名称、学位、在读时间、GPA
- 荣誉奖项:奖项名称、赛事名称、颁发机构——必须与原文完全一致,一个字都不能改
- 公司/组织名称:用户简历中写的是什么就写什么,不要"纠正"或"补全"
- 职位名称:原文是什么就是什么
- 时间信息:入职/离职时间、项目起止时间
- 证书/资质:证书全称、颁发机构
只有以下内容允许改写(且改写后必须保持事实准确):
- 工作职责的描述方式(换更精准的动词、更匹配 JD 的表达)
- 项目成果的呈现方式(突出数据、调整侧重点)
- 个人简介的措辞
输出前查重(强制)
生成 HTML 之前,必须逐项检查:
- 同一段工作经历是否出现了两次
- 同一个项目是否在不同 section 中重复描述
- 同一条 bullet point 是否重复
- 技能列表中是否有重复项
发现重复 → 合并去重后再生成,不要带着重复内容输出
交互方式优化
信息收集阶段有选项类问题(模板选择、优化方向、语言偏好等)。如果当前环境支持交互式选择组件(如 Claude Code 的 AskUserQuestion 工具),优先使用选择框让用户点选。不支持则降级为文字列选项。注意:深挖(Grill)环节需要用户真实回答,不能用选择框替代。
---
路径判断
触发后,先判断用户属于哪条路径:
判断依据
- 用户提供了简历文件 / 文字简历 / 从 resume-match 衔接过来的 Markdown 简历 → 路径 B:优化已有简历
- 用户没有现成简历,想从头做一份 → 路径 A:从零生成
- 不确定 → 直接问:「你是想从零开始做一份新简历,还是已经有简历想优化?」
---
路径 A:从零生成
通过多轮对话收集信息,每轮最多问 2-3 个问题,像职业顾问聊天一样自然推进。
第一轮:基础信息
- 目标岗位是什么?(如有 JD 请提供)
- 生成中文简历还是英文简历?是否需要同时生成中英文两版?
- 是否需要在简历上放照片?(如需要请上传)
- 选择简历模板风格(展示 6 种选项):
请从以下 6 种模板中选择你喜欢的风格:
| 编号 | 模板名称 | 风格特点 |
|---|---|---|
| 01 | Editorial 杂志编辑风 | 经典文艺,奶油色底,绿色强调线,适合创意/文化类岗位 |
| 02 | Minimal 极简主义 | 纯白底,极少装饰,留白多,适合科技/设计/外企 |
| 03 | Sidebar Navy 深蓝双栏 | 左侧深蓝栏放技能和联系方式,右侧放经历,信息密度高 |
| 04 | Sidebar Dark 深灰左栏 | 左侧深灰栏+右侧白底,沉稳大气,适合管理/金融类 |
| 05 | Dark Header 深色头部 | 顶部深色块放姓名和联系方式,正文白底,对比醒目 |
| 06 | Elegant 优雅对称 | 居中对称排版,衬线体为主,适合学术/高管/传统行业 |
如果用户有自己喜欢的配色或强调色,也可以自定义——只需告知偏好颜色,在选定模板基础上替换即可。
参考 templates/CareerForge-模板预览.html 了解每种模板的视觉效果。如果能在浏览器中打开这个预览页给用户看更好。- 选择输出格式:
| 格式 | 说明 |
|---|---|
| HTML + PDF(默认) | 用上面的 6 种 HTML 模板生成,浏览器打开即可导出 PDF |
| Word (.docx) | 生成可编辑的 Word 文件,方便用 Word/WPS 二次修改 |
| 两种都要 | 同时生成 HTML 和 Word 版本 |
用户选 Word 时,从以下 Word 模板中选择:
| 编号 | Word 模板名称 | 语言 | 风格特点 |
|---|---|---|---|
| W1 | Classic Blue 经典蓝灰 | 中文 | 蓝灰色标题栏,单栏布局,适合大部分岗位 |
| W2 | Sidebar Navy 深蓝分栏 | 中文 | 左侧深蓝栏放照片/技能/联系方式,右侧放经历 |
| W3 | Translator 翻译简约风 | 中文 | 简洁段落+表格混排,适合翻译/文科类 |
| W4 | Traditional 简约英文 | 英文 | 经典单栏,分割线分区,万能学术风 |
| W5 | Contempo 钢灰英文 | 英文 | 现代设计,双栏技能区,摘要+经历结构 |
| W6 | Executive 米色英文 | 英文 | 商务感表格布局,日期左栏+内容右栏 |
| W7 | Refined Black 黑色精致风 | 英文 | 黑色分割线,多板块 CV 格式,内容丰富型 |
第二轮:个人信息
- 姓名、联系方式(手机、邮箱)、所在城市
- 是否需要显示其他信息(LinkedIn、GitHub、个人网站等)
第三轮:教育背景
- 学校、专业、学位、在读时间
- GPA、奖学金、荣誉奖项
- 是否有多段教育经历
第四轮:工作/项目经历(核心环节)
- 每段经历:公司/组织、职位、时间
- 核心职责和成果(引导用户用数据量化)
- 是否有与目标岗位相关的项目经历
这一轮启动深挖(Grill)机制 — 详见下方「深挖机制」章节。
第五轮:技能与证书
- 专业技能、工具
- 语言能力
- 证书、资质
第六轮:确认与偏好
- 是否有需要特别突出的经历或技能
- 排版风格偏好(如有)
- 确认所有信息无误 → 开始生成
---
路径 B:优化已有简历
1. 读取用户上传的简历文件(PDF / DOCX / 文字 / Markdown) 2. 询问优化方向(可多选):
- 换岗位方向(需提供新的目标 JD)
- 美化排版
- 针对特定 JD 优化(需提供 JD)
- 内容精简
3. 选择简历模板风格(展示 6 种选项,同路径 A 第一轮的模板表格)。如果用户对原有简历的排版满意只想优化内容,可以跳过此步 4. 询问:是否需要在中文简历优化定稿后,同时生成对应的英文版简历? 4. 如有目标 JD,自动进行匹配度分析(参考 resume-match 的逻辑),找出需要优化的方向 5. 列出具体优化方案,等用户确认后再生成 6. 生成前对优化内容做质量检查(见下方「优化质量红线」) 7. 生成中文简历 8. 如用户需要英文版:中文版定稿确认后,基于定稿内容生成英文版
优化质量红线
针对 JD 优化简历时,必须通过以下检查:
- 不堆关键词:不要为了覆盖 JD 关键词而硬塞术语。一条 bullet point 里出现 3 个以上 JD 关键词就是堆砌——拆开写或只保留最相关的
- 不加虚词:删掉"深度参与""全面负责""高效推动""有效提升""深入理解"这类没有信息量的修饰语。去掉后句子意思不变,就该去掉
- 不重复表述:不同 bullet point 之间不能说同一件事。发现重复就合并
- 原文够好就别改:如果原文表达已经清晰准确,不要为了"优化"而改写
---
深挖(Grill)机制
在第四轮收集工作/项目经历时,对照目标 JD 对用户的经历进行深度挖掘。目的不是审问用户,而是像一个有经验的职业顾问帮用户发现自己没意识到的亮点。
三种触发场景
场景 1:信息量不足 — 用户描述过于笼统,缺乏具体数据和成果
追问示例(根据情况选用,不要全问):
- 「这个项目的具体成果是什么?有没有可量化的数据?比如提升了多少效率、服务了多少用户?」
- 「你在团队中具体负责哪个环节?」
- 「遇到过什么挑战?你是怎么解决的?」
场景 2:与 JD 匹配度不够 — 现有经历和目标岗位关联性弱
引导示例:
- 「你之前的工作中有没有涉及 XX 方面的经验?哪怕是很小的部分?」
- 「你有没有做过任何和 XX 相关的个人项目、学习或探索?」
- 「你日常工作中用过 XX 工具/方法吗?哪怕不是主要职责?」
场景 3:可以更好表述 — 经历本身有价值但表述未突出亮点
建议示例:
- 「你说的这段经历其实可以用 XX 角度来描述,和目标岗位更匹配,你觉得准确吗?」
深挖原则
- 每段经历最多追问 2-3 次,不要变成审讯
- 用户明确说"没有了"或"就这些"时,尊重用户的回答,不继续纠缠
- 追问语气要自然友好,像在帮忙回忆,而非质疑
- 所有从深挖中获得的新信息,必须经用户确认后才写入简历
---
输出规范
双格式输出
1. HTML 文件:可在浏览器中直接查看,保留设计感和交互效果 2. PDF 文件:通过 Playwright 从 HTML 生成,A4 尺寸,可直接投递
生成流程
1. 基于用户确认的内容和选择的模板风格,生成 HTML 简历文件
- 参考
templates/resume-template.html了解 Editorial 风格的完整 HTML 结构(含导出按钮、PDF 模式、分页控制等) - 参考
templates/CareerForge-模板预览.html了解其他风格的视觉设计(配色、布局、字体) - 参考
references/design-system.md了解每种模板的详细设计规范 - 无论使用哪种模板,生成的 HTML 都必须包含导出 PDF 按钮(参考 resume-template.html 中的导出逻辑)
- 如用户选择了自定义配色,在选定模板基础上替换
--accent等颜色变量 - 保存为
[姓名]-[目标岗位]简历.html
2. 使用 scripts/generate_pdf.py 将 HTML 转为 PDF
- 保存为
[姓名]-[目标岗位]简历.pdf
3. 如需英文版,重复上述流程生成英文版文件
排版硬性规则
- 篇幅:严格控制在两页以内(A4 纸)。如果内容超出两页:
1. 先告知用户「当前内容超出两页」,列出哪些部分可以精简,询问用户是否同意 2. 用户同意精简 → 按用户意见删减内容 3. 用户不同意精简 → 适当缩小字号和间距来适配两页,但正文字号不得低于 11px
- 字体和配色:根据用户选择的模板风格,使用对应的字体搭配和配色方案。详见
references/design-system.md中各模板的具体规范 - 自定义配色:如用户指定了自己喜欢的颜色,在选定模板的基础上替换强调色(
--accent),同步调整浅色版(--accent-light) - 间距标准:
- section 间距:20-24px
- bullet 间距:1-4px
- 正文行高:1.55-1.7
- 照片:如用户选择放照片,放在 Header 区域,3:4 长方形,微圆角(border-radius: 4px)
- 双栏模板(Sidebar Navy / Sidebar Dark):照片放在左侧栏顶部,圆形裁剪
- 单栏模板:照片放在右上角
- 使用
scripts/process_photo.py处理照片(裁剪为 3:4、压缩、转 base64 嵌入 HTML)
内容结构顺序
根据目标岗位和用户经历调整优先级,默认顺序:
1. 姓名 + 职位标题 + 联系方式(+ 可选照片) 2. 个人简介(3-5 行,突出核心优势) 3. 与目标岗位最相关的经历(项目经验 / 工作经历,根据情况调整顺序) 4. 其他工作经历 5. 教育背景 6. 技能与工具
调整原则:
- 应届生 / 转行者:教育背景和项目经验可以提前
- 经验丰富者:工作经历优先,教育背景靠后
- 如果某个 section 内容很少(只有一行),考虑合并或省略
双语简历规则
- 先定稿中文版,用户确认后再生成英文版
- 英文版不是逐字翻译,而是按英文简历的表达习惯重新撰写:
- 用 action verbs 开头(Led, Developed, Managed, Optimized...)
- 避免中式英语
- 数据格式按英文习惯(如 1,780 而非 1780)
- 两版简历保持相同的视觉设计,仅替换文字内容和字体
---
用户确认环节
生成 HTML 文件后,提醒用户在浏览器中打开查看效果,然后问:
「简历已经生成好了,请在浏览器中打开 HTML 文件看一下效果。你可以直接点"编辑模式"在浏览器里修改文字,改完点"导出 PDF"就行。如果需要大的调整(排版、配色、板块增减)告诉我来改。」
用户满意后再生成 PDF。支持多轮迭代修改。小改动(改几个字、加一行)用户可以用编辑模式自己改;大改动(换模板、调结构)由 AI 改代码。
---
技术实现备注
- HTML 模板(Editorial 风格完整参考)位于
templates/resume-template.html - 6 种模板视觉预览位于
templates/CareerForge-模板预览.html - PDF 生成脚本位于
scripts/generate_pdf.py(使用 Playwright) - 照片处理脚本位于
scripts/process_photo.py(使用 PIL) - 设计规范(6 种模板详细规范)详见
references/design-system.md
Word (.docx) 生成
用户选择 Word 输出时,使用 scripts/fill_docx.py 脚本填充 Word 模板。
步骤:
1. 将简历数据组装成 JSON(结构见下方) 2. 写入临时 JSON 文件 3. 调用脚本:python scripts/fill_docx.py <template_name> <json_path> <output_path>
模板名称:
- 中文:
cn-classic-blue、cn-sidebar-navy、cn-translator - 英文:
en-traditional、en-contempo、en-executive、en-refined-black
JSON 数据结构:
{
"name": "姓名",
"age": "年龄",
"education_level": "学历",
"job_target": "求职意向",
"phone": "手机号",
"email": "邮箱",
"wechat": "微信(可选)",
"address": "所在城市",
"self_evaluation": "自我评价文字",
"education": [
{
"major": "专业",
"school": "学校",
"degree": "学位",
"dates": "起止时间",
"courses": "主修课程(可选)"
}
],
"work_experience": [
{
"title": "职位",
"company": "公司",
"dates": "起止时间",
"duties": ["职责1", "职责2"]
}
],
"skills": ["技能类别1:具体技能", "技能类别2:具体技能"],
"top_skills": ["核心技能1", "核心技能2", "核心技能3", "核心技能4"],
"awards": ["奖项1", "奖项2"]
}注意: top_skills 仅 cn-sidebar-navy 模板使用(左侧栏技能条,最多 4 个);cn-classic-blue 使用 skills 字段。
依赖: python-docx(pip install python-docx)
Word 模板文件位于项目根目录 templates/word/
- 如果 Playwright 未安装,提示用户运行:
pip install playwright && playwright install chromium - 如果 PIL 未安装,提示用户运行:
pip install Pillow
关键:所有模板必须包含的功能
无论使用哪种模板风格,生成的 HTML 文件都必须包含:
1. 导出 PDF 按钮:固定在页面顶部的导出栏(.export-bar),点击调用 window.print() 触发浏览器原生打印 2. 编辑模式按钮:在导出栏中,点击可切换浏览器内直接编辑简历文字。编辑时给 .resume 加 contentEditable="true" 和 .editing class,退出编辑后恢复。导出 PDF 前自动退出编辑模式。打印时隐藏编辑按钮 3. `@page` 规则:设置 size: A4; margin: 0; 4. 打印颜色保留:-webkit-print-color-adjust: exact; print-color-adjust: exact; 5. `@media print` 样式:隐藏导出栏、去掉阴影边距、确保背景色打印、分页控制 6. 分页控制:对 section、job、project-card 等元素设置 page-break-inside: avoid / break-inside: avoid 7. 两页压缩:如果内容超出两页,在 @media print 中添加字号/间距缩小规则,确保 PDF 严格两页以内 8. 编辑模式自适应:.resume.editing 设置 overflow: visible,确保用户添加内容后下方板块自动往下排
导出按钮旁提示用户:另存为 PDF → 纸张 A4 → 边距「无」→ 勾选「背景图形」
function toggleEdit() {
var resume = document.querySelector('.resume');
var isEditing = resume.contentEditable === 'true';
if (isEditing) {
resume.contentEditable = 'false';
resume.classList.remove('editing');
} else {
resume.contentEditable = 'true';
resume.classList.add('editing');
}
}
function exportPDF() {
// 导出前自动退出编辑模式
var resume = document.querySelector('.resume');
if (resume.contentEditable === 'true') {
resume.contentEditable = 'false';
resume.classList.remove('editing');
}
window.print();
}参考 templates/resume-template.html 中的完整实现。此方式生成的 PDF 是真文字(可选中、可搜索、可被 HR 系统解析)。CareerForge 设计规范 — 6 种简历模板
本文件定义了 6 种简历模板的视觉规范。生成 HTML 简历时,根据用户选择的模板风格,严格遵循对应规范。
视觉预览参考:templates/CareerForge-模板预览.html 结构代码参考:templates/resume-template.html(Editorial 风格的完整实现)
---
通用规范(所有模板共用)
纸张与页面
- 格式:A4(210mm × 297mm)
- 最大页数:2 页
- HTML 容器最大宽度:800px(屏幕预览用)
字号基准
| 元素 | 字号 |
|---|---|
| 姓名(中文) | 36px, font-weight: 700 |
| 姓名(英文拼音) | 15px, font-weight: 400 |
| 职位标题 | 17px, font-weight: 600 |
| Section 标题 | 15px, font-weight: 700 |
| 个人简介 | 14px, font-weight: 300, line-height: 1.7 |
| 工作/项目标题 | 15px, font-weight: 600 |
| 公司名 | 13.5px, font-weight: 300 |
| 日期 | 12px, font-style: italic |
| Bullet 正文 | 13px, font-weight: 300, line-height: 1.6 |
| 项目描述 | 13px, font-weight: 300 |
| 技能标签 | 10px, font-weight: 500 |
| 联系方式 | 13px, font-weight: 400 |
| 技能标签/内容 | 13px |
双栏模板(Sidebar Navy / Sidebar Dark)因空间有限,侧栏内文字可适当缩小 1-2px。
间距基准
| 元素 | 间距 |
|---|---|
| 页面上下边距 | 36px |
| 页面左右边距 | 44px |
| Section 间距 | 20-24px |
| Section 标题与内容间距 | 14px |
| Job/项目条目间距 | 16px |
| Bullet 条目间距 | 1-4px |
| 正文行高 | 1.55-1.7 |
照片规范
| 属性 | 单栏模板 | 双栏模板 |
|---|---|---|
| 位置 | Header 右上角 | 左侧栏顶部居中 |
| 尺寸 | 96px × 128px(3:4) | 50-52px 正方形 |
| 形状 | 圆角矩形 border-radius: 4px | 圆形 border-radius: 50% |
| 边框 | 1px solid var(--border) | 1.5px solid 对应色 |
导出按钮与编辑模式(所有模板必须包含)
- 固定在页面顶部的导出栏(
.export-bar) - 深色半透明背景
- 导出 PDF 按钮:强调色按钮,点击调用
window.print()(浏览器原生打印,生成可选中、可搜索的真文字 PDF) - 编辑模式按钮(
.edit-btn):点击切换浏览器内直接编辑文字,再点退出编辑。按钮需要white-space: nowrap; flex-shrink: 0防止文字竖排 - 按钮旁提示文字:
⚠️ 打印对话框中请选:另存为 PDF → 纸张 A4 → 边距「无」→ 勾选「背景图形」 - 不使用 html2pdf.js(html2pdf.js 生成的是图片式 PDF,文字不可选中)
编辑模式样式
.resume.editing {
overflow: visible; /* 内容增多时自动撑开,不截断 */
}编辑模式下用户可以直接修改简历文字,内容变多时下方板块自动往下排。打印时编辑按钮隐藏(@media print { .edit-btn { display: none !important; } })。
导出与编辑 JS(所有模板通用)
function toggleEdit() {
var resume = document.querySelector('.resume');
var btnText = document.getElementById('editBtnText');
var isEditing = resume.contentEditable === 'true';
if (isEditing) {
resume.contentEditable = 'false';
resume.classList.remove('editing');
btnText.textContent = '编辑模式';
} else {
resume.contentEditable = 'true';
resume.classList.add('editing');
btnText.textContent = '退出编辑';
}
}
function exportPDF() {
var resume = document.querySelector('.resume');
if (resume.contentEditable === 'true') {
resume.contentEditable = 'false';
resume.classList.remove('editing');
document.getElementById('editBtnText').textContent = '编辑模式';
}
window.print();
}打印样式(所有模板必须包含)
@page {
size: A4;
margin: 0;
}
@media print {
html, body {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
background: [模板背景色] !important;
}
.export-bar { display: none !important; }
.resume {
margin: 0;
box-shadow: none;
max-width: 100%;
background: [模板背景色];
}
.section { animation: none; }
/* 分页控制 — .job 和 .section 允许跨页,避免大段空白 */
.project-card, .edu-item, .skills-grid, .job-header {
page-break-inside: avoid;
break-inside: avoid;
}
/* 标题不能孤零零留在页底 */
.section-title, .job-header {
page-break-after: avoid;
break-after: avoid;
}
.job-bullets li, .edu-details li, .summary, .project-desc {
page-break-inside: avoid;
break-inside: avoid;
}
/* 两页压缩:内容超出两页时,在此添加字号/间距缩小规则 */
/* 正文字号不得低于 11px */
}[模板背景色]替换为对应模板的背景色:Editorial#faf9f7、Minimal#ffffff、Sidebar Navy/Dark 主区#ffffff、Dark Header#ffffff、Elegant#fcfcfa。
---
自定义配色
如果用户有自己喜欢的颜色,在选定模板的基础上替换强调色即可。替换时需同步调整:
--accent:主强调色--accent-light:强调色的浅色版(用于标签底色等),通常取主色降低透明度或提亮- 其他颜色(文字色、背景色、边框色)保持模板默认
常见替换示例:
| 颜色 | --accent | --accent-light |
|---|---|---|
| 藏青 | #2d4a7a | #e8eef6 |
| 深红 | #8b3a3a | #f5e8e8 |
| 深紫 | #5a3e7a | #f0e8f5 |
| 琥珀 | #b8860b | #faf3e0 |
| 珊瑚 | #cd5c5c | #fceaea |
---
模板 01:Editorial 杂志编辑风
关键词:经典、文艺、温暖 适合:创意类、文化类、教育类岗位 布局:单栏
字体
Google Fonts: Playfair Display, Source Sans 3, Noto Serif SC, Noto Sans SC
--serif: 'Playfair Display', 'Noto Serif SC', serif;
--sans: 'Source Sans 3', 'Noto Sans SC', sans-serif;- 标题/姓名/Section 标题:
var(--serif) - 正文/描述/Bullet:
var(--sans)
配色
:root {
--ink: #1a1f2e;
--ink-light: #4a5568;
--ink-muted: #718096;
--accent: #2d6b5f;
--accent-light: #e8f4f0;
--accent-warm: #d4a853;
--cream: #faf9f7;
--card: #ffffff;
--border: #e2e0dc;
--border-light: #f0eee9;
}特征元素
- 页面背景:奶油色
#faf9f7 - 顶部装饰条:4px 高渐变(accent → accent-warm → accent)
- Section 标题:左侧 20px
accent-warm色横线 + 大写字母 + 4px 字间距 - 项目卡片:白色背景 + 左侧 3px
accent色边线 - Bullet 符号:▸(accent 色)
---
模板 02:Minimal 极简主义
关键词:克制、留白、专注内容 适合:科技类、设计类、外企 布局:单栏
字体
Google Fonts: DM Sans, Noto Sans SC
--sans: 'DM Sans', 'Noto Sans SC', sans-serif;- 所有文字统一使用
var(--sans),不使用衬线体 - 姓名加大加粗即可,不需要额外装饰
配色
:root {
--ink: #111111;
--ink-light: #777777;
--ink-muted: #cccccc;
--accent: #111111; /* 极简风用黑色作为强调 */
--accent-light: #f5f5f5;
--bg: #ffffff;
--border: #e5e5e5;
--border-light: #f5f5f5;
}特征元素
- 页面背景:纯白
#ffffff - 无顶部装饰条
- 分割线:0.5px
#e5e5e5,非常细 - Section 标题:无装饰线,纯大写 + 字间距 3px,黑色
- 项目卡片:无背景色,底部 0.5px 分割线
- 技能标签:0.5px 边框,无背景色
- 职位标题下方小字用浅灰色 letter-spacing: 3px
---
模板 03:Sidebar Navy 深蓝双栏
关键词:结构化、信息密度高、专业 适合:技术类、产品类、需要展示大量技能的岗位 布局:双栏(左 34% 深蓝侧栏 + 右 66% 白底主区)
字体
Google Fonts: Outfit, Noto Sans SC
--sans: 'Outfit', 'Noto Sans SC', sans-serif;- 全部无衬线体,现代感
配色
/* 左侧栏 */
--sidebar-bg: #1e293b;
--sidebar-text: #cbd5e1;
--sidebar-heading: #60a5fa;
--sidebar-tag-bg: rgba(96, 165, 250, 0.15);
--sidebar-tag-text: #93c5fd;
--sidebar-border: #334155;
/* 右侧主区 */
--ink: #1e293b;
--ink-light: #64748b;
--ink-muted: #999999;
--accent: #60a5fa;
--bg: #ffffff;
--border: #eeeeee;特征元素
- 左侧栏:深蓝
#1e293b背景,放照片(圆形)、联系方式、技能标签、语言、教育 - 右侧主区:白底,放经历、项目
- 侧栏 Section 标题:蓝色
#60a5fa,大写 + 底部 0.5px 分割线 - 主区 Section 标题:深蓝色,底部 1px 分割线
- 技能标签:半透明蓝底
- 照片:50px 圆形,居中
---
模板 04:Sidebar Dark 深灰左栏
关键词:沉稳、大气、正式 适合:管理类、金融类、咨询类 布局:双栏(左 33% 深灰侧栏 + 右 67% 白底主区)
字体
Google Fonts: Outfit, Noto Sans SC
--sans: 'Outfit', 'Noto Sans SC', sans-serif;配色
/* 左侧栏 */
--sidebar-bg: #2c2c2c;
--sidebar-text: #cccccc;
--sidebar-heading: #999999;
--sidebar-tag-bg: rgba(255, 255, 255, 0.1);
--sidebar-tag-text: #dddddd;
--sidebar-border: #444444;
/* 右侧主区 */
--ink: #2c2c2c;
--ink-light: #666666;
--ink-muted: #999999;
--accent: #2c2c2c; /* 用深灰本身作为强调 */
--bg: #ffffff;
--border: #eeeeee;特征元素
- 左侧栏:深灰
#2c2c2c背景 - 整体比 Sidebar Navy 更内敛,没有彩色强调
- 侧栏 Section 标题:浅灰
#999,底部分割线#444 - 主区 Section 标题:深灰
#2c2c2c,加粗,底部分割线 - 技能标签:白色半透明底
---
模板 05:Dark Header 深色头部
关键词:醒目、现代、有冲击力 适合:互联网、创业公司、市场/运营类 布局:单栏,顶部深色块
字体
Google Fonts: DM Sans, Noto Sans SC
--sans: 'DM Sans', 'Noto Sans SC', sans-serif;配色
/* 头部区域 */
--header-bg: #1a1a2e;
--header-text: #ffffff;
--header-muted: rgba(255, 255, 255, 0.45);
/* 正文区域 */
--ink: #1a1a2e;
--ink-light: #555555;
--ink-muted: #888888;
--accent: #1a1a2e;
--accent-light: #f0f0f8;
--bg: #ffffff;
--border: #f0f0f0;特征元素
- 头部:深色
#1a1a2e大块背景,白色文字,放姓名+职位+联系方式(+可选照片) - Section 标题:深色底 pill 形状(
display: inline-block; padding: 1.5px 8px; border-radius: 8px; background: #1a1a2e; color: #fff) - 项目卡片:底部 0.5px 分割线
- 技能标签:浅蓝灰底
#f0f0f8
---
模板 06:Elegant 优雅对称
关键词:精致、传统、学术感 适合:学术、高管、传统行业、法律/财务 布局:单栏,居中对称
字体
Google Fonts: Libre Baskerville, DM Sans, Noto Serif SC, Noto Sans SC
--serif: 'Libre Baskerville', 'Noto Serif SC', serif;
--sans: 'DM Sans', 'Noto Sans SC', sans-serif;- 标题/姓名/Section 标题:
var(--serif)衬线体 - 正文/描述/Bullet/标签:
var(--sans)无衬线 - 整体以衬线体为主调
配色
:root {
--ink: #222222;
--ink-light: #777777;
--ink-muted: #999999;
--accent: #222222; /* 用深黑作为强调,不用彩色 */
--accent-light: #f5f5f5;
--bg: #fcfcfa;
--border: #e8e6e2;
--border-light: #d0ccc5;
}特征元素
- 页面背景:微暖白
#fcfcfa - 姓名:居中,大字间距 4px
- 职位:居中,斜体
- 联系方式:居中排列
- 头部底部:1.5px 实线分割
- Section 标题:居中 + 两侧装饰线(
::before/::after各一条水平线从两侧延伸) - 项目卡片:白色背景 + 0.5px 边框,有 padding
- 技能标签:0.5px 边框 + 无背景
- 个人简介:居中对齐 + 斜体
"""
Word 简历模板填充脚本
用法: python fill_docx.py <template> <resume_json> <output_path>
template: 模板名称 (cn-classic-blue / cn-sidebar-navy)
resume_json: 简历数据 JSON 文件路径
output_path: 输出 .docx 文件路径
JSON 数据格式见 fill_docx_schema.json
"""
import json
import sys
import os
from pathlib import Path
from copy import deepcopy
from docx import Document
from lxml import etree
W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
WPS_NS = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape'
TEMPLATE_DIR = Path(__file__).parent.parent.parent.parent / 'templates' / 'word'
def get_textboxes(body):
return list(body.iter(f'{{{WPS_NS}}}txbx'))
def set_textbox_text(txbx, lines, preserve_first_run_format=True):
w_p = f'{{{W_NS}}}p'
w_r = f'{{{W_NS}}}r'
w_t = f'{{{W_NS}}}t'
w_rPr = f'{{{W_NS}}}rPr'
w_pPr = f'{{{W_NS}}}pPr'
w_br = f'{{{W_NS}}}br'
txbx_content = txbx[0]
first_para = txbx_content.find(w_p)
if first_para is None:
return
first_run = first_para.find(w_r)
run_format = None
if first_run is not None:
rPr = first_run.find(w_rPr)
if rPr is not None:
run_format = deepcopy(rPr)
para_format = None
pPr = first_para.find(w_pPr)
if pPr is not None:
para_format = deepcopy(pPr)
for p in list(txbx_content.findall(w_p)):
txbx_content.remove(p)
for i, line in enumerate(lines):
new_p = etree.SubElement(txbx_content, w_p)
if para_format is not None and i == 0:
new_p.insert(0, deepcopy(para_format))
new_r = etree.SubElement(new_p, w_r)
if run_format is not None:
new_r.insert(0, deepcopy(run_format))
new_t = etree.SubElement(new_r, w_t)
new_t.text = line
new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
def fill_cn_classic_blue(doc, data):
body = doc.element.body
tbs = get_textboxes(body)
name = data.get('name', '')
age = data.get('age', '')
edu_level = data.get('education_level', '')
job_target = data.get('job_target', '')
set_textbox_text(tbs[11], [
f'姓 名: {name}',
f'年 龄: {age}',
f'学 历: {edu_level}',
f'求职意向: {job_target}',
])
phone = data.get('phone', '')
email = data.get('email', '')
wechat = data.get('wechat', '')
address = data.get('address', '')
set_textbox_text(tbs[9], [
f'手机: {phone}',
f'邮箱: {email}',
f'微信: {wechat}',
f'地址: {address}',
])
self_eval = data.get('self_evaluation', '')
set_textbox_text(tbs[8], [self_eval])
edu_lines = []
for edu in data.get('education', []):
header = f"{edu.get('major', '')} {edu.get('school', '')}({edu.get('degree', '')}) {edu.get('dates', '')}"
edu_lines.append(header)
if edu.get('courses'):
edu_lines.append(f"主修课程:{edu['courses']}")
set_textbox_text(tbs[6], edu_lines)
work = data.get('work_experience', [])
if len(work) >= 1:
w = work[0]
lines = [f"{w.get('title', '')} {w.get('company', '')} {w.get('dates', '')}"]
for d in w.get('duties', []):
lines.append(d)
set_textbox_text(tbs[4], lines)
if len(work) >= 2:
w = work[1]
lines = [f"{w.get('title', '')} {w.get('company', '')} {w.get('dates', '')}"]
for d in w.get('duties', []):
lines.append(d)
set_textbox_text(tbs[2], lines)
skills = data.get('skills', [])
if skills:
set_textbox_text(tbs[1], skills)
def fill_cn_sidebar_navy(doc, data):
body = doc.element.body
tbs = get_textboxes(body)
set_textbox_text(tbs[6], [data.get('name', '')])
age = data.get('age', '')
address = data.get('address', '')
phone = data.get('phone', '')
email = data.get('email', '')
set_textbox_text(tbs[7], [age, address, phone, email])
set_textbox_text(tbs[12], [f"求职意向:{data.get('job_target', '')}"])
top_skills = data.get('top_skills', data.get('skills', []))[:4]
skill_boxes = [tbs[8], tbs[9], tbs[10], tbs[11]]
for i, sb in enumerate(skill_boxes):
if i < len(top_skills):
set_textbox_text(sb, [top_skills[i]])
edu_lines = []
for edu in data.get('education', []):
header = f"{edu.get('dates', '')} {edu.get('school', '')} {edu.get('major', '')}({edu.get('degree', '')})"
edu_lines.append(header)
if edu.get('courses'):
edu_lines.append(edu['courses'])
set_textbox_text(tbs[13], edu_lines)
work_lines = []
for w in data.get('work_experience', []):
work_lines.append(f"{w.get('dates', '')} {w.get('company', '')} {w.get('title', '')}")
for d in w.get('duties', []):
work_lines.append(d)
set_textbox_text(tbs[15], work_lines)
awards = data.get('awards', [])
if awards:
set_textbox_text(tbs[17], awards)
self_eval = data.get('self_evaluation', '')
if self_eval:
set_textbox_text(tbs[19], [self_eval])
def replace_cell_text(cell, new_text):
lines = new_text.split('\n') if new_text else ['']
paras = cell.paragraphs
for i, line in enumerate(lines):
if i < len(paras):
_set_para_text(paras[i], line)
else:
_add_para_to_cell(cell, line, paras[-1] if paras else None)
tc = cell._element
w_p = f'{{{W_NS}}}p'
all_p = tc.findall(w_p)
for p in all_p[len(lines):]:
tc.remove(p)
def _set_para_text(para, text):
for run in para.runs:
run.text = ''
if para.runs:
para.runs[0].text = text
else:
run = para.add_run(text)
def _add_para_to_cell(cell, text, template_para):
from copy import deepcopy
w_p = f'{{{W_NS}}}p'
w_pPr = f'{{{W_NS}}}pPr'
w_r = f'{{{W_NS}}}r'
w_rPr = f'{{{W_NS}}}rPr'
w_t = f'{{{W_NS}}}t'
new_p = etree.SubElement(cell._element, w_p)
if template_para is not None:
pPr = template_para._element.find(w_pPr)
if pPr is not None:
new_p.insert(0, deepcopy(pPr))
first_run = template_para._element.find(w_r)
rPr = None
if first_run is not None:
rPr = first_run.find(w_rPr)
new_r = etree.SubElement(new_p, w_r)
if template_para is not None and first_run is not None and rPr is not None:
new_r.insert(0, deepcopy(rPr))
new_t = etree.SubElement(new_r, w_t)
new_t.text = text
new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
def _remove_para(para):
p = para._element
parent = p.getparent()
if parent is not None:
parent.remove(p)
def replace_para_text(para, new_text):
_set_para_text(para, new_text)
def fill_cn_translator(doc, data):
paras = doc.paragraphs
replace_para_text(paras[0], data.get('name', ''))
phone = data.get('phone', '')
email = data.get('email', '')
replace_para_text(paras[1], f'手机:{phone} | 邮箱:{email}')
domains = data.get('translation_domains', '英汉双语翻译')
replace_para_text(paras[2], domains)
job_target = data.get('job_target', '')
replace_para_text(paras[3], f'求职意向:{job_target}')
tables = doc.tables
self_eval = data.get('self_evaluation', '')
if self_eval:
replace_cell_text(tables[0].rows[0].cells[0], self_eval)
edu = data.get('education', [])
if len(edu) >= 1:
e = edu[0]
replace_cell_text(tables[1].rows[1].cells[0],
f"{e.get('major', '')} | {e.get('degree', '')}\n{e.get('school', '')}")
replace_cell_text(tables[1].rows[1].cells[1], e.get('dates', ''))
details = f"GPA:{e.get('gpa', '')}"
if e.get('honors'):
details += f"\n荣誉:{e['honors']}"
replace_cell_text(tables[1].rows[2].cells[0], details)
if len(edu) >= 2:
e = edu[1]
txt = f"{e.get('major', '')} | {e.get('degree', '')}\n{e.get('school', '')}"
if e.get('gpa'):
txt += f"\nGPA:{e['gpa']}"
if e.get('honors'):
txt += f"\n荣誉:{e['honors']}"
replace_cell_text(tables[1].rows[3].cells[0], txt)
replace_cell_text(tables[1].rows[3].cells[1], e.get('dates', ''))
work = data.get('work_experience', [])
work_table = tables[2]
for i, w in enumerate(work):
row_header = i * 2
row_detail = i * 2 + 1
if row_header >= len(work_table.rows):
break
replace_cell_text(work_table.rows[row_header].cells[0],
f"{w.get('title', '')}\n{w.get('company', '')}")
replace_cell_text(work_table.rows[row_header].cells[1],
f"{w.get('dates', '')}\n{w.get('city', '')}")
if row_detail < len(work_table.rows):
duties = '\n'.join(w.get('duties', []))
replace_cell_text(work_table.rows[row_detail].cells[0], duties)
activities = data.get('activities', [])
act_table = tables[3]
if len(activities) >= 1:
a = activities[0]
replace_cell_text(act_table.rows[0].cells[0],
f"{a.get('title', '')}\n{a.get('organization', '')}")
replace_cell_text(act_table.rows[0].cells[1], a.get('dates', ''))
if 1 < len(act_table.rows):
duties = '\n'.join(a.get('duties', []))
replace_cell_text(act_table.rows[1].cells[0], duties)
if len(activities) >= 2:
a = activities[1]
content = f"{a.get('title', '')}\n{a.get('organization', '')}"
duties = a.get('duties', [])
if duties:
content += '\n' + '\n'.join(duties)
replace_cell_text(act_table.rows[2].cells[0], content)
replace_cell_text(act_table.rows[2].cells[2], a.get('dates', ''))
certs = data.get('certificates', '')
tools = data.get('tools', '')
cert_text = f"证书:{certs}\n软件工具:{tools}"
replace_cell_text(tables[4].rows[0].cells[0], cert_text)
_auto_fit_row_heights(doc)
def _auto_fit_row_heights(doc):
w_trPr = f'{{{W_NS}}}trPr'
w_trHeight = f'{{{W_NS}}}trHeight'
for table in doc.tables:
for row in table.rows:
trPr = row._tr.find(w_trPr)
if trPr is not None:
trHeight = trPr.find(w_trHeight)
if trHeight is not None:
trPr.remove(trHeight)
def fill_en_traditional(doc, data):
paras = doc.paragraphs
replace_para_text(paras[0], data.get('name', ''))
contact_parts = []
if data.get('email'):
contact_parts.append(data['email'])
if data.get('phone'):
contact_parts.append(data['phone'])
replace_para_text(paras[1], ' ● '.join(contact_parts))
replace_para_text(paras[2], data.get('address', ''))
edu = data.get('education', [])
if edu:
e = edu[0]
replace_para_text(paras[6], e.get('school', ''))
degree_line = e.get('degree', '')
if e.get('dates'):
degree_line += ', ' + e['dates']
replace_para_text(paras[7], degree_line)
replace_para_text(paras[8], 'Relevant courses: ' + e.get('courses', '') if e.get('courses') else '')
paras_to_remove = []
work = data.get('work_experience', [])
if len(work) >= 1:
w = work[0]
header = f"{w.get('company', '')} | {w.get('dates', '')}"
replace_para_text(paras[12], header)
replace_para_text(paras[13], w.get('title', ''))
duties = w.get('duties', [])
duty_start = 16
for di, d in enumerate(duties):
idx = duty_start + di
if idx < len(paras):
replace_para_text(paras[idx], d)
for idx in range(duty_start + len(duties), 22):
if idx < len(paras):
paras_to_remove.append(paras[idx])
if len(work) >= 2:
w = work[1]
header = f"{w.get('company', '')} | {w.get('dates', '')}"
replace_para_text(paras[23], header)
replace_para_text(paras[24], w.get('title', ''))
duties = w.get('duties', [])
duty_start = 27
for di, d in enumerate(duties):
idx = duty_start + di
if idx < len(paras):
replace_para_text(paras[idx], d)
for idx in range(duty_start + len(duties), 33):
if idx < len(paras):
paras_to_remove.append(paras[idx])
skills = data.get('skills', [])
if skills and len(paras) > 37:
replace_para_text(paras[37], skills[0] if len(skills) > 0 else '')
if len(skills) > 1 and len(paras) > 38:
replace_para_text(paras[38], skills[1] if len(skills) > 1 else '')
interests = data.get('interests', '')
if len(paras) > 39:
replace_para_text(paras[39], 'Interest: ' + interests if interests else '')
for p in paras_to_remove:
_remove_para(p)
def fill_en_contempo(doc, data):
paras = doc.paragraphs
replace_para_text(paras[0], data.get('name', ''))
contact = data.get('address', '')
if data.get('phone'):
contact += ' | ' + data['phone']
if data.get('email'):
contact += ' | ' + data['email']
replace_para_text(paras[1], contact)
replace_para_text(paras[3], data.get('summary', ''))
tables = doc.tables
skills = data.get('skills', [])
mid = len(skills) // 2
if tables:
replace_cell_text(tables[0].rows[0].cells[0], '\n'.join(skills[:mid]) if skills else '')
replace_cell_text(tables[0].rows[0].cells[1], '\n'.join(skills[mid:]) if skills else '')
paras_to_remove = []
edu = data.get('education', [])
if edu:
e = edu[0]
edu_text = e.get('degree', '')
if e.get('dates'):
edu_text += ', ' + e['dates']
replace_para_text(paras[6], edu_text)
replace_para_text(paras[7], e.get('school', ''))
if len(paras) > 8:
replace_para_text(paras[8], e.get('certifications', '') if e.get('certifications') else '')
for idx in range(9, 11):
if idx < len(paras):
paras_to_remove.append(paras[idx])
work = data.get('work_experience', [])
if len(work) >= 1:
w = work[0]
replace_para_text(paras[12], f"{w.get('title', '')}, {w.get('dates', '')}")
replace_para_text(paras[13], f"{w.get('company', '')}")
duties = w.get('duties', [])
for di, d in enumerate(duties):
idx = 14 + di
if idx < 19:
replace_para_text(paras[idx], d)
for idx in range(14 + len(duties), 19):
if idx < len(paras):
paras_to_remove.append(paras[idx])
if len(work) >= 2:
w = work[1]
replace_para_text(paras[19], f"{w.get('title', '')}, {w.get('dates', '')}")
replace_para_text(paras[20], f"{w.get('company', '')}")
duties = w.get('duties', [])
for di, d in enumerate(duties):
idx = 21 + di
if idx < 24:
replace_para_text(paras[idx], d)
for idx in range(21 + len(duties), 24):
if idx < len(paras):
paras_to_remove.append(paras[idx])
langs = data.get('languages', [])
lang_start = 69
for li, lang in enumerate(langs):
idx = lang_start + li
if idx < len(paras):
replace_para_text(paras[idx], lang)
last_work_idx = 23 if len(work) >= 2 else 18
for idx in range(last_work_idx + 1, lang_start):
if idx < len(paras):
paras_to_remove.append(paras[idx])
lang_end = lang_start + len(langs)
for idx in range(lang_end, len(paras)):
paras_to_remove.append(paras[idx])
for p in paras_to_remove:
_remove_para(p)
def fill_en_executive(doc, data):
paras = doc.paragraphs
tables = doc.tables
replace_para_text(paras[0], data.get('name', ''))
replace_para_text(paras[1], data.get('address', ''))
contact = ''
if data.get('phone'):
contact += data['phone']
if data.get('email'):
contact += ' - ' + data['email']
replace_para_text(paras[2], contact)
replace_para_text(paras[4], data.get('summary', ''))
skills = data.get('skills', [])
mid = len(skills) // 2
if tables:
replace_cell_text(tables[0].rows[0].cells[0], '\n'.join(skills[:mid]) if skills else '')
replace_cell_text(tables[0].rows[0].cells[1], '\n'.join(skills[mid:]) if skills else '')
edu = data.get('education', [])
if edu and len(tables) > 1:
e = edu[0]
edu_text = e.get('degree', '')
if e.get('school'):
edu_text += '\n' + e['school']
if e.get('certifications'):
edu_text += '\n' + e['certifications']
replace_cell_text(tables[1].rows[0].cells[1], edu_text)
work = data.get('work_experience', [])
if len(work) >= 1 and len(tables) > 2:
w = work[0]
replace_cell_text(tables[2].rows[0].cells[0], w.get('dates', ''))
content = w.get('title', '') + '\n' + w.get('company', '')
for d in w.get('duties', []):
content += '\n' + d
replace_cell_text(tables[2].rows[0].cells[1], content)
if len(work) >= 2 and len(tables) > 3:
w = work[1]
replace_cell_text(tables[3].rows[0].cells[0], w.get('dates', ''))
content = w.get('title', '') + '\n' + w.get('company', '')
for d in w.get('duties', []):
content += '\n' + d
replace_cell_text(tables[3].rows[0].cells[1], content)
keep_indices = {0, 1, 2, 3, 4, 5, 6, 7}
for idx in range(len(paras) - 1, -1, -1):
if idx not in keep_indices:
_remove_para(paras[idx])
_auto_fit_row_heights(doc)
def _replace_merged_row(table, row_idx, col_date, col_content, date_text, content_text):
row = table.rows[row_idx]
seen = set()
for ci, cell in enumerate(row.cells):
cell_id = id(cell._element)
if cell_id in seen:
continue
seen.add(cell_id)
if ci <= col_date:
replace_cell_text(cell, date_text)
else:
replace_cell_text(cell, content_text)
def fill_en_harvard(doc, data):
t0 = doc.tables[0]
replace_cell_text(t0.rows[0].cells[0], data.get('name', ''))
replace_cell_text(t0.rows[1].cells[0], data.get('title', ''))
row3 = t0.rows[3]
seen3 = set()
parts = {'phone': data.get('phone', ''), 'address': data.get('address', ''), 'email': data.get('email', '')}
field_order = ['phone', 'address', 'email']
fi = 0
for ci, cell in enumerate(row3.cells):
cid = id(cell._element)
if cid in seen3:
continue
seen3.add(cid)
if cell.text.strip() or fi < len(field_order):
if fi < len(field_order):
replace_cell_text(cell, parts[field_order[fi]])
fi += 1
edu = data.get('education', [])
edu_rows = [7, 9, 11]
for i, e in enumerate(edu):
if i >= len(edu_rows):
break
ri = edu_rows[i]
if ri < len(t0.rows):
_replace_merged_row(t0, ri, 1, 3, e.get('dates', ''),
e.get('degree', '') + '\n' + e.get('school', '') +
('\n' + e.get('details', '') if e.get('details') else ''))
work = data.get('work_experience', [])
work_rows = [16, 18]
for i, w in enumerate(work):
if i >= len(work_rows):
break
ri = work_rows[i]
if ri < len(t0.rows):
content = w.get('title', '') + '\n' + w.get('company', '')
for d in w.get('duties', []):
content += '\n' + d
_replace_merged_row(t0, ri, 1, 3, w.get('dates', ''), content)
if len(doc.tables) > 1:
t1 = doc.tables[1]
awards = data.get('awards', [])
award_rows = [21, 23]
for i, a in enumerate(awards):
if i >= len(award_rows):
break
ri = award_rows[i]
if ri < len(t1.rows):
_replace_merged_row(t1, ri, 0, 2, a.get('date', ''), a.get('description', ''))
skills_text = '\n'.join(data.get('skills', []))
if 28 < len(t1.rows):
replace_cell_text(t1.rows[28].cells[0], skills_text)
_auto_fit_row_heights(doc)
def fill_en_standout(doc, data):
tables = doc.tables
if tables:
header_cell = tables[0].rows[0].cells[1]
paras = header_cell.paragraphs
if paras:
replace_para_text(paras[0], data.get('name', ''))
contact = data.get('address', '')
if data.get('phone'):
contact += ' ' + data['phone']
if data.get('email'):
contact += ' ' + data['email']
if len(paras) > 1:
replace_para_text(paras[1], contact)
if len(tables) > 1:
summary_cell = tables[1].rows[0].cells[1]
nested = summary_cell.tables
if nested:
target = nested[0].rows[0].cells[1] if len(nested[0].rows[0].cells) > 1 else nested[0].rows[0].cells[0]
replace_cell_text(target, data.get('summary', ''))
if len(tables) > 2:
qual_cell = tables[2].rows[0].cells[1]
nested = qual_cell.tables
skills = data.get('skills', [])
mid = len(skills) // 2
if nested:
inner = nested[0]
if inner.rows[0].cells[0].tables:
double_nested = inner.rows[0].cells[0].tables[0]
replace_cell_text(double_nested.rows[0].cells[0], '\n'.join(skills[:mid]))
replace_cell_text(double_nested.rows[0].cells[1], '\n'.join(skills[mid:]))
else:
replace_cell_text(inner.rows[0].cells[0], '\n'.join(skills[:mid]))
if len(inner.rows[0].cells) > 1:
replace_cell_text(inner.rows[0].cells[1], '\n'.join(skills[mid:]))
if len(tables) > 3:
edu_cell = tables[3].rows[0].cells[1]
nested = edu_cell.tables
edu = data.get('education', [])
if nested and edu:
e = edu[0]
target = nested[0].rows[0].cells[1] if len(nested[0].rows[0].cells) > 1 else nested[0].rows[0].cells[0]
edu_text = e.get('degree', '') + '\n' + e.get('school', '')
if e.get('certifications'):
edu_text += '\n' + e['certifications']
replace_cell_text(target, edu_text)
if len(tables) > 4:
work_cell = tables[4].rows[0].cells[1]
nested_tables = work_cell.tables
work = data.get('work_experience', [])
for i, w in enumerate(work):
if i >= len(nested_tables):
break
nt = nested_tables[i]
target = nt.rows[0].cells[1] if len(nt.rows[0].cells) > 1 else nt.rows[0].cells[0]
content = f"{w.get('title', '')} {w.get('dates', '')}"
content += f"\n{w.get('company', '')}"
for d in w.get('duties', []):
content += '\n' + d
replace_cell_text(target, content)
_auto_fit_row_heights(doc)
def fill_en_refined_black(doc, data):
paras = doc.paragraphs
tables = doc.tables
replace_para_text(paras[0], data.get('name', ''))
contact_parts = []
if data.get('address'):
contact_parts.append(data['address'])
if data.get('phone'):
contact_parts.append(data['phone'])
if data.get('email'):
contact_parts.append(data['email'])
replace_para_text(paras[4], ' ♦ '.join(contact_parts))
replace_para_text(paras[7], data.get('summary', ''))
skills = data.get('skills', [])
mid = len(skills) // 2
if tables:
replace_cell_text(tables[0].rows[0].cells[0], '\n'.join(skills[:mid]) if skills else '')
replace_cell_text(tables[0].rows[0].cells[1], '\n'.join(skills[mid:]) if skills else '')
edu = data.get('education', [])
if edu:
e = edu[0]
replace_para_text(paras[10], e.get('degree', ''))
replace_para_text(paras[11], e.get('school', ''))
replace_para_text(paras[12], e.get('certifications', '') if e.get('certifications') else '')
paras_to_remove = []
for idx in range(13, 15):
if idx < len(paras):
paras_to_remove.append(paras[idx])
work = data.get('work_experience', [])
if len(work) >= 1:
w = work[0]
replace_para_text(paras[16], f"{w.get('title', '')}, {w.get('dates', '')}")
replace_para_text(paras[17], f"{w.get('company', '')}")
duties = w.get('duties', [])
for di, d in enumerate(duties):
idx = 18 + di
if idx < 23:
replace_para_text(paras[idx], d)
for idx in range(18 + len(duties), 23):
if idx < len(paras):
paras_to_remove.append(paras[idx])
if len(work) >= 2:
w = work[1]
replace_para_text(paras[23], f"{w.get('title', '')}, {w.get('dates', '')}")
replace_para_text(paras[24], f"{w.get('company', '')}")
duties = w.get('duties', [])
for di, d in enumerate(duties):
idx = 25 + di
if idx < 28:
replace_para_text(paras[idx], d)
for idx in range(25 + len(duties), 28):
if idx < len(paras):
paras_to_remove.append(paras[idx])
for idx in range(28, 69):
if idx < len(paras):
paras_to_remove.append(paras[idx])
langs = data.get('languages', [])
if 70 < len(paras):
paras_to_remove.append(paras[70])
for li, lang in enumerate(langs):
idx = 71 + li
if idx < len(paras):
replace_para_text(paras[idx], lang)
for idx in range(71 + len(langs), 75):
if idx < len(paras):
paras_to_remove.append(paras[idx])
awards = data.get('awards', [])
for ai, a in enumerate(awards):
idx = 76 + ai
if idx < len(paras):
replace_para_text(paras[idx], a.get('description', ''))
for idx in range(76 + len(awards), 78):
if idx < len(paras):
paras_to_remove.append(paras[idx])
for idx in range(78, len(paras)):
paras_to_remove.append(paras[idx])
for p in paras_to_remove:
_remove_para(p)
TEMPLATES = {
'cn-classic-blue': {
'file': 'cn-classic-blue.docx',
'fill': fill_cn_classic_blue,
'name_cn': '经典蓝灰职业风',
},
'cn-sidebar-navy': {
'file': 'cn-sidebar-navy.docx',
'fill': fill_cn_sidebar_navy,
'name_cn': '深蓝左右分栏风',
},
'cn-translator': {
'file': 'cn-translator.docx',
'fill': fill_cn_translator,
'name_cn': '中文翻译简约风',
},
'en-traditional': {
'file': 'en-traditional-cv.docx',
'fill': fill_en_traditional,
'name_cn': 'Traditional 简约英文',
},
'en-contempo': {
'file': 'en-contempo_success_steel_CV.docx',
'fill': fill_en_contempo,
'name_cn': 'Contempo 钢灰英文',
},
'en-executive': {
'file': 'en-executive_ecru_CV.docx',
'fill': fill_en_executive,
'name_cn': 'Executive 米色英文',
},
# TODO: 排版问题待修复
# 'en-harvard': {
# 'file': 'en-Harvard-深蓝色.docx',
# 'fill': fill_en_harvard,
# 'name_cn': 'Harvard 深蓝学术风',
# },
# 'en-standout': {
# 'file': 'en-standout_CV.docx',
# 'fill': fill_en_standout,
# 'name_cn': 'Standout 左栏标题风',
# },
'en-refined-black': {
'file': 'en-refined_black_CV.docx',
'fill': fill_en_refined_black,
'name_cn': 'Refined Black 黑色精致风',
},
}
def fill_template(template_name, data, output_path):
if template_name not in TEMPLATES:
print(f"Error: Unknown template '{template_name}'")
print(f"Available: {', '.join(TEMPLATES.keys())}")
sys.exit(1)
tmpl = TEMPLATES[template_name]
template_path = TEMPLATE_DIR / tmpl['file']
if not template_path.exists():
print(f"Error: Template file not found: {template_path}")
sys.exit(1)
doc = Document(str(template_path))
tmpl['fill'](doc, data)
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(output))
print(f"✅ Word 简历已生成: {output}")
print(f" 模板: {tmpl['name_cn']} ({template_name})")
def main():
if len(sys.argv) < 4:
print(__doc__)
print("\nAvailable templates:")
for k, v in TEMPLATES.items():
print(f" {k} — {v['name_cn']}")
sys.exit(1)
template_name = sys.argv[1]
json_path = sys.argv[2]
output_path = sys.argv[3]
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
fill_template(template_name, data, output_path)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
HTML → PDF 转换脚本(使用 Playwright)
用法:
python scripts/generate_pdf.py <html文件路径> [pdf输出路径]
示例:
python scripts/generate_pdf.py 周静儿-AI产品经理简历.html
python scripts/generate_pdf.py resume.html output/resume.pdf
依赖:
pip install playwright
playwright install chromium
"""
import sys
from pathlib import Path
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("错误:需要安装 Playwright")
print("运行以下命令安装:")
print(" pip install playwright")
print(" playwright install chromium")
sys.exit(1)
def html_to_pdf(html_path, pdf_path=None):
html_path = Path(html_path).resolve()
if not html_path.exists():
print(f"错误:找不到文件 {html_path}")
return None
if pdf_path is None:
pdf_path = html_path.with_suffix('.pdf')
else:
pdf_path = Path(pdf_path).resolve()
pdf_path.parent.mkdir(parents=True, exist_ok=True)
html_content = html_path.read_text(encoding='utf-8')
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_content(html_content, wait_until="networkidle")
page.wait_for_timeout(2000)
page.evaluate("document.body.classList.add('pdf-mode')")
page.pdf(
path=str(pdf_path),
format="A4",
margin={"top": "0mm", "right": "0mm", "bottom": "0mm", "left": "0mm"},
print_background=True,
)
browser.close()
print(f"PDF 已生成:{pdf_path}")
return pdf_path
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法:python scripts/generate_pdf.py <html文件路径> [pdf输出路径]")
sys.exit(1)
html_file = sys.argv[1]
pdf_file = sys.argv[2] if len(sys.argv) > 2 else None
html_to_pdf(html_file, pdf_file)
#!/usr/bin/env python3
"""
照片处理脚本:裁剪为 3:4 比例 → 压缩 → 转 base64
用法:
python scripts/process_photo.py <图片路径> [最大宽度]
示例:
python scripts/process_photo.py photo.jpg
python scripts/process_photo.py photo.png 150
输出:
将 base64 字符串打印到标准输出,可直接嵌入 HTML 的 <img src="data:image/jpeg;base64,...">
依赖:
pip install Pillow
"""
import sys
import io
import base64
from pathlib import Path
try:
from PIL import Image
except ImportError:
print("错误:需要安装 Pillow")
print("运行以下命令安装:")
print(" pip install Pillow")
sys.exit(1)
def process_photo(image_path, ratio=(3, 4), max_width=120, quality=85):
img = Image.open(image_path)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
w, h = img.size
target_ratio = ratio[0] / ratio[1]
current_ratio = w / h
if current_ratio > target_ratio:
new_w = int(h * target_ratio)
left = (w - new_w) // 2
img = img.crop((left, 0, left + new_w, h))
elif current_ratio < target_ratio:
new_h = int(w / target_ratio)
top = (h - new_h) // 2
img = img.crop((0, top, w, top + new_h))
if img.width > max_width:
scale = max_width / img.width
new_size = (max_width, int(img.height * scale))
img = img.resize(new_size, Image.LANCZOS)
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=quality, optimize=True)
b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return b64
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法:python scripts/process_photo.py <图片路径> [最大宽度]")
sys.exit(1)
image_path = sys.argv[1]
max_width = int(sys.argv[2]) if len(sys.argv) > 2 else 120
if not Path(image_path).exists():
print(f"错误:找不到文件 {image_path}")
sys.exit(1)
result = process_photo(image_path, max_width=max_width)
print(result)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerForge — 7种简历模板预览</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Source+Sans+3:wght@300;400;600;700&family=DM+Sans:wght@300;400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Outfit:wght@200;300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#111;font-family:'DM Sans',sans-serif;color:#fff;min-height:100vh}
.hdr{text-align:center;padding:44px 24px 8px}
.hdr h1{font-family:'Space Grotesk',sans-serif;font-size:22px;font-weight:300;letter-spacing:6px;text-transform:uppercase}
.hdr p{margin-top:6px;font-size:12px;color:rgba(255,255,255,0.3);font-weight:300}
.grid{max-width:1200px;margin:24px auto 60px;padding:0 20px;display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:24px}
.card{background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:10px;overflow:hidden;transition:all 0.3s}
.card:hover{border-color:rgba(255,255,255,0.15);transform:translateY(-2px);box-shadow:0 12px 40px rgba(0,0,0,0.3)}
.lbl{padding:12px 16px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(255,255,255,0.04)}
.lbl-n{font-family:'Space Grotesk',sans-serif;font-size:12px;font-weight:500;letter-spacing:1px;color:rgba(255,255,255,0.7)}
.lbl-t{font-size:9px;font-weight:500;letter-spacing:1.5px;text-transform:uppercase;padding:2px 8px;border-radius:20px;background:rgba(255,255,255,0.06);color:rgba(255,255,255,0.35)}
.pv{padding:10px;height:500px;overflow:hidden}
.m{width:100%;height:100%;border-radius:2px;overflow:hidden;font-size:7.5px;line-height:1.4}
/* == Shared micro-classes == */
.ph{background:#ddd;flex-shrink:0;border-radius:2px}
/* == T1: EDITORIAL == */
.t1{background:#faf9f7;padding:18px;font-family:'Source Sans 3','Noto Sans SC',sans-serif}
.t1 .hd{display:flex;justify-content:space-between;align-items:flex-start}
.t1 .nm{font-family:'Playfair Display','Noto Serif SC',serif;font-size:24px;font-weight:700;color:#1a1f2e}
.t1 .ne{font-size:8px;color:#bbb;letter-spacing:3px;margin-left:6px;font-family:'Playfair Display',serif}
.t1 .tl{font-family:'Playfair Display',serif;font-size:10px;color:#2d6b5f;font-weight:600;margin-top:2px}
.t1 .ct{margin-top:6px;font-size:6.5px;color:#999;display:flex;gap:8px}
.t1 .ct span::before{content:'✦ ';color:#2d6b5f}
.t1 .dv{height:2.5px;background:linear-gradient(90deg,#2d6b5f,#d4a853,#2d6b5f);margin:8px 0;border-radius:2px}
.t1 .sc{font-family:'Playfair Display',serif;font-size:7px;font-weight:700;letter-spacing:3px;text-transform:uppercase;color:#2d6b5f;margin:10px 0 5px}
.t1 .sc::before{content:'— ';color:#d4a853}
.t1 .sm{font-size:7px;color:#555;line-height:1.55;font-weight:300}
.t1 .sm b{color:#1a1f2e;font-weight:600}
.t1 .cd{border-left:2px solid #2d6b5f;background:#fff;padding:5px 7px;margin-bottom:4px}
.t1 .ct2{font-family:'Playfair Display',serif;font-size:8px;font-weight:600;color:#1a1f2e}
.t1 .cd2{font-size:6px;color:#777;margin-top:1px;line-height:1.4}
.t1 .tg{display:flex;gap:2px;margin-top:2px;flex-wrap:wrap}
.t1 .tg span{font-size:5px;background:#e8f4f0;color:#2d6b5f;padding:1px 3px;border-radius:1px}
/* == T2: MINIMAL == */
.t2{background:#fff;padding:22px 20px;font-family:'DM Sans','Noto Sans SC',sans-serif}
.t2 .nm{font-size:20px;font-weight:700;color:#111;letter-spacing:1px}
.t2 .tl{font-size:8px;color:#aaa;font-weight:300;letter-spacing:3px;text-transform:uppercase;margin-top:1px}
.t2 .ct{margin-top:8px;font-size:6.5px;color:#ccc;display:flex;gap:14px}
.t2 .dv{height:0.5px;background:#e5e5e5;margin:10px 0}
.t2 .sc{font-size:6.5px;font-weight:600;letter-spacing:3px;text-transform:uppercase;color:#111;margin:12px 0 6px}
.t2 .sm{font-size:7px;color:#777;line-height:1.65;font-weight:300}
.t2 .cd{padding:5px 0;border-bottom:0.5px solid #f5f5f5}
.t2 .ct2{font-size:8px;font-weight:600;color:#111}
.t2 .cd2{font-size:6px;color:#999;margin-top:1px;line-height:1.4}
.t2 .tg{display:flex;gap:2px;margin-top:2px}
.t2 .tg span{font-size:5px;border:0.5px solid #ddd;color:#777;padding:1px 4px}
/* == T3: SIDEBAR NAVY == */
.t3{display:flex;height:100%;font-family:'Outfit','Noto Sans SC',sans-serif;background:#fff}
.t3 .side{width:34%;background:#1e293b;color:#fff;padding:14px 10px;flex-shrink:0}
.t3 .mn{padding:14px 12px;flex:1;color:#333}
.t3 .ph3{width:50px;height:50px;border-radius:50%;background:#334155;margin:0 auto 6px;border:1.5px solid #475569}
.t3 .nm{font-size:12px;font-weight:700;text-align:center}
.t3 .tl{font-size:6px;text-align:center;color:#94a3b8;font-weight:300;margin-top:1px}
.t3 .ss{font-size:5.5px;font-weight:600;letter-spacing:2px;text-transform:uppercase;color:#60a5fa;margin:10px 0 4px;padding-bottom:2px;border-bottom:0.5px solid #334155}
.t3 .si{font-size:6px;color:#cbd5e1;margin-bottom:1px;font-weight:300}
.t3 .si b{color:#fff;font-weight:500}
.t3 .st{font-size:5px;background:rgba(96,165,250,0.15);color:#93c5fd;padding:1px 4px;border-radius:1px;display:inline-block;margin:1px 1px}
.t3 .ms{font-size:7px;font-weight:600;color:#1e293b;margin:0 0 5px;padding-bottom:2px;border-bottom:1px solid #eee;letter-spacing:1px}
.t3 .cd{margin-bottom:5px}
.t3 .ct2{font-size:7.5px;font-weight:600;color:#1e293b}
.t3 .cs{font-size:5px;color:#999}
.t3 .cd2{font-size:6px;color:#64748b;margin-top:1px;line-height:1.3}
/* == T4: SIDEBAR DARK == */
.t4{display:flex;height:100%;font-family:'Outfit','Noto Sans SC',sans-serif;background:#fff}
.t4 .side{width:33%;background:#2c2c2c;color:#fff;padding:14px 10px;flex-shrink:0}
.t4 .mn{padding:14px 12px;flex:1;color:#333}
.t4 .nm{font-size:13px;font-weight:700;text-align:center}
.t4 .tl{font-size:6px;text-align:center;color:#aaa;font-weight:300;margin-top:1px}
.t4 .ss{font-size:5.5px;font-weight:600;letter-spacing:2px;text-transform:uppercase;color:#999;margin:10px 0 4px;padding-bottom:2px;border-bottom:0.5px solid #444}
.t4 .si{font-size:6px;color:#ccc;margin-bottom:1px;font-weight:300}
.t4 .si b{color:#fff;font-weight:500}
.t4 .st{font-size:5px;background:rgba(255,255,255,0.1);color:#ddd;padding:1px 4px;border-radius:1px;display:inline-block;margin:1px 1px}
.t4 .ms{font-size:7px;font-weight:700;color:#2c2c2c;margin:0 0 5px;padding-bottom:2px;border-bottom:1px solid #eee;letter-spacing:1px}
.t4 .cd{margin-bottom:5px}
.t4 .ct2{font-size:7.5px;font-weight:600;color:#2c2c2c}
.t4 .cs{font-size:5px;color:#999}
.t4 .cd2{font-size:6px;color:#666;margin-top:1px;line-height:1.3}
/* == T5: DARK HEADER == */
.t5{background:#fff;font-family:'DM Sans','Noto Sans SC',sans-serif;height:100%;display:flex;flex-direction:column}
.t5 .hd{background:#1a1a2e;color:#fff;padding:14px 16px;display:flex;justify-content:space-between;align-items:center}
.t5 .nm{font-size:18px;font-weight:700}
.t5 .tl{font-size:7px;color:rgba(255,255,255,0.45);font-weight:300;margin-top:1px;letter-spacing:1px}
.t5 .ct{margin-top:5px;font-size:6px;color:rgba(255,255,255,0.35);display:flex;gap:8px}
.t5 .bd{padding:12px 16px;flex:1;overflow:hidden}
.t5 .sc{font-size:6.5px;font-weight:600;color:#fff;background:#1a1a2e;display:inline-block;padding:1.5px 8px;border-radius:8px;margin:8px 0 5px;letter-spacing:1px}
.t5 .sc:first-child{margin-top:0}
.t5 .sm{font-size:7px;color:#555;line-height:1.55;font-weight:300}
.t5 .cd{padding:4px 0;border-bottom:0.5px solid #f0f0f0}
.t5 .ct2{font-size:7.5px;font-weight:600;color:#1a1a2e}
.t5 .cd2{font-size:6px;color:#888;margin-top:1px;line-height:1.3}
.t5 .tg{display:flex;gap:2px;margin-top:2px;flex-wrap:wrap}
.t5 .tg span{font-size:5px;background:#f0f0f8;color:#1a1a2e;padding:1px 4px;border-radius:1px}
/* == T6: ELEGANT == */
.t6{background:#fcfcfa;font-family:'Libre Baskerville','Noto Serif SC',serif;padding:18px 16px;height:100%}
.t6 .hd{text-align:center;padding-bottom:10px;border-bottom:1.5px solid #333}
.t6 .nm{font-size:22px;font-weight:700;color:#222;letter-spacing:4px}
.t6 .tl{font-size:8px;color:#999;font-weight:400;margin-top:2px;font-style:italic;letter-spacing:1px}
.t6 .ct{margin-top:6px;font-size:6px;color:#bbb;display:flex;justify-content:center;gap:12px;font-family:'DM Sans',sans-serif}
.t6 .ct span::before{content:'· ';color:#ccc;font-weight:700}
.t6 .sc{font-size:7px;font-weight:700;color:#222;margin:12px 0 5px;text-align:center;letter-spacing:3px;text-transform:uppercase;position:relative}
.t6 .sc::before,.t6 .sc::after{content:'';position:absolute;top:50%;height:0.5px;background:#ccc;width:20%}
.t6 .sc::before{left:0}
.t6 .sc::after{right:0}
.t6 .sm{font-size:7px;color:#777;line-height:1.65;font-style:italic;text-align:center}
.t6 .cd{margin-bottom:4px;padding:4px 8px;border:0.5px solid #e8e6e2;background:#fff}
.t6 .ct2{font-size:8px;font-weight:700;color:#222}
.t6 .cd2{font-size:6px;color:#999;margin-top:1px;line-height:1.4;font-style:italic}
.t6 .tg{display:flex;gap:2px;margin-top:2px;flex-wrap:wrap}
.t6 .tg span{font-size:5px;border:0.5px solid #d0ccc5;color:#666;padding:1px 4px;font-family:'DM Sans',sans-serif}
</style>
</head>
<body>
<div class="hdr"><h1>CAREERFORGE TEMPLATES</h1><p>6 种简历模板风格预览</p></div>
<div class="grid">
<!-- T1 Editorial -->
<div class="card"><div class="lbl"><span class="lbl-n">01 · Editorial 杂志编辑风</span><span class="lbl-t">经典</span></div><div class="pv"><div class="m t1">
<div class="hd"><div style="flex:1"><div class="nm">林悦然<span class="ne">YUERAN LIN</span></div><div class="tl">AI 产品经理 — AI Product Builder</div><div class="ct"><span>13888886666</span><span>linyueran@email.com</span><span>上海</span></div></div><div class="ph" style="width:50px;height:66px"></div></div>
<div class="dv"></div>
<div class="sc">个 人 简 介</div>
<div class="sm"><b>3 年产品经验</b>,重度使用 Claude Code / Cursor 等 AI 编码工具。独立搭建多个内部提效工具,<b>团队效率提升 40%</b>。</div>
<div class="sc">A I 项 目 经 验</div>
<div class="cd"><div class="ct2">MemoAI — 智能记忆助手(全栈)</div><div class="cd2">多层记忆架构,语义检索引擎,API 工具集成。</div><div class="tg"><span>Claude Code</span><span>MCP</span><span>全栈</span></div></div>
<div class="cd"><div class="ct2">TeamFlow — 团队协作平台</div><div class="cd2">任务管理、实时协作、数据看板。</div><div class="tg"><span>HTML/JS</span><span>Supabase</span></div></div>
<div class="cd"><div class="ct2">DataViz — 数据可视化工具(开源)</div><div class="cd2">多种图表类型,一键导出,已开源。</div><div class="tg"><span>开源</span><span>模板系统</span></div></div>
<div class="cd"><div class="ct2">CodePath — 编程学习平台</div><div class="cd2">Python 入门讲义+练习+错题本。</div><div class="tg"><span>教育产品</span></div></div>
</div></div></div>
<!-- T2 Minimal -->
<div class="card"><div class="lbl"><span class="lbl-n">02 · Minimal 极简主义</span><span class="lbl-t">克制</span></div><div class="pv"><div class="m t2">
<div class="nm">林悦然</div><div class="tl">AI Product Manager</div>
<div class="ct"><span>13888886666</span><span>linyueran@email.com</span><span>上海</span></div>
<div class="dv"></div>
<div class="sc">ABOUT</div>
<div class="sm">3 年产品经验,1 年+ AI 辅助开发经验。独立搭建多个内部提效工具,团队效率提升 40%。</div>
<div class="dv"></div>
<div class="sc">PROJECTS</div>
<div class="cd"><div class="ct2">MemoAI — 智能记忆助手</div><div class="cd2">全栈,多层记忆架构,语义检索引擎,API 工具集成。</div><div class="tg"><span>Claude Code</span><span>MCP</span><span>全栈</span></div></div>
<div class="cd"><div class="ct2">TeamFlow — 团队协作平台</div><div class="cd2">任务管理、实时协作、数据看板。</div><div class="tg"><span>HTML/JS</span><span>Supabase</span></div></div>
<div class="cd"><div class="ct2">DataViz — 数据可视化工具</div><div class="cd2">开源,多种图表类型,一键导出。</div><div class="tg"><span>开源</span><span>Vibe Coding</span></div></div>
<div class="cd"><div class="ct2">CodePath — 编程学习平台</div><div class="cd2">Python 入门讲义+练习+错题本。</div><div class="tg"><span>教育产品</span></div></div>
<div class="dv"></div>
<div class="sc">EXPERIENCE</div>
<div class="cd"><div class="ct2">产品运营经理 · 星辰科技</div><div class="cd2">DAU 提升 30%,搭建用户增长体系。</div></div>
</div></div></div>
<!-- T3 Sidebar Navy -->
<div class="card"><div class="lbl"><span class="lbl-n">03 · Sidebar Navy 深蓝双栏</span><span class="lbl-t">结构化</span></div><div class="pv"><div class="m t3">
<div class="side">
<div class="ph3"></div>
<div class="nm">林悦然</div><div class="tl">AI 产品经理</div>
<div class="ss">联系方式</div><div class="si">13888886666</div><div class="si">linyueran@email.com</div><div class="si">上海</div>
<div class="ss">技能</div><div><span class="st">Claude Code</span><span class="st">Codex</span><span class="st">Cursor</span><span class="st">Trae</span><span class="st">HTML</span><span class="st">JS</span><span class="st">Supabase</span><span class="st">MCP</span><span class="st">Figma</span></div>
<div class="ss">语言</div><div class="si"><b>英语</b> CET-6 580</div><div class="si"><b>日语</b> N2</div>
<div class="ss">教育</div><div class="si"><b>复旦大学</b></div><div class="si" style="font-size:5px;color:#94a3b8">计算机硕士 3.7/4.0</div><div class="si" style="margin-top:3px"><b>南京大学</b></div><div class="si" style="font-size:5px;color:#94a3b8">软件工程学士 3.8/4.0</div>
</div>
<div class="mn">
<div class="ms">项目经验</div>
<div class="cd"><div class="ct2">MemoAI — 智能记忆助手</div><div class="cs">全栈 · Vibe Coding</div><div class="cd2">多层记忆架构,语义检索引擎,API 工具集成。</div></div>
<div class="cd"><div class="ct2">TeamFlow — 团队协作平台</div><div class="cs">前端 · Supabase</div><div class="cd2">任务管理、实时协作、权限控制。</div></div>
<div class="cd"><div class="ct2">DataViz — 可视化工具(开源)</div><div class="cs">开源 · GitHub</div><div class="cd2">多种图表类型,一键导出。</div></div>
<div class="cd"><div class="ct2">CodePath — 编程学习平台</div><div class="cs">教育产品</div><div class="cd2">Python 入门讲义+练习+错题本。</div></div>
<div class="ms" style="margin-top:8px">工作经历</div>
<div class="cd"><div class="ct2">产品运营经理</div><div class="cs">星辰科技 · 2023-至今</div><div class="cd2">DAU 提升 30%,搭建增长体系。</div></div>
</div>
</div></div></div>
<!-- T4 Sidebar Dark -->
<div class="card"><div class="lbl"><span class="lbl-n">04 · Sidebar Dark 深灰左栏</span><span class="lbl-t">沉稳</span></div><div class="pv"><div class="m t4">
<div class="side">
<div class="ph" style="width:52px;height:68px;margin:0 auto 6px;background:#555"></div>
<div class="nm">林悦然</div><div class="tl">AI 产品经理</div>
<div class="ss">联系方式</div><div class="si">13888886666</div><div class="si">linyueran@email.com</div><div class="si">上海</div>
<div class="ss">技能工具</div><div><span class="st">Claude Code</span><span class="st">Codex</span><span class="st">Cursor</span><span class="st">Trae</span><span class="st">Figma</span><span class="st">HTML</span><span class="st">JS</span><span class="st">Supabase</span><span class="st">MCP</span></div>
<div class="ss">语言</div><div class="si"><b>英语</b> CET-6 580</div><div class="si"><b>日语</b> N2</div>
<div class="ss">教育</div><div class="si"><b>复旦</b> 计算机硕士 3.7</div><div class="si"><b>南大</b> 软件工程学士 3.8</div>
</div>
<div class="mn">
<div class="ms">个人简介</div>
<div class="cd"><div class="cd2">3 年产品经验,1 年+ AI 辅助开发经验。独立搭建多个提效工具,团队效率提升 40%。</div></div>
<div class="ms" style="margin-top:8px">项目经验</div>
<div class="cd"><div class="ct2">MemoAI — 智能记忆助手</div><div class="cs">全栈</div><div class="cd2">多层记忆架构,语义检索引擎。</div></div>
<div class="cd"><div class="ct2">TeamFlow — 团队协作平台</div><div class="cs">前端 · Supabase</div><div class="cd2">实时协作、权限管理。</div></div>
<div class="cd"><div class="ct2">DataViz — 可视化工具(开源)</div><div class="cs">GitHub</div><div class="cd2">多种图表类型,一键导出。</div></div>
<div class="cd"><div class="ct2">CodePath — 编程学习平台</div><div class="cs">教育产品</div><div class="cd2">Python 入门讲义+练习+错题本。</div></div>
<div class="ms" style="margin-top:8px">工作经历</div>
<div class="cd"><div class="ct2">产品运营经理</div><div class="cs">星辰科技 · 2023-至今</div></div>
<div class="cd"><div class="ct2">产品助理</div><div class="cs">云途互联 · 2021-2023</div></div>
</div>
</div></div></div>
<!-- T5 Dark Header -->
<div class="card"><div class="lbl"><span class="lbl-n">05 · Dark Header 深色头部</span><span class="lbl-t">醒目</span></div><div class="pv"><div class="m t5">
<div class="hd"><div style="flex:1"><div class="nm">林悦然</div><div class="tl">AI 产品经理 · AI Product Builder</div><div class="ct"><span>13888886666</span><span>linyueran@email.com</span><span>上海</span></div></div><div class="ph" style="width:46px;height:60px;background:#333;border:1px solid rgba(255,255,255,0.15)"></div></div>
<div class="bd">
<div class="sc">个人简介</div>
<div class="sm">3 年产品经验,重度使用 Claude Code 等 AI 编码工具。独立搭建多个内部提效工具,团队效率提升 40%。</div>
<div class="sc">项目经验</div>
<div class="cd"><div class="ct2">MemoAI — 智能记忆助手(全栈)</div><div class="cd2">多层记忆架构,语义检索引擎,API 工具集成。</div><div class="tg"><span>Claude Code</span><span>MCP</span><span>全栈</span></div></div>
<div class="cd"><div class="ct2">TeamFlow — 团队协作平台</div><div class="cd2">任务管理、实时协作、数据看板。</div><div class="tg"><span>HTML/JS</span><span>Supabase</span></div></div>
<div class="cd"><div class="ct2">DataViz — 数据可视化工具(开源)</div><div class="cd2">多种图表类型,一键导出。</div><div class="tg"><span>开源</span><span>模板系统</span></div></div>
<div class="cd"><div class="ct2">CodePath — 编程学习平台</div><div class="cd2">Python 入门讲义+练习+错题本。</div><div class="tg"><span>教育产品</span></div></div>
<div class="sc">工作经历</div>
<div class="cd"><div class="ct2">产品运营经理 · 星辰科技</div><div class="cd2">DAU 提升 30%,搭建用户增长体系。</div></div>
</div>
</div></div></div>
<!-- T6 Elegant -->
<div class="card"><div class="lbl"><span class="lbl-n">06 · Elegant 优雅对称</span><span class="lbl-t">精致</span></div><div class="pv"><div class="m t6">
<div class="hd">
<div class="nm">林 悦 然</div>
<div class="tl">AI 产品经理 — AI Product Builder</div>
<div class="ct"><span>13888886666</span><span>linyueran@email.com</span><span>上海</span></div>
</div>
<div class="sc">个人简介</div>
<div class="sm">3 年产品经验,重度使用 Claude Code 等 AI 编码工具。独立搭建多个提效工具,团队效率提升 40%。</div>
<div class="sc">项目经验</div>
<div class="cd"><div class="ct2">MemoAI — 智能记忆助手(全栈)</div><div class="cd2">多层记忆架构,语义检索引擎,API 集成。</div><div class="tg"><span>Claude Code</span><span>MCP</span><span>全栈</span></div></div>
<div class="cd"><div class="ct2">TeamFlow — 团队协作平台</div><div class="cd2">任务管理、实时协作、数据看板。</div><div class="tg"><span>HTML/JS</span><span>Supabase</span></div></div>
<div class="cd"><div class="ct2">DataViz — 数据可视化工具(开源)</div><div class="cd2">多种图表类型,一键导出,已开源。</div><div class="tg"><span>开源</span><span>Vibe Coding</span></div></div>
<div class="cd"><div class="ct2">CodePath — 编程学习平台</div><div class="cd2">Python 入门讲义+练习+错题本。</div><div class="tg"><span>教育产品</span></div></div>
<div class="sc">工作经历</div>
<div class="cd"><div class="ct2">产品运营经理 · 星辰科技</div><div class="cd2">DAU 提升 30%,搭建增长体系。</div></div>
</div></div></div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="{{LANG}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{NAME}} — {{TARGET_TITLE}}</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400&family=Noto+Serif+SC:wght@400;600;700&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #1a1f2e;
--ink-light: #4a5568;
--ink-muted: #718096;
--accent: #2d6b5f;
--accent-light: #e8f4f0;
--accent-warm: #d4a853;
--cream: #faf9f7;
--card: #ffffff;
--border: #e2e0dc;
--border-light: #f0eee9;
--shadow: rgba(26, 31, 46, 0.06);
--serif: 'Playfair Display', 'Noto Serif SC', serif;
--sans: 'Source Sans 3', 'Noto Sans SC', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--sans);
background: #f0efec;
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
/* ========== EXPORT BUTTON ========== */
.export-bar {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 1000;
background: rgba(26, 31, 46, 0.95);
backdrop-filter: blur(12px);
padding: 12px 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.export-btn {
background: var(--accent);
color: #fff;
border: none;
padding: 10px 28px;
font-family: var(--sans);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.export-btn:hover {
background: #245a50;
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(45, 107, 95, 0.3);
}
.export-btn:active { transform: translateY(0); }
.export-btn svg { width: 16px; height: 16px; fill: currentColor; }
.export-hint { color: rgba(255,255,255,0.5); font-size: 13px; }
.edit-btn {
background: transparent;
color: rgba(255,255,255,0.8);
border: 1.5px solid rgba(255,255,255,0.3);
padding: 10px 28px;
font-family: var(--sans);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
flex-shrink: 0;
}
.edit-btn:hover {
background: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.5);
}
.edit-btn.active {
background: var(--accent-warm);
color: #1a1f2e;
border-color: var(--accent-warm);
}
.edit-btn svg { width: 16px; height: 16px; fill: currentColor; }
.resume.editing {
outline: 2px dashed var(--accent);
outline-offset: 4px;
overflow: visible;
}
.resume.editing *:hover {
outline: 1px dashed rgba(45, 107, 95, 0.3);
outline-offset: 1px;
}
/* ========== LOADING OVERLAY ========== */
.loading-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(26, 31, 46, 0.85);
backdrop-filter: blur(8px);
z-index: 2000;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 20px;
}
.loading-overlay.active { display: flex; }
.loading-spinner {
width: 40px; height: 40px;
border: 3px solid rgba(255,255,255,0.15);
border-top-color: var(--accent-warm);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text {
color: #fff;
font-family: var(--sans);
font-size: 15px;
font-weight: 300;
letter-spacing: 1px;
}
/* ========== RESUME CONTAINER ========== */
.resume {
max-width: 800px;
margin: 72px auto 60px;
background: var(--cream);
box-shadow: 0 2px 40px var(--shadow), 0 0 0 1px var(--border-light);
position: relative;
overflow: hidden;
}
.resume::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
background: linear-gradient(90deg, var(--accent), var(--accent-warm), var(--accent));
}
/* ========== HEADER ========== */
.header {
padding: 36px 44px 28px;
position: relative;
}
.header::after {
content: '';
position: absolute;
bottom: 0;
left: 44px; right: 44px;
height: 1px;
background: var(--border);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
}
.header-left { flex: 1; }
.header-photo {
width: 96px;
height: 128px;
border-radius: 4px;
object-fit: cover;
object-position: center top;
border: 1px solid var(--border);
flex-shrink: 0;
margin-top: 4px;
}
.name-row {
display: flex;
align-items: baseline;
gap: 16px;
margin-bottom: 4px;
}
.name-zh {
font-family: var(--serif);
font-size: 36px;
font-weight: 700;
color: var(--ink);
letter-spacing: 4px;
line-height: 1;
}
.name-en {
font-family: var(--serif);
font-size: 15px;
font-weight: 400;
color: var(--ink-muted);
letter-spacing: 4px;
text-transform: uppercase;
}
.title-row {
margin-top: 6px;
display: flex;
align-items: center;
gap: 12px;
}
.title-main {
font-family: var(--serif);
font-size: 17px;
font-weight: 600;
color: var(--accent);
}
.title-divider {
width: 24px;
height: 1px;
background: var(--accent-warm);
}
.title-sub {
font-size: 14px;
font-weight: 300;
color: var(--ink-muted);
letter-spacing: 1px;
}
.contact-row {
margin-top: 12px;
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.contact-item {
font-size: 13px;
color: var(--ink-muted);
font-weight: 400;
display: flex;
align-items: center;
gap: 6px;
}
.contact-dot {
width: 4px; height: 4px;
background: var(--accent);
border-radius: 50%;
flex-shrink: 0;
}
/* ========== SECTIONS ========== */
.section {
padding: 20px 44px;
position: relative;
}
.section + .section::before {
content: '';
position: absolute;
top: 0;
left: 44px; right: 44px;
height: 1px;
background: var(--border-light);
}
.section-title {
font-family: var(--serif);
font-size: 15px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 4px;
color: var(--accent);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 12px;
}
.section-title::before {
content: '';
width: 20px;
height: 2px;
background: var(--accent-warm);
flex-shrink: 0;
}
/* ========== SUMMARY ========== */
.summary {
font-size: 14px;
line-height: 1.7;
color: var(--ink-light);
font-weight: 300;
}
.summary strong {
color: var(--ink);
font-weight: 600;
}
/* ========== PROJECT CARDS ========== */
.projects-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-card {
background: var(--card);
border: 1px solid var(--border-light);
border-left: 3px solid var(--accent);
padding: 12px 18px;
transition: all 0.3s ease;
}
.project-card:hover {
border-left-color: var(--accent-warm);
box-shadow: 0 4px 20px var(--shadow);
transform: translateX(2px);
}
.project-name {
font-family: var(--serif);
font-size: 15px;
font-weight: 600;
color: var(--ink);
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.project-badge {
font-family: var(--sans);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--accent);
background: var(--accent-light);
padding: 2px 8px;
border-radius: 3px;
}
.project-desc {
font-size: 13px;
line-height: 1.6;
color: var(--ink-light);
font-weight: 300;
margin-bottom: 8px;
}
.project-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag {
font-size: 10px;
font-weight: 500;
color: var(--accent);
background: var(--accent-light);
padding: 3px 10px;
border-radius: 3px;
letter-spacing: 0.3px;
}
/* ========== METHOD GRID ========== */
.method-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.method-item {
background: var(--card);
border: 1px solid var(--border-light);
padding: 12px 16px;
position: relative;
}
.method-item::before {
content: '';
position: absolute;
top: 12px; left: 0;
width: 3px; height: 16px;
background: var(--accent-warm);
}
.method-label {
font-family: var(--serif);
font-size: 12.5px;
font-weight: 600;
color: var(--ink);
margin-bottom: 3px;
}
.method-desc {
font-size: 11.5px;
line-height: 1.55;
color: var(--ink-muted);
font-weight: 300;
}
/* ========== WORK EXPERIENCE ========== */
.job { margin-bottom: 16px; }
.job:last-child { margin-bottom: 0; }
.job-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 5px;
flex-wrap: wrap;
gap: 4px;
}
.job-title {
font-family: var(--serif);
font-size: 15px;
font-weight: 600;
color: var(--ink);
}
.job-company {
font-weight: 300;
color: var(--ink-muted);
font-size: 13.5px;
}
.job-date {
font-size: 12px;
color: var(--accent);
font-weight: 400;
font-style: italic;
font-family: var(--serif);
}
.job-bullets {
list-style: none;
padding: 0;
}
.job-bullets li {
font-size: 13px;
line-height: 1.6;
color: var(--ink-light);
font-weight: 300;
padding-left: 14px;
position: relative;
margin-bottom: 1px;
}
.job-bullets li::before {
content: '▸';
position: absolute;
left: 0;
color: var(--accent);
font-size: 13px;
}
.job-bullets li strong {
font-weight: 600;
color: var(--ink);
}
/* ========== EDUCATION ========== */
.edu-item { margin-bottom: 10px; }
.edu-item:last-child { margin-bottom: 0; }
.edu-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 3px;
flex-wrap: wrap;
gap: 4px;
}
.edu-degree {
font-family: var(--serif);
font-size: 15px;
font-weight: 600;
color: var(--ink);
}
.edu-school {
font-weight: 300;
color: var(--ink-muted);
}
.edu-date {
font-size: 12px;
color: var(--accent);
font-style: italic;
font-family: var(--serif);
}
.edu-details {
list-style: none;
padding: 0;
}
.edu-details li {
font-size: 13px;
line-height: 1.55;
color: var(--ink-muted);
font-weight: 300;
padding-left: 16px;
position: relative;
}
.edu-details li::before {
content: '▸';
position: absolute;
left: 0;
color: var(--accent);
font-size: 11px;
}
/* ========== SKILLS ========== */
.skills-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 16px;
align-items: baseline;
}
.skill-label {
font-family: var(--serif);
font-size: 13px;
font-weight: 600;
color: var(--ink);
text-align: right;
white-space: nowrap;
}
.skill-content {
font-size: 13px;
color: var(--ink-light);
font-weight: 300;
line-height: 1.6;
}
.skill-row { display: contents; }
/* ========== ANIMATIONS ========== */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.section { animation: fadeUp 0.5s ease both; }
.section:nth-child(2) { animation-delay: 0.05s; }
.section:nth-child(3) { animation-delay: 0.1s; }
.section:nth-child(4) { animation-delay: 0.15s; }
.section:nth-child(5) { animation-delay: 0.2s; }
.section:nth-child(6) { animation-delay: 0.25s; }
.section:nth-child(7) { animation-delay: 0.3s; }
/* ========== PRINT / PDF ========== */
@page {
size: A4;
margin: 0;
}
@media print {
html, body {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
background: var(--cream) !important;
}
.export-bar { display: none !important; }
.edit-btn { display: none !important; }
.loading-overlay { display: none !important; }
.resume { margin: 0; box-shadow: none; max-width: 100%; background: var(--cream); }
.project-card:hover { transform: none; box-shadow: none; }
.project-card { transform: none !important; box-shadow: none !important; }
.section { animation: none; }
.project-card, .method-item, .edu-item, .skills-grid, .job-header {
page-break-inside: avoid;
break-inside: avoid;
}
.section-title, .job-header {
page-break-after: avoid;
break-after: avoid;
}
.job-bullets li, .edu-details li, .summary, .project-desc {
page-break-inside: avoid;
break-inside: avoid;
}
/*
内容压缩规则:如果简历内容超出两页,在此处添加字号/间距缩小规则。
示例(根据实际内容量按需启用):
.section { padding: 13px 40px !important; }
.section-title { font-size: 12px !important; margin-bottom: 9px !important; }
.summary { font-size: 12px !important; line-height: 1.55 !important; }
.project-name { font-size: 13px !important; }
.project-desc { font-size: 11.2px !important; line-height: 1.45 !important; }
.job-bullets li { font-size: 11.2px !important; line-height: 1.42 !important; }
.skill-label, .skill-content { font-size: 11.2px !important; }
*/
}
</style>
</head>
<body>
<!-- Export Bar -->
<div class="export-bar" id="exportBar">
<button class="edit-btn" id="editBtn" onclick="toggleEdit()">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
<span id="editBtnText">编辑模式</span>
</button>
<button class="export-btn" onclick="exportPDF()">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6zm5-6v4h2v-4h3l-4-4-4 4h3z"/></svg>
{{EXPORT_BUTTON_TEXT}}
</button>
<span class="export-hint">{{EXPORT_HINT_TEXT}}</span>
</div>
<!-- Resume -->
<div class="resume" id="resume">
<!-- HEADER -->
<div class="header">
<div class="header-content">
<div class="header-left">
<div class="name-row">
<span class="name-zh">{{NAME_ZH}}</span>
<span class="name-en">{{NAME_EN}}</span>
</div>
<div class="title-row">
<span class="title-main">{{TARGET_TITLE}}</span>
<!-- Optional subtitle -->
<!-- <span class="title-divider"></span> -->
<!-- <span class="title-sub">{{SUBTITLE}}</span> -->
</div>
<div class="contact-row">
{{CONTACT_ITEMS}}
<!-- Example:
<span class="contact-item"><span class="contact-dot"></span>13800138000</span>
<span class="contact-item"><span class="contact-dot"></span>name@email.com</span>
<span class="contact-item"><span class="contact-dot"></span>城市</span>
-->
</div>
</div>
{{PHOTO_ELEMENT}}
<!-- If photo: <img class="header-photo" src="data:image/jpeg;base64,{{PHOTO_BASE64}}" alt="{{NAME_ZH}}"> -->
<!-- If no photo: leave empty -->
</div>
</div>
<!-- SUMMARY -->
<div class="section">
<div class="section-title">{{SUMMARY_TITLE}}</div>
<div class="summary">
{{SUMMARY_CONTENT}}
</div>
</div>
<!-- SECTIONS: Fill based on content priority -->
<!--
Use the following section types as building blocks.
Arrange them in the order that best matches the user's background and target role.
== PROJECT CARDS ==
<div class="section">
<div class="section-title">{{SECTION_TITLE}}</div>
<div class="projects-grid">
<div class="project-card">
<div class="project-name">项目名称 <span class="project-badge">标签</span></div>
<div class="project-desc">项目描述</div>
<div class="project-tags">
<span class="tag">标签1</span>
<span class="tag">标签2</span>
</div>
</div>
</div>
</div>
== METHOD GRID (2x2) ==
<div class="section">
<div class="section-title">{{SECTION_TITLE}}</div>
<div class="method-grid">
<div class="method-item">
<div class="method-label">标题</div>
<div class="method-desc">描述</div>
</div>
</div>
</div>
== WORK EXPERIENCE ==
<div class="section">
<div class="section-title">{{SECTION_TITLE}}</div>
<div class="job">
<div class="job-header">
<div>
<span class="job-title">职位</span>
<span class="job-company"> · 公司名</span>
</div>
<span class="job-date">起止时间</span>
</div>
<ul class="job-bullets">
<li><strong>关键词:</strong>描述内容</li>
</ul>
</div>
</div>
== EDUCATION ==
<div class="section">
<div class="section-title">{{SECTION_TITLE}}</div>
<div class="edu-item">
<div class="edu-header">
<div>
<span class="edu-degree">学位</span>
<span class="edu-school"> · 学校名</span>
</div>
<span class="edu-date">起止时间</span>
</div>
<ul class="edu-details">
<li>详情</li>
</ul>
</div>
</div>
== SKILLS GRID ==
<div class="section">
<div class="section-title">{{SECTION_TITLE}}</div>
<div class="skills-grid">
<div class="skill-row">
<span class="skill-label">类别</span>
<span class="skill-content">技能列表</span>
</div>
</div>
</div>
-->
</div>
<script>
function toggleEdit() {
var resume = document.getElementById('resume');
var btn = document.getElementById('editBtn');
var btnText = document.getElementById('editBtnText');
var isEditing = resume.contentEditable === 'true';
if (isEditing) {
resume.contentEditable = 'false';
resume.classList.remove('editing');
btn.classList.remove('active');
btnText.textContent = '编辑模式';
} else {
resume.contentEditable = 'true';
resume.classList.add('editing');
btn.classList.add('active');
btnText.textContent = '退出编辑';
}
}
function exportPDF() {
var resume = document.getElementById('resume');
if (resume.contentEditable === 'true') {
resume.contentEditable = 'false';
resume.classList.remove('editing');
document.getElementById('editBtn').classList.remove('active');
document.getElementById('editBtnText').textContent = '编辑模式';
}
window.print();
}
</script>
</body>
</html>