Files
atridotdad/src/pages/api/resume/pdf.ts
Atridad 9eb964ff06
All checks were successful
Docker Deploy / build-and-push (push) Successful in 7m19s
Switched to playwright... puppeteer is kinda clunky
2025-06-30 13:59:38 -06:00

460 lines
14 KiB
TypeScript

import type { APIRoute } from "astro";
import { chromium } from 'playwright';
import { siteConfig } from "../../../config/data";
import * as TOML from "@iarna/toml";
// Helper function to fetch and return SVG icon from Simple Icons CDN
async function getSimpleIcon(iconName: string): Promise<string> {
try {
const response = await fetch(
`https://cdn.jsdelivr.net/npm/simple-icons@v10/icons/${iconName.toLowerCase()}.svg`,
);
if (!response.ok) {
console.warn(`Failed to fetch icon: ${iconName}`);
return "";
}
const svgContent = await response.text();
// Add inline styles for proper sizing and color
return svgContent.replace(
"<svg",
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;"',
);
} catch (error) {
console.warn(`Error fetching icon ${iconName}:`, error);
return "";
}
}
// Helper function to get MDI icon SVG
function getMdiIcon(iconName: string): string {
const iconMap: { [key: string]: string } = {
"mdi:email":
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M20,8L12,13L4,8V6L12,11L20,6M20,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6C2.89,4 20,4.89 20,4Z"/></svg>',
"mdi:download":
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"/></svg>',
"mdi:link":
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M3.9,12C3.9,10.29 5.29,8.9 7,8.9H11V7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12M8,13H16V11H8V13M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.71 18.71,15.1 17,15.1H13V17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7Z"/></svg>',
};
return iconMap[iconName] || "";
}
interface ResumeData {
basics: {
name: string;
email: string;
website?: string;
profiles: {
network: string;
username: string;
url: string;
}[];
};
summary: {
content: string;
};
experience: {
company: string;
position: string;
location: string;
date: string;
description: string[];
url?: string;
}[];
education: {
institution: string;
degree: string;
field: string;
date: string;
details?: string[];
}[];
skills: {
name: string;
level: number;
}[];
volunteer: {
organization: string;
position: string;
date: string;
}[];
awards: {
title: string;
organization: string;
date: string;
description?: string;
}[];
}
// Helper function to generate sections for a column
function generateColumnSections(
sectionNames: string[] = [],
sectionData: { [key: string]: any },
): string {
return sectionNames
.map((sectionName) => {
const section = sectionData[sectionName];
if (
!section ||
!section.data ||
!section.data.length ||
!section.enabled
) {
return "";
}
return `
<section>
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
${section.title}
</h2>
<div class="${section.spacing}">
${section.html}
</div>
</section>
`;
})
.join("");
}
const generateResumeHTML = async (data: ResumeData): Promise<string> => {
const resumeConfig = siteConfig.resume;
// Get layout configuration with defaults
const layout = resumeConfig.layout || {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
};
// Pre-fetch icons for profiles
const profileIcons: { [key: string]: string } = {};
if (data.basics.profiles) {
for (const profile of data.basics.profiles) {
const iconName = profile.network.toLowerCase();
profileIcons[profile.network] = await getSimpleIcon(iconName);
}
}
// Get email icon
const emailIcon = getMdiIcon("mdi:email");
const skillsHTML =
data.skills
?.map((skill) => {
const progressValue = skill.level * 20;
return `
<div class="mb-1">
<div class="flex justify-between items-center mb-0.5">
<span class="text-xs font-medium text-gray-900">${skill.name}</span>
<span class="text-xs text-gray-600">${skill.level}/5</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: ${progressValue}%"></div>
</div>
</div>
`;
})
.join("") || "";
const experienceHTML =
data.experience
?.map((exp) => {
const descriptionList = exp.description
.map((item) => `<li class="mb-1">${item}</li>`)
.join("");
return `
<div class="mb-3 pl-2 border-l-2 border-blue-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${exp.position}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${exp.company}</span>
<span class="mx-1">•</span>
<span>${exp.date}</span>
<span class="mx-1">•</span>
<span>${exp.location}</span>
</div>
<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">
${descriptionList}
</ul>
</div>
`;
})
.join("") || "";
const educationHTML =
data.education
?.map((edu) => {
const detailsList = edu.details
? edu.details
.map((detail) => `<li class="mb-1">${detail}</li>`)
.join("")
: "";
return `
<div class="mb-3 pl-2 border-l-2 border-green-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${edu.institution}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${edu.degree} in ${edu.field}</span>
<span class="mx-1">•</span>
<span>${edu.date}</span>
</div>
${detailsList ? `<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">${detailsList}</ul>` : ""}
</div>
`;
})
.join("") || "";
const volunteerHTML =
data.volunteer
?.map((vol) => {
return `
<div class="mb-2 pl-2 border-l-2 border-purple-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${vol.organization}</h3>
<div class="text-xs text-gray-600">
<span class="font-medium">${vol.position}</span>
<span class="mx-1">•</span>
<span>${vol.date}</span>
</div>
</div>
`;
})
.join("") || "";
const awardsHTML =
data.awards
?.map((award) => {
return `
<div class="mb-2 pl-2 border-l-2 border-yellow-600">
<h3 class="text-xs font-semibold text-gray-900 mb-1">${award.title}</h3>
<div class="text-xs text-gray-600 mb-1">
<span class="font-medium">${award.organization}</span>
<span class="mx-1">•</span>
<span>${award.date}</span>
</div>
${award.description ? `<div class="text-xs text-gray-700 leading-tight">${award.description}</div>` : ""}
</div>
`;
})
.join("") || "";
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${data.basics.name} - Resume</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@media print {
body {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
.resume-container {
max-width: 8.5in;
min-height: 11in;
}
</style>
</head>
<body class="bg-white text-gray-900 text-xs leading-tight p-3">
<div class="resume-container mx-auto">
<header class="text-center mb-3 pb-2 border-b-2 border-gray-300">
<h1 class="text-3xl font-bold text-gray-900 mb-1">${data.basics.name}</h1>
<div class="flex justify-center items-center flex-wrap gap-4 text-xs text-gray-600">
${data.basics.email ? `<div class="flex items-center gap-1">${emailIcon} ${data.basics.email}</div>` : ""}
${data.basics.profiles
?.map((profile) => {
const icon = profileIcons[profile.network] || "";
const displayUrl = profile.url
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `<div class="flex items-center gap-1">${icon} ${displayUrl}</div>`;
})
.join("") || ""
}
</div>
</header>
${data.summary && resumeConfig.sections.summary?.enabled
? `
<section class="mb-3">
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
${resumeConfig.sections.summary.title || "Summary"}
</h2>
<div class="text-xs text-gray-700 leading-tight">${data.summary.content}</div>
</section>
`
: ""
}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-4">
${generateColumnSections(layout.leftColumn, {
experience: {
data: data.experience,
html: experienceHTML,
title:
resumeConfig.sections.experience?.title || "Experience",
enabled: resumeConfig.sections.experience?.enabled,
spacing: "space-y-3",
},
volunteer: {
data: data.volunteer,
html: volunteerHTML,
title:
resumeConfig.sections.volunteer?.title ||
"Volunteer Work",
enabled: resumeConfig.sections.volunteer?.enabled,
spacing: "space-y-2",
},
awards: {
data: data.awards,
html: awardsHTML,
title:
resumeConfig.sections.awards?.title ||
"Awards & Recognition",
enabled: resumeConfig.sections.awards?.enabled,
spacing: "space-y-2",
},
skills: {
data: data.skills,
html: skillsHTML,
title: resumeConfig.sections.skills?.title || "Skills",
enabled: resumeConfig.sections.skills?.enabled,
spacing: "space-y-1",
},
education: {
data: data.education,
html: educationHTML,
title:
resumeConfig.sections.education?.title || "Education",
enabled: resumeConfig.sections.education?.enabled,
spacing: "space-y-3",
},
})}
</div>
<div class="space-y-4">
${generateColumnSections(layout.rightColumn, {
experience: {
data: data.experience,
html: experienceHTML,
title:
resumeConfig.sections.experience?.title || "Experience",
enabled: resumeConfig.sections.experience?.enabled,
spacing: "space-y-3",
},
volunteer: {
data: data.volunteer,
html: volunteerHTML,
title:
resumeConfig.sections.volunteer?.title ||
"Volunteer Work",
enabled: resumeConfig.sections.volunteer?.enabled,
spacing: "space-y-2",
},
awards: {
data: data.awards,
html: awardsHTML,
title:
resumeConfig.sections.awards?.title ||
"Awards & Recognition",
enabled: resumeConfig.sections.awards?.enabled,
spacing: "space-y-2",
},
skills: {
data: data.skills,
html: skillsHTML,
title: resumeConfig.sections.skills?.title || "Skills",
enabled: resumeConfig.sections.skills?.enabled,
spacing: "space-y-1",
},
education: {
data: data.education,
html: educationHTML,
title:
resumeConfig.sections.education?.title || "Education",
enabled: resumeConfig.sections.education?.enabled,
spacing: "space-y-3",
},
})}
</div>
</div>
</div>
</body>
</html>
`;
};
export const GET: APIRoute = async ({ request }) => {
try {
if (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
return new Response("Resume not configured", { status: 404 });
}
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
if (!response.ok) {
throw new Error(
`Failed to fetch resume: ${response.status} ${response.statusText}`,
);
}
const tomlContent = await response.text();
const resumeData: ResumeData = TOML.parse(
tomlContent,
) as unknown as ResumeData;
const htmlContent = await generateResumeHTML(resumeData);
// Launch browser with Playwright
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
(process.env.NODE_ENV === "production" ? "/usr/bin/google-chrome" : undefined),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-web-security',
'--disable-features=VizDisplayCompositor'
]
});
const page = await browser.newPage();
await page.setContent(htmlContent, { waitUntil: 'networkidle' });
const pdfBuffer = await page.pdf({
format: 'A4',
margin: {
top: '0.2in',
bottom: '0.2in',
left: '0.2in',
right: '0.2in',
},
printBackground: true,
scale: 0.9,
});
await browser.close();
return new Response(pdfBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="Atridad_Lahiji_Resume.pdf"`,
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
});
} catch (error) {
console.error("Error generating PDF:", error);
return new Response("Error generating PDF", { status: 500 });
}
};