using System.Globalization; using System.Net; using System.Text; using System.Text.RegularExpressions; using JobTrackerApi.Models; namespace JobTrackerApi.Services; public sealed record ThemedCvRenderResult(string ThemeId, string SuggestedFileName, string Html); public interface IThemedCvRenderer { ThemedCvRenderResult Render(CvRenderModel model, CvTheme theme, CvVariantSettings settings); } // ONE render path for every theme. The theme (data) drives layout, palette, typography and spacing; // the variant settings apply a few runtime overrides (accent, fonts, density, page size, photo, // icons, page numbers). Adding a theme never touches this file. docs/architecture/cv-theme-engine.md. public sealed class ThemedCvRenderer : IThemedCvRenderer { public ThemedCvRenderResult Render(CvRenderModel model, CvTheme theme, CvVariantSettings settings) { settings = CvVariantSettingsJson.Normalize(settings); var accent = Override(settings.AccentColor, theme.Accent); var headingColor = theme.HeadingColor ?? accent; var headingFont = Override(settings.HeadingFont, theme.HeadingFont); var bodyFont = Override(settings.BodyFont, theme.BodyFont); var density = DensityScale(settings.Density); var pageSize = string.Equals(Override(settings.PageSize, "a4"), "letter", StringComparison.OrdinalIgnoreCase) ? "Letter" : "A4"; var pageDims = pageSize == "Letter" ? ("215.9mm", "279.4mm") : ("210mm", "297mm"); var showIcons = settings.ShowIcons && theme.DefaultIcons; var twoColumn = theme.Layout is "sidebar-left" or "sidebar-right"; var (sidebarHtml, mainHtml) = twoColumn ? SplitColumns(model, theme, showIcons) : (string.Empty, RenderSections(model.Sections, theme)); var css = BuildCss(theme, accent, headingColor, headingFont, bodyFont, density, pageDims, twoColumn); var header = RenderHeader(model, theme, showIcons, twoColumn); var body = theme.Layout switch { "sidebar-left" => $@"
{Sidebar(model, sidebarHtml, theme, showIcons)}
{mainHtml}
", "sidebar-right" => $@"
{mainHtml}
{Sidebar(model, sidebarHtml, theme, showIcons)}
", _ => $@"{header}
{mainHtml}
", }; // For two-column themes the header renders inside the sidebar; single/header-band render it on top. var page = twoColumn ? body : body; var html = $@" {Enc(model.FullName)} — {Enc(theme.Name)}
{page}
"; var fileName = Slug($"{model.FullName}-{theme.Id}") + ".pdf"; return new ThemedCvRenderResult(theme.Id, fileName, html); } private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, bool showIcons) { var sidebarKeys = theme.SidebarSections; var sidebar = new StringBuilder(); var mainSections = new List(); foreach (var section in model.Sections) { var baseKey = section.Key.Contains(':') ? section.Key : section.Key; if (sidebarKeys.Contains(baseKey, StringComparer.OrdinalIgnoreCase)) sidebar.Append(RenderSection(section, theme)); else mainSections.Add(section); } return (sidebar.ToString(), RenderSections(mainSections, theme)); } private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons) { var header = RenderHeader(model, theme, showIcons, twoColumn: true); var contact = theme.SidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase) ? RenderContactBlock(model.Contact, showIcons, sidebar: true) : string.Empty; return $@""; } private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn) { var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape); var kicker = theme.HeaderStyle == "kicker" ? @"
Curriculum Vitae
" : string.Empty; var name = $@"

{Enc(model.FullName)}

"; var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"
{Enc(model.Headline)}
"; var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false); var headerClass = twoColumn ? "hero" : $"header header-{theme.HeaderStyle}"; return $@"
{photo}
{kicker}{name}{headline}{contact}
"; } private static string RenderPhoto(string? dataUrl, string shape) { if (shape == "none" || string.IsNullOrWhiteSpace(dataUrl)) return string.Empty; return $@"
"; } private static string RenderContactBlock(List contact, bool showIcons, bool sidebar) { if (contact.Count == 0) return string.Empty; var items = new StringBuilder(); foreach (var c in contact) { var icon = showIcons ? Icon(c.Icon) : string.Empty; var text = c.Href is null ? Enc(c.Value) : $@"{Enc(c.Value)}"; items.Append($@"{icon}{text}"); } return $@"
{items}
"; } private static string RenderSections(IEnumerable sections, CvTheme theme) { var sb = new StringBuilder(); foreach (var section in sections) sb.Append(RenderSection(section, theme)); return sb.ToString(); } private static string RenderSection(CvRenderSection section, CvTheme theme) { if (section.IsEmpty) return string.Empty; var inner = section.Kind switch { "bullets" => $@"
    {Items(section.Bullets)}
