Files
jobtrackingapp/JobTrackerApi/Services/ThemedCvRenderer.cs
T
cesnimda 3b86ea2da0
CI and Deploy / test (pull_request) Successful in 4m37s
CI and Deploy / deploy (pull_request) Has been skipped
fix(cv): enforce readable render palettes
Choose a contrasting header foreground for custom accents and keep public colour and font overrides inside safe supported values.
2026-08-15 17:08:20 +02:00

324 lines
18 KiB
C#

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 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, headerInk, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
var header = RenderHeader(model, theme, showIcons, twoColumn);
var body = theme.Layout switch
{
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons)}<section class=""main"">{mainHtml}</section></div>",
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons)}</div>",
_ => $@"{header}<section class=""main"">{mainHtml}</section>",
};
// For two-column themes the header renders inside the sidebar; single/header-band render it on top.
var page = twoColumn ? body : body;
var html = $@"<!DOCTYPE html>
<html lang=""{Attr(settings.Language ?? "en")}"">
<head>
<meta charset=""utf-8"" />
<title>{Enc(model.FullName)} {Enc(theme.Name)}</title>
<style>{css}</style>
</head>
<body>
<main class=""page"">{page}</main>
</body>
</html>";
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<CvRenderSection>();
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 $@"<aside class=""sidebar"">{header}{contact}{sectionsHtml}</aside>";
}
private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn)
{
var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape);
var kicker = theme.HeaderStyle == "kicker" ? @"<div class=""kicker"">Curriculum Vitae</div>" : string.Empty;
var name = $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"<div class=""headline"">{Enc(model.Headline)}</div>";
var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false);
var headerClass = twoColumn ? "hero" : $"header header-{theme.HeaderStyle}";
return $@"<header class=""{headerClass}"">{photo}<div class=""head-text"">{kicker}{name}{headline}{contact}</div></header>";
}
private static string RenderPhoto(string? dataUrl, string shape)
{
if (shape == "none" || string.IsNullOrWhiteSpace(dataUrl)) return string.Empty;
return $@"<div class=""photo photo-{shape}""><img src=""{Attr(dataUrl)}"" alt=""Profile photo"" /></div>";
}
private static string RenderContactBlock(List<CvContactItem> 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) : $@"<a href=""{Attr(c.Href)}"">{Enc(c.Value)}</a>";
items.Append($@"<span class=""contact-item"">{icon}{text}</span>");
}
return $@"<div class=""contact {(sidebar ? "contact-stacked" : "contact-inline")}"">{items}</div>";
}
private static string RenderSections(IEnumerable<CvRenderSection> 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" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>",
"tags" => $@"<ul class=""tags"">{string.Join("", section.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>",
_ => string.Join("", section.Entries.Select(RenderEntry)),
};
return $@"<section class=""section""><h2 class=""section-title"">{Enc(section.Title)}</h2>{inner}</section>";
}
private static string RenderEntry(CvRenderEntry entry)
{
var sb = new StringBuilder();
sb.Append(IsFlowingEntry(entry) ? @"<article class=""entry entry-flow"">" : @"<article class=""entry"">");
var hasMeta = !string.IsNullOrWhiteSpace(entry.Meta);
sb.Append(@"<div class=""entry-head"">");
sb.Append($@"<div class=""entry-title"">{Enc(entry.Title)}</div>");
if (hasMeta) sb.Append($@"<div class=""entry-meta"">{Enc(entry.Meta)}</div>");
sb.Append("</div>");
if (!string.IsNullOrWhiteSpace(entry.Subtitle)) sb.Append($@"<div class=""entry-subtitle"">{Enc(entry.Subtitle)}</div>");
if (entry.Bullets.Count > 0) sb.Append($@"<ul class=""bullets"">{Items(entry.Bullets)}</ul>");
if (entry.Tags.Count > 0) sb.Append($@"<ul class=""tags entry-tags"">{string.Join("", entry.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>");
sb.Append("</article>");
return sb.ToString();
}
private static string Items(IEnumerable<string> items) =>
string.Join("", items.Select(i => $"<li{(IsFlowingItem(i) ? " class=\"item-flow\"" : string.Empty)}>{Inline(i)}</li>"));
// 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(@"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", RegexOptions.Compiled);
private static string Inline(string? value)
{
var s = Enc(value);
s = LinkRx.Replace(s, m => $"<a href=\"{m.Groups[2].Value}\">{m.Groups[1].Value}</a>");
s = BoldRx.Replace(s, "<strong>$1</strong>");
s = UnderlineRx.Replace(s, "<u>$1</u>");
s = ItalicRx.Replace(s, "<em>$1</em>");
return s;
}
private static string BuildCss(CvTheme t, string accent, string headerInk, 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 columnTemplate = t.Layout == "sidebar-right"
? $"minmax(0,1fr) {F(t.SidebarWidthMm)}mm"
: $"{F(t.SidebarWidthMm)}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:{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:visible;overflow-wrap:anywhere;word-break:normal;}}
h1,h2{{font-family:{headingFont};}}
.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.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:{t.Muted};font-size:{F(t.BodySizePt + 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:{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;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(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;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(t.BodySizePt + 1)}pt;min-width:0;flex:1 1 50mm;overflow-wrap:anywhere;}}
.entry-meta{{color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
.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 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) =>
$@"<svg viewBox=""0 0 16 16"" fill=""none"" stroke=""currentColor"" stroke-width=""1.3"" stroke-linecap=""round"" stroke-linejoin=""round""><path d=""{path}""/></svg>";
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("'", "&#39;", 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('-');
}
}