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.
This commit is contained in:
@@ -63,6 +63,16 @@ public sealed class CvCustomSectionSetting
|
||||
|
||||
public static class CvVariantSettingsJson
|
||||
{
|
||||
private static readonly HashSet<string> AllowedFonts = new(StringComparer.Ordinal)
|
||||
{
|
||||
"'Segoe UI', Roboto, Arial, sans-serif",
|
||||
"Arial, Helvetica, sans-serif",
|
||||
"Georgia, 'Times New Roman', serif",
|
||||
"'Helvetica Neue', Arial, sans-serif",
|
||||
"'Roboto', Arial, sans-serif",
|
||||
"'Poppins', 'Segoe UI', Arial, sans-serif",
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -83,9 +93,28 @@ public static class CvVariantSettingsJson
|
||||
{
|
||||
s ??= new CvVariantSettings();
|
||||
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
|
||||
s.AccentColor = NormalizeColor(s.AccentColor);
|
||||
s.HeadingFont = NormalizeFont(s.HeadingFont);
|
||||
s.BodyFont = NormalizeFont(s.BodyFont);
|
||||
s.Sections ??= new();
|
||||
s.Overrides ??= new();
|
||||
s.CustomSections ??= new();
|
||||
return s;
|
||||
}
|
||||
|
||||
private static string? NormalizeColor(string? value)
|
||||
{
|
||||
var candidate = value?.Trim();
|
||||
return candidate is { Length: 7 }
|
||||
&& candidate[0] == '#'
|
||||
&& candidate.Skip(1).All(Uri.IsHexDigit)
|
||||
? candidate.ToLowerInvariant()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? NormalizeFont(string? value)
|
||||
{
|
||||
var candidate = value?.Trim();
|
||||
return candidate is not null && AllowedFonts.Contains(candidate) ? candidate : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
{
|
||||
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);
|
||||
@@ -35,7 +36,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
? SplitColumns(model, theme, showIcons)
|
||||
: (string.Empty, RenderSections(model.Sections, theme));
|
||||
|
||||
var css = BuildCss(theme, accent, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
|
||||
var css = BuildCss(theme, accent, headerInk, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn);
|
||||
var body = theme.Layout switch
|
||||
{
|
||||
@@ -189,7 +190,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
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)
|
||||
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);
|
||||
@@ -209,13 +210,14 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
.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:#fff;}}
|
||||
.header-band .name,.header-band .headline,.header-band a{{color:#fff;}}
|
||||
.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};}}";
|
||||
@@ -293,6 +295,21 @@ h1,h2{{font-family:{headingFont};}}
|
||||
$@"<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("'", "'", StringComparison.Ordinal);
|
||||
|
||||
Reference in New Issue
Block a user