import fs from "fs";
import PDFDocument from "pdfkit";
import SVGtoPDF from "svg-to-pdfkit";
import path from "path";
import { fileURLToPath } from "url";
const COLORS = {
text: "#333333",
gray: "#777777",
line: "#000000",
blue: "#1a73e8"
};
const DATE_COL_WIDTH = 140;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const FONT_PATH = path.join(__dirname, "fonts", "NotoSerifCJKsc-Regular.otf");
const FONT_PATH_BOLD = path.join(__dirname, "fonts", "NotoSerifCJKsc-Bold.otf");
let HAS_CJK_BOLD = false;
let FONT_NAME = "Helvetica";
let FONT_BOLD_NAME = "Helvetica-Bold";
function applyCJKFont(doc) {
if (fs.existsSync(FONT_PATH)) {
doc.registerFont("CJK", FONT_PATH);
FONT_NAME = "CJK";
doc.font(FONT_NAME);
}
if (fs.existsSync(FONT_PATH_BOLD)) {
doc.registerFont("CJK-Bold", FONT_PATH_BOLD);
FONT_BOLD_NAME = "CJK-Bold";
HAS_CJK_BOLD = true;
}
}
const ICON_SVGS = {
location:
'<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5S10.62 6.5 12 6.5s2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>',
phone:
'<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M6.62 10.79a15.05 15.05 0 006.59 6.59l2.2-2.2a1 1 0 011.11-.21c1.2.49 2.53.76 3.89.76a1 1 0 011 1v3.5a1 1 0 01-1 1c-9.39 0-17-7.61-17-17a1 1 0 011-1H5a1 1 0 011 1c0 1.36.27 2.69.76 3.89a1 1 0 01-.21 1.11l-2.2 2.2z"/></svg>',
email:
'<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M20 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V6a2 2 0 00-2-2zm-1.4 3L12 12 5.4 7H20z"/></svg>',
website:
'<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M12 2a10 10 0 100 20 10 10 0 000-20zm4.9 6h-2.4a14.7 14.7 0 00-1.6-3.2A8.02 8.02 0 0116.9 8zm-5.5-3.2A14.7 14.7 0 009 8H6.1A8.02 8.02 0 0111.4 4.8zM6 10h3a16.2 16.2 0 000 4H6a8.02 8.02 0 010-4zm5 0h2a13.7 13.7 0 010 4h-2a13.7 13.7 0 010-4zM6.1 16H9a14.7 14.7 0 001.8 3.2A8.02 8.02 0 016.1 16zM12.9 19.2A14.7 14.7 0 0014.5 16h2.4a8.02 8.02 0 01-4 3.2zM18 14h-3a16.2 16.2 0 000-4h3a8.02 8.02 0 010 4z"/></svg>'
};
function drawHeader(doc, data) {
doc
.fontSize(26)
.fillColor(COLORS.text)
.text(data.fullName, { align: "center" });
doc.moveDown(0.1);
const contactItems = [
{ key: "location", value: data.location },
{ key: "phone", value: data.phone },
{ key: "email", value: data.email },
{ key: "website", value: data.website }
].filter(i => i.value);
doc.fontSize(10).fillColor(COLORS.text);
drawContactIcons(doc, contactItems);
doc.moveDown(0.6);
}
function sectionTitle(doc, title) {
doc
.fontSize(14)
.fillColor(COLORS.text)
.text(title.toUpperCase());
const left = doc.page.margins.left;
const right = doc.page.width - doc.page.margins.right;
doc.strokeColor(COLORS.line).lineWidth(1).moveTo(left, doc.y).lineTo(right, doc.y).stroke();
doc.moveDown(0.4);
}
function drawContactIcons(doc, items) {
const left = doc.page.margins.left;
const right = doc.page.width - doc.page.margins.right;
const width = right - left;
let x = left;
let y = doc.y;
const iconSize = 10;
const gap = 6;
const itemGap = 18;
const lineHeight = 14;
const baselineAdjust = 3;
const rows = [];
let currentRow = [];
let currentWidth = 0;
items.forEach(({ key, value }) => {
const textWidth = doc.widthOfString(value);
const itemWidth = iconSize + gap + textWidth + itemGap;
if (currentWidth + itemWidth > width && currentRow.length > 0) {
rows.push({ items: currentRow, rowWidth: currentWidth - itemGap });
currentRow = [];
currentWidth = 0;
}
currentRow.push({ key, value, textWidth });
currentWidth += itemWidth;
});
if (currentRow.length > 0) {
rows.push({ items: currentRow, rowWidth: currentWidth - itemGap });
}
rows.forEach(({ items: rowItems, rowWidth }) => {
x = left + Math.max(0, Math.floor((width - rowWidth) / 2));
rowItems.forEach(({ key, value, textWidth }) => {
const svg = ICON_SVGS[key];
if (svg) {
SVGtoPDF(doc, svg, x, y + baselineAdjust, { width: iconSize, height: iconSize });
}
const xText = x + iconSize + gap;
if (key === "website") {
const url = normalizeUrl(value);
doc.fillColor(COLORS.blue);
doc.text(value, xText, y, { lineBreak: false });
if (url) doc.link(xText, y, textWidth, lineHeight, url);
doc.fillColor(COLORS.text);
} else if (key === "email") {
const mail = "mailto:" + String(value);
doc.text(value, xText, y, { lineBreak: false });
doc.link(xText, y, textWidth, lineHeight, mail);
} else {
doc.text(value, xText, y, { lineBreak: false });
}
x += iconSize + gap + textWidth + itemGap;
});
y += lineHeight;
});
doc.x = left;
doc.y = y;
}
function normalizeUrl(v) {
const s = String(v || "").trim();
if (!s) return s;
if (/^https?:\/\//i.test(s)) return s;
return "https://" + s;
}
function truncateToWidth(doc, text, maxWidth) {
const s = String(text || "");
if (!s) return s;
if (doc.widthOfString(s) <= maxWidth) return s;
let out = "";
for (let i = 0; i < s.length; i++) {
const candidate = out + s[i] + "…";
if (doc.widthOfString(candidate) > maxWidth) break;
out += s[i];
}
return out + "…";
}
function drawEducation(doc, { education }) {
if (!education || education.length === 0) return;
sectionTitle(doc, "教育经历");
education.forEach(item => {
const title = `${item.institution} — ${item.degree}`;
const dates = `${item.startDate} to ${item.endDate}`;
const left = doc.page.margins.left;
const right = doc.page.width - doc.page.margins.right;
const avail = right - left;
const gap = 10;
const yRow = doc.y;
const titleW = Math.max(0, avail - DATE_COL_WIDTH - gap);
doc.fontSize(11).fillColor(COLORS.text).text(title, left, yRow, { width: titleW });
doc.fontSize(10).fillColor(COLORS.gray).text(dates, left, yRow, { width: avail, align: "right" });
doc.x = left;
doc.y = Math.max(doc.y, yRow + 16);
if (item.summary) doc.fillColor(COLORS.text).text(item.summary);
doc.moveDown(0.1);
});
doc.moveDown(0.6);
}
function drawExperience(doc, { experience }) {
if (!experience || experience.length === 0) return;
sectionTitle(doc, "职业经历");
experience.forEach(item => {
const position = item.position || "";
const department = item.department || item.dept || "";
const company = item.company || "";
const dates = `${item.startDate} to ${item.endDate}`;
const left = doc.page.margins.left;
const right = doc.page.width - doc.page.margins.right;
const avail = right - left;
const gap = 10;
const yRow = doc.y;
const headerW = Math.max(0, avail - DATE_COL_WIDTH - gap);
let x = left;
const boldName = HAS_CJK_BOLD ? FONT_BOLD_NAME : FONT_NAME;
doc.fontSize(11).fillColor(COLORS.text).font(boldName);
let posText = position;
if (doc.widthOfString(posText) > headerW) posText = truncateToWidth(doc, posText, headerW);
if (posText) {
doc.text(posText, x, yRow, { lineBreak: false });
x += doc.widthOfString(posText);
}
if (department) {
const sep = posText ? " · " : "";
doc.font(FONT_NAME).text(sep, x, yRow, { lineBreak: false });
x += doc.widthOfString(sep);
doc.font(boldName);
const remainDept = headerW - (x - left);
let deptText = department;
if (remainDept <= 0) deptText = "";
else if (doc.widthOfString(deptText) > remainDept) deptText = truncateToWidth(doc, deptText, remainDept);
if (deptText) {
doc.text(deptText, x, yRow, { lineBreak: false });
x += doc.widthOfString(deptText);
}
}
const sep2 = (posText || department) ? " — " : "";
doc.font(FONT_NAME).text(sep2, x, yRow, { lineBreak: false });
x += doc.widthOfString(sep2);
doc.fillColor(COLORS.blue).font(FONT_NAME);
const remainComp = headerW - (x - left);
let compText = company;
if (remainComp <= 0) compText = "";
else if (doc.widthOfString(compText) > remainComp) compText = truncateToWidth(doc, compText, remainComp);
if (compText) {
doc.text(compText, x, yRow, { lineBreak: false });
x += doc.widthOfString(compText);
}
doc.fillColor(COLORS.gray).font(FONT_NAME);
doc.fontSize(10).text(dates, left, yRow, { width: avail, align: "right" });
doc.x = left;
doc.y = Math.max(doc.y, yRow + 16);
if (item.highlights) {
item.highlights.forEach(h => {
doc.fontSize(10).fillColor(COLORS.text).text(`• ${h}`, { indent: 10 });
});
}
doc.moveDown(0.3);
});
doc.moveDown(0.6);
}
function drawSkills(doc, { skills }) {
if (!skills || !Array.isArray(skills) || skills.length === 0) return;
sectionTitle(doc, "专业技能");
const uniq = Array.from(new Set(skills.map(s => String(s || "").trim()).filter(Boolean)));
uniq.forEach(s => {
doc.font(FONT_NAME).fontSize(10).fillColor(COLORS.text).text(`• ${s}`, { indent: 10 });
});
doc.moveDown(0.6);
}
function drawHonors(doc, { honors }) {
if (!honors || !Array.isArray(honors) || honors.length === 0) return;
sectionTitle(doc, "荣誉奖项");
honors
.map(s => String(s || "").trim())
.filter(Boolean)
.forEach(s => {
doc.font(FONT_NAME).fontSize(10).fillColor(COLORS.text).text(`• ${s}`, { indent: 10 });
});
doc.moveDown(0.6);
}
function drawProjects(doc, { projects }) {
if (!projects || projects.length === 0) return;
sectionTitle(doc, "项目经历");
const left = doc.page.margins.left;
const right = doc.page.width - doc.page.margins.right;
const avail = right - left;
const lineH = 16;
projects.forEach(item => {
const name = String(item.name || "");
const url = item.url ? String(item.url) : "";
const boldName = HAS_CJK_BOLD ? FONT_BOLD_NAME : FONT_NAME;
doc.font(FONT_NAME).fontSize(10);
let urlW = url ? doc.widthOfString(url) : 0;
const maxUrlW = Math.floor(avail * 0.5);
let urlDraw = url;
if (url && urlW > maxUrlW) {
urlDraw = truncateToWidth(doc, url, maxUrlW);
urlW = doc.widthOfString(urlDraw);
}
const nameMax = Math.max(0, avail - (url ? (urlW + 10) : 0));
doc.font(boldName).fontSize(11);
let nameDraw = name;
if (doc.widthOfString(nameDraw) > nameMax) {
nameDraw = truncateToWidth(doc, nameDraw, nameMax);
}
const y = doc.y;
doc.font(boldName).fontSize(11).fillColor(COLORS.text).text(nameDraw, left, y, { lineBreak: false });
if (url) {
doc.font(FONT_NAME).fontSize(10).fillColor(COLORS.blue)
.text(urlDraw, left, y, { width: avail, align: "right", lineBreak: false });
const xLink = left + (avail - urlW);
doc.link(xLink, y, urlW, lineH, normalizeUrl(url));
doc.fillColor(COLORS.text);
}
doc.x = left;
doc.y = Math.max(doc.y, y + lineH);
doc.font(FONT_NAME);
if (item.summary)
doc.fontSize(10).fillColor(COLORS.text).text(item.summary);
if (item.highlights) {
item.highlights.forEach(h => {
doc.font(FONT_NAME).fontSize(10).fillColor(COLORS.text).text(`• ${h}`, { indent: 10 });
});
}
doc.moveDown(0.7);
});
doc.moveDown(1);
}
function normalizeSectionKey(s) {
return String(s || "").toLowerCase();
}
const SECTION_RENDERERS = {
experience: drawExperience,
education: drawEducation,
projects: drawProjects,
skills: drawSkills,
honors: drawHonors
};
function exportResume(data, outputPath = "resume.pdf") {
const doc = new PDFDocument({ margins: { top: 24, bottom: 40, left: 40, right: 40 } });
doc.pipe(fs.createWriteStream(outputPath));
applyCJKFont(doc);
drawHeader(doc, data);
// Section 排序
const ordered = (data.order || []).map(normalizeSectionKey);
const allSections = Object.keys(SECTION_RENDERERS);
const seen = new Set();
const finalOrder = [];
ordered.forEach(section => {
const key = normalizeSectionKey(section);
if (SECTION_RENDERERS[key] && !seen.has(key)) {
finalOrder.push(key);
seen.add(key);
}
});
allSections.forEach(section => {
if (!seen.has(section)) {
finalOrder.push(section);
seen.add(section);
}
});
finalOrder.forEach(section => {
if (SECTION_RENDERERS[section]) {
SECTION_RENDERERS[section](doc, data);
}
});
doc.end();
}
// const data = {
// "fullName": "刘颖",
// "email": "ying.liu.frontend@example.com",
// "phone": "+86 136-1111-5678",
// "location": "上海",
// "website": " https://github.com/yingliu-fe ",
// "education": [
// {
// "institution": "复旦大学",
// "degree": "计算机科学(学士)",
// "startDate": "2016-09",
// "endDate": "2020-06",
// "summary": "主修:Web 技术、计算机图形学、人机交互;完成基于 WebGL 的图形渲染项目"
// }
// ],
// "experience": [
// {
// "company": "哔哩哔哩",
// "department": "互动前端",
// "position": "前端开发工程师",
// "startDate": "2020-07",
// "endDate": "2022-12",
// "highlights": [
// "重构视频播放页面,采用组件化与状态管理,FMP 降低 35%",
// "引入 SSR/SSG(Next.js),SEO 与首屏性能显著提升",
// "构建前端监控(Sentry + Web Vitals),问题定位效率提升"
// ]
// },
// {
// "company": "小红书",
// "department": "电商前端",
// "position": "高级前端开发工程师",
// "startDate": "2023-01",
// "endDate": "至今",
// "highlights": [
// "搭建微前端架构(Module Federation),独立部署与回滚更快速",
// "图片与资源优化(懒加载、预加载、压缩),LCP 降低 40%",
// "前后端联调平台与契约测试(OpenAPI + Pact),缺陷率下降"
// ]
// }
// ],
// "projects": [
// {
// "name": "组件库与主题系统",
// "role": "负责人",
// "summary": "统一 UI 组件与主题切换方案",
// "highlights": [
// "TypeScript + Storybook 构建与文档化",
// "CSS 变量与暗色模式支持,提升易用性",
// "CI 检查与快照测试(Jest/Cypress)"
// ]
// },
// {
// "name": "前端性能平台",
// "role": "核心开发",
// "summary": "收集与分析 Web Vitals 指标",
// "highlights": [
// "指标上报与可视化(Grafana + Loki)",
// "性能基线 + 趋势报警,促成持续优化",
// "页面级性能对比与回归报告"
// ]
// }
// ],
// "skills": [
// "TypeScript",
// "JavaScript",
// "React",
// "Vue",
// "Next.js",
// "Node.js",
// "Webpack/Vite",
// "CSS/Sass",
// "TailwindCSS",
// "GraphQL",
// "Jest",
// "Cypress",
// "Playwright",
// "Sentry",
// "Web Vitals",
// "Docker",
// "Nginx",
// "GitHub Actions",
// "PWA",
// "SSR/SSG"
// ],
// "honors": [
// "2022 前端性能优化优秀奖",
// "2023 组件库推广贡献奖"
// ],
// "order": [
// "experience",
// "projects",
// "skills",
// "education",
// "honors"
// ]
// }
// exportResume(data);
export { exportResume };