portfolio/scripts/generate-pdfs.js

243 lines
9.9 KiB
JavaScript
Raw Normal View History

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!')