", "tags" => $@"
    {string.Join("", section.Tags.Select(t => $@"
  • {Enc(t)}
  • "))}
", _ => string.Join("", section.Entries.Select(RenderEntry)), }; return $@"

{Enc(section.Title)}

{inner}
"; } private static string RenderEntry(CvRenderEntry entry) { var sb = new StringBuilder(); sb.Append(@"
"); var hasMeta = !string.IsNullOrWhiteSpace(entry.Meta); sb.Append(@"
"); sb.Append($@"
{Enc(entry.Title)}
"); if (hasMeta) sb.Append($@"
{Enc(entry.Meta)}
"); sb.Append("
"); if (!string.IsNullOrWhiteSpace(entry.Subtitle)) sb.Append($@"
{Enc(entry.Subtitle)}
"); if (entry.Bullets.Count > 0) sb.Append($@"
    {Items(entry.Bullets)}
"); if (entry.Tags.Count > 0) sb.Append($@"
    {string.Join("", entry.Tags.Select(t => $@"
  • {Enc(t)}
  • "))}
"); sb.Append("
"); return sb.ToString(); } private static string Items(IEnumerable items) => string.Join("", items.Select(i => $"
  • {Inline(i)}
  • ")); // Safe inline rich text for bullets/summary. HTML-escape EVERYTHING first (so any user markup is // inert), then re-introduce a tiny whitelist: **bold**, *italic*, __underline__, [text](url) with // http(s)/mailto only. Because escaping runs first, no user-supplied tag can survive — the only // tags in the output are the ones these patterns emit. private static readonly Regex LinkRx = new(@"\[([^\]]+)\]\((https?://[^\s)]+|mailto:[^\s)]+)\)", RegexOptions.Compiled); private static readonly Regex BoldRx = new(@"\*\*(.+?)\*\*", RegexOptions.Compiled); private static readonly Regex UnderlineRx = new(@"__(.+?)__", RegexOptions.Compiled); private static readonly Regex ItalicRx = new(@"(? $"{m.Groups[1].Value}"); s = BoldRx.Replace(s, "$1"); s = UnderlineRx.Replace(s, "$1"); s = ItalicRx.Replace(s, "$1"); return s; } private static string BuildCss(CvTheme t, string accent, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn) { var margin = F(t.PageMarginMm * density); var sectionGap = F(t.SectionGapMm * density); var entryGap = F(t.EntryGapMm * density); var headingCss = t.HeadingStyle switch { "underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}", "plain" => ".section-title{letter-spacing:.01em;}", "bar" => $".section-title{{padding-left:2.5mm;border-left:3px solid {accent};}}", _ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(t.HeadingSizePt * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}", }; var layoutCss = twoColumn ? $@".cols{{display:grid;grid-template-columns:{(t.Layout == "sidebar-right" ? $"1fr {F(t.SidebarWidthMm)}mm" : $"{F(t.SidebarWidthMm)}mm 1fr")};min-height:{page.h};}} .sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}} .sidebar .section-title{{color:{t.SidebarInk};border-color:rgba(255,255,255,.35);}} .sidebar .tag{{border-color:rgba(255,255,255,.4);}} .sidebar a{{color:inherit;}} .main{{padding:{margin}mm;}} .hero .name{{color:{t.SidebarInk};}}" : $@".main{{padding:0 {margin}mm {margin}mm {margin}mm;}} .header{{padding:{margin}mm {margin}mm {F(t.SectionGapMm * density)}mm {margin}mm;display:flex;gap:6mm;align-items:center;}} .header-band{{background:{accent};color:#fff;}} .header-band .name,.header-band .headline,.header-band a{{color:#fff;}} .header-centered{{flex-direction:column;text-align:center;justify-content:center;}} .header-centered .contact{{justify-content:center;}} .header-plain{{border-bottom:2px solid {accent};}}"; return $@" *{{box-sizing:border-box;}} body{{margin:0;background:#e9edf2;color:{t.Ink};font-family:{bodyFont};font-size:{F(t.BodySizePt)}pt;line-height:{F(t.LineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}} .page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{t.Paper};overflow:hidden;}} h1,h2{{font-family:{headingFont};}} .name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.Ink};line-height:1.1;}} .kicker{{text-transform:uppercase;letter-spacing:.3em;font-size:7.5pt;color:{accent};margin-bottom:1.5mm;}} .headline{{margin-top:1.5mm;color:{t.Muted};font-size:{F(t.BodySizePt + 0.5)}pt;}} .head-text{{flex:1;}} .photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}} .photo-square{{border-radius:2mm;}} .photo-rounded{{border-radius:5mm;}} .photo-circle{{border-radius:50%;}} .photo img{{width:100%;height:100%;object-fit:cover;display:block;}} .contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;margin-top:2.5mm;}} .contact-stacked{{flex-direction:column;gap:1.8mm;}} .contact-item{{display:inline-flex;align-items:center;gap:1.2mm;}} .contact a{{color:inherit;text-decoration:none;}} .contact svg{{width:3.2mm;height:3.2mm;flex:0 0 auto;opacity:.85;}} .hero{{margin-bottom:{sectionGap}mm;}} .hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}} .section{{margin-top:{sectionGap}mm;}} .section:first-child{{margin-top:0;}} .section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(t.HeadingSizePt)}pt;font-weight:700;color:{headingColor};}} {headingCss} .bullets{{margin:0;padding-left:4.5mm;}} .bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}} .tags{{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:1.8mm;}} .tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(t.BodySizePt - 0.5)}pt;}} .entry{{margin-bottom:{entryGap}mm;}} .entry:last-child{{margin-bottom:0;}} .entry-head{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;}} .entry-title{{font-weight:700;font-size:{F(t.BodySizePt + 1)}pt;}} .entry-meta{{color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;white-space:nowrap;}} .entry-subtitle{{color:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}} .entry-tags{{margin-top:1.4mm;}} {layoutCss} /* Print quality: keep an entry whole across a page break, keep a heading with its content, and avoid single dangling lines. Chromium honours these in the Playwright PDF pass. */ .entry{{break-inside:avoid;page-break-inside:avoid;}} .section-title{{break-after:avoid;page-break-after:avoid;}} .tag,.contact-item{{break-inside:avoid;}} .bullets li{{orphans:2;widows:2;}} @page{{size:{page.w} {page.h};margin:0;}} "; } private static double DensityScale(string? density) => (density ?? "balanced").Trim().ToLowerInvariant() switch { "compact" => 0.82, "roomy" => 1.18, _ => 1.0, }; private static string Icon(string kind) => kind switch { "email" => Svg("M2 4h12v8H2z M2 4l6 4 6-4"), "phone" => Svg("M3 3c0 5 5 10 10 10l1-3-3-1-1 1c-2-1-4-3-5-5l1-1-1-3z"), "location" => Svg("M8 1a4 4 0 0 0-4 4c0 3 4 8 4 8s4-5 4-8a4 4 0 0 0-4-4z M8 5v.01"), "web" => Svg("M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1z M1 8h14 M8 1c2 2 2 12 0 14 M8 1c-2 2-2 12 0 14"), "linkedin" => Svg("M3 6v7 M3 3v.01 M7 13V6 M7 9c0-3 5-3 5 0v4"), _ => string.Empty, }; private static string Svg(string path) => $@""; private static string Override(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); private static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture); private static string Enc(string? v) => WebUtility.HtmlEncode(v ?? string.Empty); private static string Attr(string? v) => WebUtility.HtmlEncode(v ?? string.Empty).Replace("'", "'", StringComparison.Ordinal); private static string Slug(string v) { var cleaned = new string((v ?? string.Empty).ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()); while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal); return cleaned.Trim('-'); } }