feat: add server-backed profile CV builder pipeline

This commit is contained in:
2026-04-01 12:25:35 +02:00
parent 22d7dd3573
commit 0551a525a8
7 changed files with 625 additions and 23 deletions
+108 -1
View File
@@ -24,6 +24,8 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
"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);
@@ -39,6 +41,8 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
"harvard" => "harvard",
"auckland" => "auckland",
"edinburgh" => "edinburgh",
"monarch" => "monarch",
"fjord" => "fjord",
_ => "ats-minimal"
};
}
@@ -201,6 +205,106 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
</html>";
}
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 ? $"<div class=\"monarch-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<div class=\"monarch-company\">Tailored toward {Encode(companyName)}</div>";
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — Monarch</title>
<style>
:root {{ --accent:{accent}; --ink:#1c1917; --muted:#57534e; --paper:#fffdf8; --panel:#f7efe6; --line:#d6c1a8; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#efe7dc; color:var(--ink); font-family:'Times New Roman', Georgia, serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
.monarch-shell {{ border:1px solid var(--line); padding:10mm; position:relative; }}
.monarch-shell::before {{ content:''; position:absolute; inset:6mm; border:1px solid color-mix(in srgb, var(--line) 70%, white); pointer-events:none; }}
.monarch-header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; align-items:center; margin-bottom:8mm; }}
.monarch-kicker {{ display:inline-block; text-transform:uppercase; letter-spacing:.3em; font-size:8pt; color:var(--accent); margin-bottom:2mm; }}
.monarch-name {{ margin:0; font-size:28pt; line-height:1.05; }}
.monarch-headline {{ margin-top:2mm; font-size:11pt; color:var(--muted); max-width:130mm; }}
.monarch-company {{ margin-top:2mm; font-size:9pt; color:var(--accent); text-transform:uppercase; letter-spacing:.16em; }}
.monarch-photo {{ width:30mm; height:38mm; border:1px solid var(--line); background:var(--panel); overflow:hidden; }}
.monarch-photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
.monarch-summary {{ margin-bottom:5mm; padding:4mm 5mm; background:var(--panel); border-left:3px solid var(--accent); font-size:10pt; color:var(--muted); }}
{BaseSectionCss(accent, "harvard")}
.section-title {{ text-transform:uppercase; letter-spacing:.12em; font-size:10pt; }}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<section class=""monarch-shell"">
<header class=""monarch-header"">
<div>
<span class=""monarch-kicker"">Executive CV</span>
<h1 class=""monarch-name"">{Encode(candidateName)}</h1>
<div class=""monarch-headline"">{Encode(document.Headline ?? jobTitle)}</div>
{companyMarkup}
</div>
{photoMarkup}
</header>
{(!string.IsNullOrWhiteSpace(jobTitle) ? $"<div class=\"monarch-summary\">Primary role target: {Encode(jobTitle)}</div>" : string.Empty)}
{body}
</section>
</main>
</body>
</html>";
}
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 ? $"<div class=\"fjord-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<span>{Encode(companyName)}</span>";
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — Fjord</title>
<style>
:root {{ --accent:{accent}; --ink:#102a43; --muted:#486581; --panel:#e6f1f3; --line:#9fb3c8; --paper:#fbfdff; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#d9e8ef; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:0; }}
.fjord-grid {{ display:grid; grid-template-columns:72mm 1fr; min-height:297mm; }}
.fjord-rail {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 15%, white)); color:white; padding:16mm 8mm; }}
.fjord-name {{ margin:0; font-size:21pt; line-height:1.08; }}
.fjord-headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
.fjord-meta {{ margin-top:4mm; font-size:8.5pt; display:flex; flex-direction:column; gap:1.2mm; opacity:.9; }}
.fjord-photo {{ width:28mm; height:28mm; border-radius:50%; overflow:hidden; border:2px solid rgba(255,255,255,.65); margin-top:6mm; }}
.fjord-photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
.fjord-main {{ padding:14mm 14mm 14mm 10mm; }}
{BaseSectionCss(accent, "sidebar")}
.section {{ margin-top:5mm; }}
.skills {{ gap:1.5mm; }}
.skill-pill {{ background:var(--panel); border-color:transparent; color:var(--ink); }}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<section class=""fjord-grid"">
<aside class=""fjord-rail"">
<h1 class=""fjord-name"">{Encode(candidateName)}</h1>
<div class=""fjord-headline"">{Encode(document.Headline ?? jobTitle)}</div>
<div class=""fjord-meta""><span>{Encode(jobTitle)}</span>{companyMarkup}<span>Template: Fjord</span></div>
{photoMarkup}
</aside>
<section class=""fjord-main"">{body}</section>
</section>
</main>
</body>
</html>";
}
private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle)
{
var sectionOrder = document.RenderOptions.SectionOrder.Count == 0
@@ -291,7 +395,10 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(Encode));
items.Append("<article class=\"entry\">");
items.Append($"<div class=\"entry-title\">{Encode(entry.Qualification)}</div>");
var title = string.IsNullOrWhiteSpace(entry.QualificationLevel)
? entry.Qualification
: $"{entry.Qualification} ({entry.QualificationLevel})";
items.Append($"<div class=\"entry-title\">{Encode(title)}</div>");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(detail => $"<li>{Encode(detail)}</li>"))}</ul>");
items.Append("</article>");