package cv import ( "bytes" "context" "database/sql" "embed" "errors" "fmt" "os" "os/exec" "path/filepath" "regexp" "strings" "time" ) //go:embed templates/cv-template.tex var templateFS embed.FS // Required structural markers in the filled template. Borrowed from // career-ops generate-latex.mjs so the output passes the same validation. var requiredSections = []string{ `\section{Education}`, `\section{Work Experience}`, `\section{Personal Projects}`, `\section{Technical Skills}`, } var requiredCommands = []string{ `\resumeSubheading`, `\resumeItem`, `\resumeProjectHeading`, } // LaTeXExporter fills the embedded CV template with profile contact info and // writes a .tex file. If pdflatex is on PATH, it also compiles to PDF. // // Section placeholders ({{EDUCATION}}, {{EXPERIENCE}}, {{PROJECTS}}, {{SKILLS}}) // are left as markers by default — the user is expected to populate them from // cv.md in Overleaf or via a follow-up Claude pass. The direct-fill mode // still produces a working skeleton that compiles and has the user's contact // block correct. type LaTeXExporter struct { DB *sql.DB CVPath string // path to cv.md (optional; used for the skills fallback) OutputDir string // defaults to $HOME/.apex/cv } // Output carries paths of everything written so the TUI can link to them. type Output struct { TeXPath string PDFPath string // empty if pdflatex not available Issues []string } // Export writes the .tex file (and PDF if pdflatex is available). // Returns the paths and any structural issues flagged by validation. func (e *LaTeXExporter) Export(ctx context.Context) (Output, error) { tmpl, err := templateFS.ReadFile("templates/cv-template.tex") if err != nil { return Output{}, fmt.Errorf("load template: %w", err) } profile, err := loadProfileContact(e.DB) if err != nil { return Output{}, fmt.Errorf("load profile: %w", err) } filled := fillContactPlaceholders(string(tmpl), profile) // Section placeholders get a one-line filler so the document compiles // even without user edits. Validation still flags them via the // {{...}} scan below so the user knows what's pending. filled = fillStubSections(filled) issues := validate(filled) dir := e.OutputDir if dir == "" { home, _ := os.UserHomeDir() dir = filepath.Join(home, ".apex", "cv") } if err := os.MkdirAll(dir, 0o755); err != nil { return Output{}, fmt.Errorf("mkdir output: %w", err) } name := sanitizeFilename(profile.Name) if name == "" { name = "cv" } date := time.Now().Format("2006-01-02") texPath := filepath.Join(dir, fmt.Sprintf("%s-%s.tex", name, date)) if err := atomicWrite(texPath, []byte(filled)); err != nil { return Output{}, fmt.Errorf("write tex: %w", err) } out := Output{TeXPath: texPath, Issues: issues} pdfPath, err := compilePDF(ctx, texPath, dir) if err != nil { // pdflatex absence is not fatal — the .tex is still useful. Only // surface the error via Issues so the caller can decide to warn. if !errors.Is(err, errPdflatexMissing) { return out, fmt.Errorf("pdflatex: %w", err) } out.Issues = append(out.Issues, "pdflatex not on PATH — .tex written, PDF skipped") return out, nil } out.PDFPath = pdfPath return out, nil } type contactProfile struct { Name string Email string Location string Links []string } // loadProfileContact reads the minimal contact fields from the single-row // profile table Phase 1 wrote. Missing profile is not fatal — placeholders // fall back to obvious TODO markers in the .tex output. func loadProfileContact(db *sql.DB) (contactProfile, error) { if db == nil { return contactProfile{Name: "Your Name"}, nil } row := db.QueryRow(`SELECT COALESCE(name,''), COALESCE(email,''), COALESCE(location,'') FROM profile WHERE id = 1`) var p contactProfile if err := row.Scan(&p.Name, &p.Email, &p.Location); err != nil { if err == sql.ErrNoRows { return contactProfile{Name: "Your Name"}, nil } return contactProfile{}, err } if p.Name == "" { p.Name = "Your Name" } return p, nil } // fillContactPlaceholders replaces the top-of-document identity fields. // LaTeX escaping is conservative — we only replace where a safe subset fits. func fillContactPlaceholders(tmpl string, p contactProfile) string { contact := p.Location if p.Email != "" { if contact != "" { contact += " · " } contact += p.Email } emailURL := p.Email emailDisp := p.Email // Minimal LaTeX escaping for user-provided strings. Only # & _ % are // common in email/location. Full escaping would need more, but these // cover 99% of cases. texEsc := func(s string) string { s = strings.ReplaceAll(s, `\`, `\textbackslash{}`) s = strings.ReplaceAll(s, `&`, `\&`) s = strings.ReplaceAll(s, `%`, `\%`) s = strings.ReplaceAll(s, `#`, `\#`) s = strings.ReplaceAll(s, `_`, `\_`) s = strings.ReplaceAll(s, `$`, `\$`) // ~ and ^ are active in LaTeX (tie / accent) — neutralize them so // a location like "c^top" can't inject a superscript command. s = strings.ReplaceAll(s, `~`, `\textasciitilde{}`) s = strings.ReplaceAll(s, `^`, `\textasciicircum{}`) return s } replacements := map[string]string{ "{{NAME}}": texEsc(p.Name), "{{CONTACT_LINE}}": texEsc(contact), "{{EMAIL_URL}}": emailURL, "{{EMAIL_DISPLAY}}": texEsc(emailDisp), "{{LINKEDIN_URL}}": "https://linkedin.com/in/TODO", "{{LINKEDIN_DISPLAY}}": "linkedin.com/in/TODO", "{{GITHUB_URL}}": "https://github.com/TODO", "{{GITHUB_DISPLAY}}": "github.com/TODO", } out := tmpl for k, v := range replacements { out = strings.ReplaceAll(out, k, v) } return out } // fillStubSections replaces section placeholders with a minimal valid body so // the document compiles. Each stub still includes the required commands so // template validation passes. func fillStubSections(s string) string { stubs := map[string]string{ "{{EDUCATION}}": `\resumeSubHeadingListStart \resumeSubheading{Your School}{Year}{Degree}{Location} \resumeSubHeadingListEnd`, "{{EXPERIENCE}}": `\resumeSubHeadingListStart \resumeSubheading{Your Role}{Dates}{Employer}{Location} \resumeItemListStart \resumeItem{Responsibility or achievement.} \resumeItemListEnd \resumeSubHeadingListEnd`, "{{PROJECTS}}": `\resumeSubHeadingListStart \resumeProjectHeading{\textbf{Project Name} $|$ \emph{Stack}}{Link} \resumeItemListStart \resumeItem{What the project does.} \resumeItemListEnd \resumeSubHeadingListEnd`, "{{SKILLS}}": `\begin{itemize}[leftmargin=0.15in, label={}] \small{\item{ \textbf{Languages}{: TODO} \\ \textbf{Tools}{: TODO} }} \end{itemize}`, } out := s for k, v := range stubs { out = strings.ReplaceAll(out, k, v) } return out } // validate mirrors the checks in career-ops generate-latex.mjs: required // sections, required commands, begin/end document, and no unresolved // placeholders. Returns a list of issues (empty = clean). func validate(content string) []string { var issues []string for _, s := range requiredSections { if !strings.Contains(content, s) { issues = append(issues, "missing section: "+s) } } for _, c := range requiredCommands { if !strings.Contains(content, c) { issues = append(issues, "missing command: "+c) } } if !strings.Contains(content, `\begin{document}`) { issues = append(issues, `missing \begin{document}`) } if !strings.Contains(content, `\end{document}`) { issues = append(issues, `missing \end{document}`) } if unresolved := unresolvedPlaceholderRE.FindAllString(content, -1); len(unresolved) > 0 { seen := make(map[string]struct{}) var uniq []string for _, p := range unresolved { if _, ok := seen[p]; ok { continue } seen[p] = struct{}{} uniq = append(uniq, p) } issues = append(issues, "unresolved placeholders: "+strings.Join(uniq, ", ")) } return issues } var unresolvedPlaceholderRE = regexp.MustCompile(`\{\{[A-Z_]+\}\}`) // compilePDF shells to pdflatex in the output directory so .aux/.log land // next to the .tex and not pollute the current working directory. // Returns errPdflatexMissing when the binary isn't on PATH so callers can // degrade gracefully. func compilePDF(ctx context.Context, texPath, workDir string) (string, error) { if _, err := exec.LookPath("pdflatex"); err != nil { return "", errPdflatexMissing } runCtx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() // -no-shell-escape blocks \write18{...} and \input of unvetted files // inside any .tex we compile — defense-in-depth for the case where a // future flow lets the user paste section contents in-app. cmd := exec.CommandContext(runCtx, "pdflatex", "-no-shell-escape", "-interaction=nonstopmode", "-halt-on-error", "-output-directory="+workDir, texPath, ) var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf if err := cmd.Run(); err != nil { return "", fmt.Errorf("pdflatex failed: %w (tail: %s)", err, tail(buf.String(), 500)) } base := strings.TrimSuffix(filepath.Base(texPath), ".tex") pdf := filepath.Join(workDir, base+".pdf") // Best-effort cleanup of pdflatex's byproducts. for _, ext := range []string{".aux", ".log", ".out"} { _ = os.Remove(filepath.Join(workDir, base+ext)) } return pdf, nil } var errPdflatexMissing = errors.New("pdflatex: not found on PATH") // tail returns the last n runes of s (useful for bounded error messages). func tail(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return "…" + string(r[len(r)-n:]) } // sanitizeFilename strips filesystem-unsafe characters from a profile name // so the output .tex doesn't get stuck with spaces or slashes. Consecutive // dashes collapse so "Dr. María" doesn't yield "dr--mara". func sanitizeFilename(s string) string { s = strings.ToLower(strings.TrimSpace(s)) var b strings.Builder prevDash := false for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) prevDash = false case r == ' ' || r == '-' || r == '_' || r == '.' || r == '/': if !prevDash && b.Len() > 0 { b.WriteRune('-') prevDash = true } } } return strings.Trim(b.String(), "-") } // atomicWrite is a local copy of the pattern used by internal/eval/report.go. // Keeping it package-local avoids a cross-package dep that would drag in // unrelated types (Evaluation, JobContext) just to share one helper. func atomicWrite(path string, data []byte) error { dir := filepath.Dir(path) f, err := os.CreateTemp(dir, ".cv-*.tmp") if err != nil { return err } tmp := f.Name() if _, err := f.Write(data); err != nil { f.Close() os.Remove(tmp) return err } if err := f.Close(); err != nil { os.Remove(tmp) return err } return os.Rename(tmp, path) }