faj-cli
Version:
FAJ - A powerful CLI resume builder with AI enhancement and multi-format export
469 lines • 19.9 kB
JavaScript
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.OpenResumePDFGenerator = void 0;
const pdf_lib_1 = require("pdf-lib");
const fontkit = __importStar(require("@pdf-lib/fontkit"));
const fs = __importStar(require("fs/promises"));
const path = __importStar(require("path"));
const Logger_1 = require("../../utils/Logger");
/**
* PDF Generator using local TTF fonts like Open-Resume
* Simple, straightforward approach with pre-downloaded fonts
*/
class OpenResumePDFGenerator {
logger;
fontsDir;
themes = {
modern: {
primary: [0.2, 0.4, 0.8],
secondary: [0.4, 0.4, 0.4],
accent: [0.1, 0.7, 0.9],
text: [0.1, 0.1, 0.1],
light: [0.95, 0.95, 0.95]
},
professional: {
primary: [0.17, 0.24, 0.31],
secondary: [0.52, 0.58, 0.64],
accent: [0.15, 0.68, 0.38],
text: [0.17, 0.17, 0.17],
light: [0.95, 0.96, 0.97]
},
minimalist: {
primary: [0, 0, 0],
secondary: [0.4, 0.4, 0.4],
accent: [0.2, 0.2, 0.2],
text: [0.1, 0.1, 0.1],
light: [0.97, 0.97, 0.97]
}
};
constructor() {
this.logger = new Logger_1.Logger('OpenResumePDFGenerator');
this.fontsDir = path.join(process.cwd(), 'public', 'fonts');
}
async generatePDF(resume, themeName = 'modern') {
const theme = this.themes[themeName] || this.themes.modern;
// Detect if content has CJK characters
const resumeText = JSON.stringify(resume);
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(resumeText);
// Create PDF
const pdfDoc = await pdf_lib_1.PDFDocument.create();
pdfDoc.registerFontkit(fontkit);
// Set metadata
pdfDoc.setTitle(`${resume.basicInfo?.name || 'Resume'}`);
pdfDoc.setAuthor(resume.basicInfo?.name || 'Unknown');
pdfDoc.setCreator('FAJ Resume Builder');
// Load fonts from local files (like Open-Resume)
let regularFont;
let boldFont;
let cjkRegularFont = null;
let cjkBoldFont = null;
try {
// Always load Roboto for English/numbers
this.logger.info('Loading fonts...');
const robotoRegularBytes = await fs.readFile(path.join(this.fontsDir, 'Roboto-Regular.ttf'));
const robotoBoldBytes = await fs.readFile(path.join(this.fontsDir, 'Roboto-Bold.ttf'));
regularFont = await pdfDoc.embedFont(robotoRegularBytes);
boldFont = await pdfDoc.embedFont(robotoBoldBytes);
// Load CJK fonts only if needed
if (hasCJK) {
this.logger.info('Loading CJK fonts for Chinese content');
const cjkRegularBytes = await fs.readFile(path.join(this.fontsDir, 'NotoSansSC-Regular.ttf'));
const cjkBoldBytes = await fs.readFile(path.join(this.fontsDir, 'NotoSansSC-Bold.ttf'));
cjkRegularFont = await pdfDoc.embedFont(cjkRegularBytes);
cjkBoldFont = await pdfDoc.embedFont(cjkBoldBytes);
}
}
catch (error) {
this.logger.warn('Failed to load local fonts, using defaults');
this.logger.error('Font loading error:', error);
// Fallback to standard fonts
const { StandardFonts } = await Promise.resolve().then(() => __importStar(require('pdf-lib')));
regularFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
boldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
}
// Create A4 page
const page = pdfDoc.addPage(pdf_lib_1.PageSizes.A4);
const { width, height } = page.getSize();
let y = height - 50;
const leftMargin = 50;
const rightMargin = 50;
const margin = leftMargin; // For backward compatibility
const contentWidth = width - leftMargin - rightMargin;
// Helper function to draw mixed text (CJK + English/numbers)
const drawMixedText = (text, x, y, size, color, isBold = false) => {
if (!hasCJK || !cjkRegularFont) {
// No CJK content, use regular font
page.drawText(text, {
x,
y,
size,
font: isBold ? boldFont : regularFont,
color: (0, pdf_lib_1.rgb)(color[0], color[1], color[2])
});
return;
}
// Split text into segments of CJK and non-CJK
let currentX = x;
let currentSegment = '';
let isCJKSegment = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const charIsCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(char);
if (i === 0) {
isCJKSegment = charIsCJK;
currentSegment = char;
}
else if (charIsCJK !== isCJKSegment) {
// Draw the current segment
const font = isCJKSegment
? (isBold ? cjkBoldFont : cjkRegularFont)
: (isBold ? boldFont : regularFont);
page.drawText(currentSegment, {
x: currentX,
y,
size,
font,
color: (0, pdf_lib_1.rgb)(color[0], color[1], color[2])
});
currentX += font.widthOfTextAtSize(currentSegment, size);
// Start new segment
isCJKSegment = charIsCJK;
currentSegment = char;
}
else {
currentSegment += char;
}
}
// Draw the last segment
if (currentSegment) {
const font = isCJKSegment
? (isBold ? cjkBoldFont : cjkRegularFont)
: (isBold ? boldFont : regularFont);
page.drawText(currentSegment, {
x: currentX,
y,
size,
font,
color: (0, pdf_lib_1.rgb)(color[0], color[1], color[2])
});
}
};
// Helper function to calculate mixed text width
const getMixedTextWidth = (text, size, isBold = false) => {
if (!hasCJK || !cjkRegularFont) {
return (isBold ? boldFont : regularFont).widthOfTextAtSize(text, size);
}
let totalWidth = 0;
let currentSegment = '';
let isCJKSegment = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const charIsCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(char);
if (i === 0) {
isCJKSegment = charIsCJK;
currentSegment = char;
}
else if (charIsCJK !== isCJKSegment) {
const font = isCJKSegment
? (isBold ? cjkBoldFont : cjkRegularFont)
: (isBold ? boldFont : regularFont);
totalWidth += font.widthOfTextAtSize(currentSegment, size);
isCJKSegment = charIsCJK;
currentSegment = char;
}
else {
currentSegment += char;
}
}
if (currentSegment) {
const font = isCJKSegment
? (isBold ? cjkBoldFont : cjkRegularFont)
: (isBold ? boldFont : regularFont);
totalWidth += font.widthOfTextAtSize(currentSegment, size);
}
return totalWidth;
};
// Header Section
if (resume.basicInfo?.name) {
// Name
drawMixedText(resume.basicInfo.name, margin, y, 28, theme.primary, true);
y -= 35;
// Contact info in one line
const contacts = [
resume.basicInfo.email,
resume.basicInfo.phone,
resume.basicInfo.location
].filter(Boolean);
if (contacts.length > 0) {
const contactText = contacts.join(' • ');
drawMixedText(contactText, margin, y, 10, theme.secondary);
y -= 20;
}
// Divider line
page.drawLine({
start: { x: leftMargin, y: y },
end: { x: width - rightMargin, y: y },
thickness: 0.5,
color: (0, pdf_lib_1.rgb)(theme.light[0], theme.light[1], theme.light[2])
});
y -= 25;
}
// Professional Summary
if (resume.content.summary) {
this.drawSection(page, 'PROFESSIONAL SUMMARY', margin, y, theme, boldFont);
y -= 20;
const lines = this.wrapMixedText(resume.content.summary, contentWidth, 11, false);
for (const line of lines) {
drawMixedText(line, margin, y, 11, theme.text);
y -= 16;
}
y -= 15;
}
// Work Experience
if (resume.content.experience?.length > 0) {
this.drawSection(page, 'WORK EXPERIENCE', margin, y, theme, boldFont);
y -= 20;
for (const exp of resume.content.experience) {
// Title and company
const titleText = exp.title;
const companyText = exp.company ? ` at ${exp.company}` : '';
drawMixedText(titleText, margin, y, 12, theme.text, true);
if (companyText) {
const titleWidth = getMixedTextWidth(titleText, 12, true);
drawMixedText(companyText, margin + titleWidth, y, 12, theme.secondary);
}
// Date on right
const dateText = `${exp.startDate} - ${exp.current ? 'Present' : exp.endDate}`;
const dateWidth = getMixedTextWidth(dateText, 10);
drawMixedText(dateText, width - rightMargin - dateWidth, y, 10, theme.secondary);
y -= 18;
// Description
if (exp.description) {
const descLines = this.wrapMixedText(exp.description, contentWidth, 10, false);
for (const line of descLines) {
drawMixedText(line, margin, y, 10, theme.text);
y -= 14;
}
}
// Highlights
if (exp.highlights?.length > 0) {
for (const highlight of exp.highlights) {
const bulletLines = this.wrapMixedText(`• ${highlight}`, contentWidth - 20, 10, false);
for (const line of bulletLines) {
drawMixedText(line, margin + 10, y, 10, theme.text);
y -= 14;
}
}
}
y -= 15;
}
}
// Projects (if space available)
if (resume.content.projects?.length > 0 && y > 200) {
this.drawSection(page, 'PROJECTS', margin, y, theme, boldFont);
y -= 20;
for (const project of resume.content.projects.slice(0, 2)) {
drawMixedText(project.name, margin, y, 11, theme.text, true);
if (project.role) {
const nameWidth = getMixedTextWidth(project.name, 11, true);
drawMixedText(` - ${project.role}`, margin + nameWidth, y, 11, theme.secondary);
}
y -= 16;
if (project.description) {
const descLines = this.wrapMixedText(project.description, contentWidth, 10, false);
for (const line of descLines.slice(0, 2)) {
drawMixedText(line, margin, y, 10, theme.text);
y -= 14;
}
}
if (project.technologies?.length > 0) {
const techText = `Tech: ${project.technologies.slice(0, 5).join(', ')}`;
drawMixedText(techText, margin, y, 9, theme.secondary);
y -= 14;
}
y -= 10;
// Stop if running out of space
if (y < 150)
break;
}
}
// Skills (compact)
if (resume.content.skills?.length > 0 && y > 100) {
this.drawSection(page, 'TECHNICAL SKILLS', margin, y, theme, boldFont);
y -= 20;
const skillsByCategory = this.groupSkills(resume.content.skills);
for (const [category, skills] of Object.entries(skillsByCategory)) {
if (y < 80)
break;
const categoryLabel = this.getCategoryLabel(category);
const skillNames = skills.map(s => s.name).slice(0, 8).join(', ');
drawMixedText(`${categoryLabel}: `, margin, y, 10, theme.secondary, true);
const labelWidth = getMixedTextWidth(`${categoryLabel}: `, 10, true);
const skillLines = this.wrapMixedText(skillNames, contentWidth - labelWidth, 10, false);
skillLines.forEach((line, idx) => {
drawMixedText(line, margin + (idx === 0 ? labelWidth : 0), y - (idx * 14), 10, theme.text);
});
y -= skillLines.length * 14 + 5;
}
y -= 10;
}
// Education (compact)
if (resume.content.education?.length > 0 && y > 80) {
this.drawSection(page, 'EDUCATION', margin, y, theme, boldFont);
y -= 20;
for (const edu of resume.content.education) {
const degreeText = `${edu.degree} in ${edu.field}`;
drawMixedText(degreeText, margin, y, 11, theme.text, true);
const dateText = `${edu.startDate} - ${edu.endDate}`;
const dateWidth = getMixedTextWidth(dateText, 10);
drawMixedText(dateText, width - rightMargin - dateWidth, y, 10, theme.secondary);
y -= 16;
let institutionText = edu.institution;
if (edu.gpa) {
institutionText += ` • GPA: ${edu.gpa}`;
}
drawMixedText(institutionText, margin, y, 10, theme.secondary);
y -= 20;
}
}
// Save PDF
const pdfBytes = await pdfDoc.save();
const sizeKB = Math.round(pdfBytes.length / 1024);
const sizeMB = (pdfBytes.length / 1024 / 1024).toFixed(2);
if (sizeKB < 1024) {
this.logger.success(`Generated PDF: ${sizeKB}KB`);
}
else {
this.logger.success(`Generated PDF: ${sizeMB}MB`);
}
return Buffer.from(pdfBytes);
}
drawSection(page, title, x, y, theme, font) {
page.drawText(title, {
x: x,
y: y,
size: 12,
font: font,
color: (0, pdf_lib_1.rgb)(theme.primary[0], theme.primary[1], theme.primary[2])
});
// Underline
const textWidth = font.widthOfTextAtSize(title, 12);
page.drawLine({
start: { x: x, y: y - 3 },
end: { x: x + textWidth, y: y - 3 },
thickness: 0.5,
color: (0, pdf_lib_1.rgb)(theme.primary[0], theme.primary[1], theme.primary[2]),
opacity: 0.3
});
}
wrapMixedText(text, maxWidth, fontSize, _isBold) {
const lines = [];
let currentLine = '';
// Improved word wrapping with better width calculation
// Different character types have different widths
const getCharWidth = (char) => {
// CJK characters are wider
if (/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(char)) {
return fontSize * 0.9; // CJK characters are almost square
}
// Numbers and uppercase letters
if (/[A-Z0-9]/.test(char)) {
return fontSize * 0.55;
}
// Lowercase letters
if (/[a-z]/.test(char)) {
return fontSize * 0.45;
}
// Punctuation and spaces
if (/[\s\.,;:!?]/.test(char)) {
return fontSize * 0.25;
}
// Default for other characters
return fontSize * 0.5;
};
const getLineWidth = (line) => {
let width = 0;
for (const char of line) {
width += getCharWidth(char);
}
return width;
};
const words = text.split(' ');
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const testWidth = getLineWidth(testLine);
if (testWidth > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
}
else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
groupSkills(skills) {
const grouped = {};
skills.forEach(skill => {
const category = skill.category || 'other';
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category].push(skill);
});
return grouped;
}
getCategoryLabel(category) {
const labels = {
'programming_languages': 'Languages',
'language': 'Languages',
'frameworks': 'Frameworks',
'framework': 'Frameworks',
'databases': 'Databases',
'database': 'Databases',
'tools': 'Tools',
'tool': 'Tools',
'cloud': 'Cloud',
'other': 'Other'
};
return labels[category.toLowerCase()] || category;
}
}
exports.OpenResumePDFGenerator = OpenResumePDFGenerator;
//# sourceMappingURL=OpenResumePDFGenerator.js.map
;