using System.Net;
using System.Text;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
public sealed record TailoredCvRenderResult(string TemplateId, string SuggestedFileName, string Html);
public interface ICvTemplateRenderer
{
TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null);
}
public sealed class CvTemplateRenderer : ICvTemplateRenderer
{
public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null)
{
var normalized = TailoredCvDraftJson.Normalize(document);
var effectiveTemplateId = NormalizeTemplateId(templateId ?? normalized.TemplateId);
normalized.TemplateId = effectiveTemplateId;
var suggestedFileName = Slugify($"{candidateName}-{jobTitle}-{effectiveTemplateId}") + ".pdf";
var html = effectiveTemplateId switch
{
"harvard" => RenderHarvard(normalized, candidateName, jobTitle, companyName),
"auckland" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Auckland", roundedPhoto: false, curvedHeader: false),
"edinburgh" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Edinburgh", roundedPhoto: true, curvedHeader: true),
"monarch" => RenderMonarch(normalized, candidateName, jobTitle, companyName, photoDataUrl),
"fjord" => RenderFjord(normalized, candidateName, jobTitle, companyName, photoDataUrl),
_ => RenderAtsMinimal(normalized, candidateName, jobTitle, companyName, photoDataUrl)
};
return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html);
}
private static string NormalizeTemplateId(string? value)
{
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"base" => "ats-minimal",
"legacy-text" => "ats-minimal",
"harvard" => "harvard",
"auckland" => "auckland",
"edinburgh" => "edinburgh",
"monarch" => "monarch",
"fjord" => "fjord",
_ => "ats-minimal"
};
}
private static string RenderAtsMinimal(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
{
var accent = ResolveAccent(document.RenderOptions.AccentColor);
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
var body = RenderMainSections(document, accent, headingStyle: "caps-rule");
var companyFocusMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"Company focus: {Encode(companyName)}";
var photoMarkup = showPhoto ? $"
}\")
" : string.Empty;
return $@"
{Encode(candidateName)} — ATS Minimal
{body}
";
}
private static string RenderHarvard(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName)
{
var accent = ResolveAccent(document.RenderOptions.AccentColor);
var body = RenderMainSections(document, accent, headingStyle: "harvard");
var contactLine = string.Join(" • ", new[]
{
string.IsNullOrWhiteSpace(companyName) ? null : $"Targeting {Encode(companyName)}",
Encode(jobTitle)
}.Where(x => !string.IsNullOrWhiteSpace(x)));
return $@"
{Encode(candidateName)} — Harvard
{body}
";
}
private static string RenderSidebar(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl, string templateLabel, bool roundedPhoto, bool curvedHeader)
{
var accent = ResolveAccent(document.RenderOptions.AccentColor);
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
var sidebarSections = new StringBuilder();
sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[]
{
$"Name\n{Encode(candidateName)}",
$"Target role\n{Encode(jobTitle)}",
string.IsNullOrWhiteSpace(companyName) ? null : $"Company focus\n{Encode(companyName)}"
}));
if (document.CustomSections.Count > 0)
{
foreach (var section in document.CustomSections.Take(2))
{
sidebarSections.Append(RenderSidebarMetaSection(section.Title ?? "Additional", section.Items));
}
}
if (document.SelectedSkills.Count > 0)
{
sidebarSections.Append(RenderSidebarMetaSection("Skills", document.SelectedSkills.Take(8)));
}
var main = RenderMainSections(document, accent, headingStyle: "sidebar");
var photoClass = roundedPhoto ? "photo round" : "photo";
var photoMarkup = showPhoto ? $"" : string.Empty;
var heroClass = curvedHeader ? "hero curved" : "hero";
return $@"
{Encode(candidateName)} — {Encode(templateLabel)}
";
}
private static string RenderMonarch(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
{
var accent = ResolveAccent(document.RenderOptions.AccentColor);
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
var photoMarkup = showPhoto ? $"" : string.Empty;
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"Tailored toward {Encode(companyName)}
";
return $@"
{Encode(candidateName)} — Monarch
{(!string.IsNullOrWhiteSpace(jobTitle) ? $"Primary role target: {Encode(jobTitle)}
" : string.Empty)}
{body}
";
}
private static string RenderFjord(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
{
var accent = ResolveAccent(document.RenderOptions.AccentColor);
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
var photoMarkup = showPhoto ? $"" : string.Empty;
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"{Encode(companyName)}";
return $@"
{Encode(candidateName)} — Fjord
";
}
private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle)
{
var sectionOrder = document.RenderOptions.SectionOrder.Count == 0
? new List { "summary", "skills", "experience", "education", "custom" }
: document.RenderOptions.SectionOrder;
var sections = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["summary"] = RenderListSection("Profile", document.Summary, bulletList: true),
["skills"] = RenderSkillSection(document.SelectedSkills),
["experience"] = RenderExperienceSection(document.Experience),
["education"] = RenderEducationSection(document.Education),
["custom"] = string.Join(string.Empty, document.CustomSections.Select(RenderCustomSection)),
};
return string.Join(string.Empty, sectionOrder
.Select(key => sections.TryGetValue(key, out var section) ? section : string.Empty)
.Where(section => !string.IsNullOrWhiteSpace(section)));
}
private static string BaseSectionCss(string accent, string headingStyle)
{
var headingCss = headingStyle switch
{
"harvard" => ".section-title{font-size:17pt;font-weight:700;border-bottom:1.5px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}",
"sidebar" => ".section-title{font-size:14pt;font-weight:700;letter-spacing:.02em;margin-bottom:3mm;}",
_ => ".section-title{font-size:9pt;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);border-bottom:1px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}"
};
return $@"
.section{{margin-top:6mm;}}
{headingCss}
.summary,.custom-list,.education-list,.experience-bullets{{margin:0;padding-left:4.5mm;}}
.summary li,.custom-list li,.education-list li,.experience-bullets li{{margin:0 0 1.6mm 0;line-height:1.42;}}
.skills{{list-style:none;padding-left:0;display:flex;flex-wrap:wrap;gap:2mm;}}
.skill-pill{{border:1px solid var(--line);border-radius:999px;padding:1mm 2.4mm;font-size:9pt;}}
.entry{{margin-bottom:4.8mm;}}
.entry-header{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;margin-bottom:1.2mm;}}
.entry-title{{font-weight:700;font-size:11pt;}}
.entry-meta{{color:var(--muted);font-size:9pt;text-align:right;white-space:nowrap;}}
.entry-subtitle{{color:var(--muted);font-size:9.5pt;margin-bottom:1.3mm;}}";
}
private static string RenderSidebarMetaSection(string title, IEnumerable items)
{
var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $""));
if (string.IsNullOrWhiteSpace(content)) return string.Empty;
return $"";
}
private static string RenderListSection(string title, IReadOnlyCollection items, bool bulletList)
{
if (items.Count == 0) return string.Empty;
var tag = bulletList ? "summary" : "custom-list";
return $"{Encode(title)}
{string.Join(string.Empty, items.Select(item => $"- {Encode(item)}
"))}
";
}
private static string RenderSkillSection(IReadOnlyCollection skills)
{
if (skills.Count == 0) return string.Empty;
return $"Skills
{string.Join(string.Empty, skills.Select(skill => $"- {Encode(skill)}
"))}
";
}
private static string RenderExperienceSection(IReadOnlyCollection experience)
{
if (experience.Count == 0) return string.Empty;
var items = new StringBuilder();
foreach (var entry in experience)
{
var subtitle = string.Join(" · ", new[] { entry.Company, entry.Location }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Encode));
var dateRange = FormatDateRange(entry.Start, entry.End, entry.IsCurrent);
items.Append("");
items.Append($"");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"{subtitle}
");
if (entry.Bullets.Count > 0) items.Append($"{string.Join(string.Empty, entry.Bullets.Select(bullet => $"- {Encode(bullet)}
"))}
");
items.Append("");
}
return $"Professional Experience
{items}";
}
private static string RenderEducationSection(IReadOnlyCollection education)
{
if (education.Count == 0) return string.Empty;
var items = new StringBuilder();
foreach (var entry in education)
{
var subtitle = string.Join(" · ", new[] { entry.Institution, entry.Location, FormatDateRange(entry.Start, entry.End, false) }
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(Encode));
items.Append("");
var title = string.IsNullOrWhiteSpace(entry.QualificationLevel)
? entry.Qualification
: $"{entry.Qualification} ({entry.QualificationLevel})";
items.Append($"{Encode(title)}
");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"{subtitle}
");
if (entry.Details.Count > 0) items.Append($"{string.Join(string.Empty, entry.Details.Select(detail => $"- {Encode(detail)}
"))}
");
items.Append("");
}
return $"";
}
private static string RenderCustomSection(TailoredCvCustomSection section)
{
if (section.Items.Count == 0) return string.Empty;
return $"{Encode(section.Title ?? "Additional Information")}
{string.Join(string.Empty, section.Items.Select(item => $"- {Encode(item)}
"))}
";
}
private static string FormatDateRange(string? start, string? end, bool isCurrent)
{
var normalizedStart = (start ?? string.Empty).Trim();
var normalizedEnd = (end ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedStart) && string.IsNullOrWhiteSpace(normalizedEnd)) return string.Empty;
if (string.IsNullOrWhiteSpace(normalizedStart)) return normalizedEnd;
return $"{normalizedStart} - {(isCurrent ? "Present" : string.IsNullOrWhiteSpace(normalizedEnd) ? "Present" : normalizedEnd)}";
}
private static string ResolveAccent(string? accentColor)
{
var normalized = (accentColor ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"slate" => "#334155",
"blue" => "#1d4ed8",
"emerald" => "#047857",
"plum" => "#7c3aed",
"brick" => "#b45309",
_ when normalized.StartsWith("#") => normalized,
_ => "#334155"
};
}
private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
private static string EncodeAttribute(string? value) => WebUtility.HtmlEncode(value ?? string.Empty).Replace("'", "'", StringComparison.Ordinal);
private static string Slugify(string value)
{
var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray());
while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal);
return cleaned.Trim('-');
}
}