const fs = require('fs') const path = require('path') const { execSync } = require('child_process') const DATA_DIR = path.join(__dirname, '../server/data') const PUBLIC_DIR = path.join(__dirname, '../public') function readJson(filename) { const data = fs.readFileSync(path.join(DATA_DIR, filename), 'utf-8') const parsed = JSON.parse(data) function cleanDates(obj) { if (Array.isArray(obj)) return obj.map(cleanDates) if (obj && typeof obj === 'object') { if (obj.$date?.$numberLong) return new Date(parseInt(obj.$date.$numberLong)).toISOString() const cleaned = {} for (const [k, v] of Object.entries(obj)) cleaned[k] = cleanDates(v) return cleaned } return obj } return cleanDates(parsed) } function formatMonth(dateStr) { if (!dateStr) return 'Present' const date = new Date(dateStr) if (isNaN(date)) return dateStr return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) } function formatYear(dateStr) { if (!dateStr) return '' const date = new Date(dateStr) if (isNaN(date)) return dateStr return date.getFullYear() } function formatShortDate(dateStr) { if (!dateStr) return '' const date = new Date(dateStr) if (isNaN(date)) return dateStr return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } function escapeLatex(str) { if (!str) return '' str = str.replace(/[\u0300-\u036f]/g, '') return str.replace(/[&%$#_{}]/g, '\\$&').replace(/~/g, '\\textasciitilde{}').replace(/\^/g, '\\textasciicircum{}').replace(/ä/g, '{\\"a}').replace(/ö/g, '{\\"o}').replace(/ü/g, '{\\"u}').replace(/Ä/g, '{\\"A}').replace(/Ö/g, '{\\"O}').replace(/Ü/g, '{\\"U}').replace(/á/g, "\\'a").replace(/é/g, "\\'e").replace(/í/g, "\\'i").replace(/ó/g, "\\'o").replace(/ú/g, "\\'u").replace(/à/g, "\\`a").replace(/è/g, "\\`e").replace(/ì/g, "\\`i").replace(/ò/g, "\\`o").replace(/ù/g, "\\`u").replace(/ã/g, '{\\~a}').replace(/ñ/g, '{\\~n}').replace(/–/g, '--').replace(/—/g, '---').replace(/ç/g, '\\c c') } function generateCvLatex(resume, talks) { const basics = resume.basics || {} const talksByYear = {} for (const talk of talks || []) { if (!talk.date) continue const year = new Date(talk.date).getFullYear() if (!talksByYear[year]) talksByYear[year] = [] talksByYear[year].push(talk) } const sortedYears = Object.keys(talksByYear).sort((a, b) => b - a) let latex = '\\documentclass[11pt]{article}\n' latex += '\\usepackage[utf8]{inputenc}\n' latex += '\\usepackage[T1]{fontenc}\n' latex += '\\usepackage{geometry}\n' latex += '\\geometry{letterpaper, top=1in, bottom=1in, left=1in, right=1in}\n' latex += '\\usepackage{enumitem}\n' latex += '\\usepackage{titlesec}\n' latex += '\\pagestyle{empty}\n' latex += '\\titleformat{\\section}{\\bfseries\\Large}{\\thesection}{1em}{}\n' latex += '\\titleformat{\\subsection}{\\bfseries\\large}{\\thesubsection}{1em}{}\n' latex += '\\titlespacing{\\section}{0pt}{12pt}{6pt}\n' latex += '\\titlespacing{\\subsection}{0pt}{8pt}{4pt}\n' latex += '\\begin{document}\n' // Header latex += '\\begin{center}\n' latex += '{\\LARGE\\bfseries ' + escapeLatex(basics.name || '') + '}\\\\[4pt]\n' latex += '{\\large Curriculum Vitae}\\\\[8pt]\n' latex += escapeLatex(basics.email || '') + ' \\quad ' + escapeLatex(basics.phone || '') + ' \\quad ' + escapeLatex(basics.website || '') + '\n' latex += '\\end{center}\n' latex += '\\hrulefill\\\\[12pt]\n' // Education latex += '\\section{Education}\n' for (const edu of resume.education || []) { latex += '{\\bfseries ' + escapeLatex(edu.studyType) + ' in ' + escapeLatex(edu.area) + '}, ' + escapeLatex(edu.institution) + ', ' + formatYear(edu.endDate) + '\\\\[4pt]\n' } // Teaching latex += '\\section{Teaching}\n' for (const teach of resume.teaching || []) { latex += '{\\bfseries ' + escapeLatex(teach.company) + '}, \\textit{' + escapeLatex(teach.position) + '}\\\\[2pt]\n' latex += escapeLatex(formatMonth(teach.startDate)) + ' -- ' + escapeLatex(formatMonth(teach.endDate)) + '\\\\[8pt]\n' } // Lectures latex += '\\section{Lectures}\n' for (const year of sortedYears) { latex += '\\subsection{' + year + '}\n' for (const talk of talksByYear[year]) { latex += '{\\bfseries ' + escapeLatex(talk.location) + '}\\\\\n' const titles = Array.isArray(talk.title) ? talk.title : [talk.title] for (const t of titles) latex += '\\hspace{8pt}\\textit{' + escapeLatex(t) + '}\\\\\n' } latex += '\\\\[4pt]\n' } // Relevant Work latex += '\\section{Relevant Work}\n' for (const w of resume.work || []) { latex += '{\\bfseries ' + escapeLatex(w.company) + '}, \\textit{' + escapeLatex(w.position) + '}\\\\[2pt]\n' latex += escapeLatex(formatMonth(w.startDate)) + ' -- ' + escapeLatex(formatMonth(w.endDate)) + '\\\\[8pt]\n' } // Skills latex += '\\section{Skills}\n' latex += (resume.skills?.[0]?.keywords?.map(s => escapeLatex(s)).join(', ') || '') + '\\\\[8pt]\n' // Languages latex += '\\section{Languages}\n' latex += (resume.languages || []).map(l => escapeLatex(l.language) + ' (' + escapeLatex(l.fluency) + ')').join(', ') + '\\\\[8pt]\n' // Recordings latex += '\\section{Recordings}\n' latex += '\\subsection{Solo Albums}\n' for (const rel of resume.solo_releases || []) { latex += '{\\bfseries ' + escapeLatex(rel.title) + '}. ' + escapeLatex(rel.publisher) + '. ' + escapeLatex(rel.media_type) + '. ' + escapeLatex(rel.date) + '.\\\\[4pt]\n' } latex += '\\subsection{Compilation Albums}\n' for (const rel of resume.compilation_releases || []) { latex += '{\\bfseries ' + escapeLatex(rel.title) + '}. ' + escapeLatex(rel.publisher) + '. ' + escapeLatex(rel.media_type) + '. ' + escapeLatex(rel.date) + '. \\textit{featuring ' + escapeLatex(rel.work) + '}.\\\\[4pt]\n' } // Residencies latex += '\\section{Residencies and Awards}\n' for (const res of resume.residencies || []) { latex += '{\\bfseries ' + escapeLatex(res.org) + '}, ' + escapeLatex(res.date) + '\\\\[4pt]\n' } // References latex += '\\section{References}\n' for (const ref of resume.references || []) { latex += '{\\bfseries ' + escapeLatex(ref.name) + '}\\\\[2pt]\n' latex += escapeLatex(ref.position) + '\\\\[2pt]\n' latex += escapeLatex(ref.email) + '\\\\[8pt]\n' } latex += '\\end{document}\n' return latex } function generateWorksListLatex(resume, works, events) { const basics = resume.basics || {} const worksByYear = {} for (const work of works || []) { const year = work.date ? new Date(work.date).getFullYear() : 'Unknown' if (!worksByYear[year]) worksByYear[year] = [] const workEvents = (events || []).filter(e => e.program && e.program.some(p => p.work?.toLowerCase().includes(work.title.toLowerCase()))) worksByYear[year].push({ ...work, events: workEvents }) } const sortedYears = Object.keys(worksByYear).sort((a, b) => b - a) let latex = '\\documentclass[11pt]{article}\n' latex += '\\usepackage[utf8]{inputenc}\n' latex += '\\usepackage[T1]{fontenc}\n' latex += '\\usepackage{geometry}\n' latex += '\\geometry{letterpaper, top=1in, bottom=1in, left=1in, right=1in}\n' latex += '\\usepackage{enumitem}\n' latex += '\\usepackage{titlesec}\n' latex += '\\pagestyle{empty}\n' latex += '\\titleformat{\\section}{\\bfseries\\Large}{\\thesection}{1em}{}\n' latex += '\\titlespacing{\\section}{0pt}{12pt}{6pt}\n' latex += '\\begin{document}\n' // Header latex += '\\begin{center}\n' latex += '{\\LARGE\\bfseries ' + escapeLatex(basics.name || '') + '}\\\\[4pt]\n' latex += '{\\large Works List with Presentation History}\\\\[8pt]\n' latex += escapeLatex(basics.email || '') + ' \\quad ' + escapeLatex(basics.phone || '') + ' \\quad ' + escapeLatex(basics.website || '') + '\n' latex += '\\end{center}\n' latex += '\\hrulefill\\\\[12pt]\n' latex += 'A chronological performance / exhibition history, scores, and recordings are available at\\\\\n' latex += 'www.unboundedpress.org.\\\\\n' latex += 'All scores are also published or forthcoming through Frog Peak at\\\\\n' latex += 'www.frogpeak.org/fpartists/fpwinter.html.\\\\[12pt]\n' latex += '\\hrulefill\\\\[12pt]\n' for (const year of sortedYears) { latex += '\\section{' + year + '}\n' for (const work of worksByYear[year]) { latex += '{\\bfseries\\textit{' + escapeLatex(work.title) + '}}\\\\[-4pt]\n' if (work.instrument_tags) latex += '\\hspace{8pt}' + escapeLatex(work.instrument_tags.join(', ')) + '\\\\[-4pt]\n' for (const event of work.events || []) { const venue = event.venue || {} latex += '\\hspace{8pt}' + escapeLatex(venue.name) + '; ' + escapeLatex(venue.city) + ', ' + escapeLatex(venue.state) + ' -- ' + escapeLatex(formatShortDate(event.start_date)) + '\\\\\n' } latex += '\\\\[4pt]\n' } } latex += '\\end{document}\n' return latex } function compileLatex(latexContent, outputPath) { const tempDir = path.join(__dirname, 'temp') if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }) const texPath = path.join(tempDir, 'temp.tex') fs.writeFileSync(texPath, latexContent) try { execSync('cd ' + tempDir + ' && pdflatex temp.tex', { stdio: 'pipe' }) const pdfPath = path.join(tempDir, 'temp.pdf') if (fs.existsSync(pdfPath)) { fs.copyFileSync(pdfPath, outputPath) console.log('Generated:', outputPath) } } catch (e) { fs.writeFileSync(path.join(PUBLIC_DIR, 'debug.tex'), latexContent) console.error('Error generating PDF') } } const resume = readJson('resume.json')[0] const talks = readJson('talks.json') const works = readJson('works.json') const events = readJson('events.json') console.log('Generating CV...') compileLatex(generateCvLatex(resume, talks), path.join(PUBLIC_DIR, 'cv.pdf')) console.log('Generating Works List...') compileLatex(generateWorksListLatex(resume, works, events), path.join(PUBLIC_DIR, 'works_list.pdf')) console.log('Done!')