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 headerInk = ContrastInk(accent);
var ink = Override(settings.TextColor, theme.Ink);
var muted = Override(settings.MutedColor, theme.Muted);
var paper = Override(settings.BackgroundColor, theme.Paper);
var headingColor = Override(settings.HeadingColor, theme.HeadingColor ?? accent);
var headingFont = Override(settings.HeadingFont, theme.HeadingFont);
var bodyFont = Override(settings.BodyFont, theme.BodyFont);
var density = DensityScale(settings.Density);
var layout = Override(settings.Layout, theme.Layout);
var headingStyle = Override(settings.HeadingStyle, theme.HeadingStyle);
var headerStyle = Override(settings.HeaderStyle, theme.HeaderStyle);
var sidebarSections = settings.SidebarSections ?? theme.SidebarSections;
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 = layout is "sidebar-left" or "sidebar-right";
var (sidebarHtml, mainHtml) = twoColumn
? SplitColumns(model, theme, sidebarSections, settings)
: (string.Empty, RenderSections(model.Sections, theme, settings));
var css = BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, headingFont, bodyFont, headingStyle, density, pageDims, layout, twoColumn);
var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle);
var body = layout switch
{
"sidebar-left" => $@"
{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}
",
"sidebar-right" => $@"{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}
",
_ => $@"{header}",
};
var html = $@"
{Enc(model.FullName)} — {Enc(theme.Name)}
{body}
";
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, IReadOnlyCollection sidebarKeys, CvVariantSettings settings)
{
var sidebar = new StringBuilder();
var mainSections = new List();
foreach (var section in model.Sections)
{
if (sidebarKeys.Contains(section.Key, StringComparer.OrdinalIgnoreCase))
sidebar.Append(RenderSection(section, theme, settings));
else
mainSections.Add(section);
}
return (sidebar.ToString(), RenderSections(mainSections, theme, settings));
}
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection sidebarSections, string headerStyle)
{
var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle);
var contact = 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, string headerStyle)
{
var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape);
var kicker = 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-{headerStyle}";
return $@"";
}
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, CvVariantSettings settings)
{
var sb = new StringBuilder();
foreach (var section in sections) sb.Append(RenderSection(section, theme, settings));
return sb.ToString();
}
private static string RenderSection(CvRenderSection section, CvTheme theme, CvVariantSettings? settings = null)
{
if (section.IsEmpty) return string.Empty;
var inner = section.Kind switch
{
"bullets" => $@"",
"tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"{string.Join(" · ", section.Tags.Select(Enc))}
",
"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(IsFlowingEntry(entry) ? @"" : @"");
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($@"");
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)}"));
// Short entries stay together. Large entries must be allowed to paginate between bullets or
// paragraphs; forcing an entry taller than the printable area to remain whole clips content in
// Chromium. The renderer only selects the pagination strategy—it never shrinks the text.
private static bool IsFlowingEntry(CvRenderEntry entry)
{
var textLength = (entry.Title?.Length ?? 0)
+ (entry.Subtitle?.Length ?? 0)
+ (entry.Meta?.Length ?? 0)
+ entry.Bullets.Sum(item => item?.Length ?? 0)
+ entry.Tags.Sum(item => item?.Length ?? 0);
return entry.Bullets.Count > 5 || entry.Bullets.Any(IsFlowingItem) || textLength > 900;
}
private static bool IsFlowingItem(string? item) => (item?.Length ?? 0) > 360;
// 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, CvVariantSettings settings, string accent, string headerInk, string ink, string muted, string paper, string headingColor, string headingFont, string bodyFont, string headingStyle, double density, (string w, string h) page, string layout, bool twoColumn)
{
var margin = F((settings.PageMarginMm ?? t.PageMarginMm) * density);
var sectionGap = F((settings.SectionGapMm ?? t.SectionGapMm) * density);
var entryGap = F((settings.EntryGapMm ?? t.EntryGapMm) * density);
var bodySize = settings.BaseFontSizePt ?? t.BodySizePt;
var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt;
var lineHeight = settings.LineHeight ?? t.LineHeight;
var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm;
var headingCss = 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(headingSize * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}",
};
var columnTemplate = layout == "sidebar-right"
? $"minmax(0,1fr) {F(sidebarWidth)}mm"
: $"{F(sidebarWidth)}mm minmax(0,1fr)";
var layoutCss = twoColumn
? $@".cols{{display:grid;grid-template-columns:{columnTemplate};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 .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{t.SidebarInk};}}
.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:{headerInk};}}
.header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{{color:{headerInk};}}
.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;}}
html,body{{min-width:0;}}
body{{margin:0;background:#e9edf2;color:{ink};font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
h1,h2{{font-family:{headingFont};}}
.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{ink};line-height:1.1;overflow-wrap:anywhere;}}
.kicker{{text-transform:uppercase;letter-spacing:.3em;font-size:7.5pt;color:{accent};margin-bottom:1.5mm;}}
.headline{{margin-top:1.5mm;color:{muted};font-size:{F(bodySize + 0.5)}pt;}}
.head-text,.main,.sidebar,.cols>*{{min-width:0;}}
.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:{muted};font-size:{F(bodySize - 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;min-width:0;max-width:100%;overflow-wrap:anywhere;}}
.contact a{{color:inherit;text-decoration:none;min-width:0;overflow-wrap:anywhere;word-break:break-word;}}
.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(headingSize)}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;}}
.skills-text{{margin:0;overflow-wrap:anywhere;}}
.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(bodySize - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}}
.entry{{margin-bottom:{entryGap}mm;}}
.entry:last-child{{margin-bottom:0;}}
.entry-head{{display:flex;justify-content:space-between;gap:1.5mm 4mm;align-items:baseline;flex-wrap:wrap;break-after:avoid-page;page-break-after:avoid;}}
.entry-title{{font-weight:700;font-size:{F(bodySize + 1)}pt;min-width:0;flex:1 1 50mm;overflow-wrap:anywhere;}}
.entry-meta{{color:{muted};font-size:{F(bodySize - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}}
.entry-tags{{margin-top:1.4mm;}}
{layoutCss}
/* Print quality: keep normal entries whole, but allow intentionally classified long entries and
long list items to flow. An unsplittable block taller than a page is otherwise clipped. */
.entry{{break-inside:avoid-page;page-break-inside:avoid;}}
.entry-flow{{break-inside:auto;page-break-inside:auto;}}
.section-title{{break-after:avoid;page-break-after:avoid;}}
.tag,.contact-item{{break-inside:avoid;}}
.bullets li{{break-inside:avoid-page;page-break-inside:avoid;orphans:2;widows:2;overflow-wrap:anywhere;}}
.bullets li.item-flow{{break-inside:auto;page-break-inside:auto;}}
@media print{{body{{background:transparent;}}}}
@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 ContrastInk(string background)
{
if (background.Length != 7 || background[0] != '#' || !background.Skip(1).All(Uri.IsHexDigit)) return "#000";
var r = Convert.ToInt32(background.Substring(1, 2), 16) / 255d;
var g = Convert.ToInt32(background.Substring(3, 2), 16) / 255d;
var b = Convert.ToInt32(background.Substring(5, 2), 16) / 255d;
static double Channel(double value) => value <= 0.04045 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4);
var luminance = 0.2126 * Channel(r) + 0.7152 * Channel(g) + 0.0722 * Channel(b);
var whiteContrast = 1.05 / (luminance + 0.05);
var blackContrast = (luminance + 0.05) / 0.05;
return whiteContrast >= blackContrast ? "#fff" : "#000";
}
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('-');
}
}