feat(cv): rebuild professional resume studio
This commit is contained in:
@@ -23,30 +23,34 @@ 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 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 = theme.Layout is "sidebar-left" or "sidebar-right";
|
||||
var twoColumn = layout is "sidebar-left" or "sidebar-right";
|
||||
var (sidebarHtml, mainHtml) = twoColumn
|
||||
? SplitColumns(model, theme, showIcons)
|
||||
: (string.Empty, RenderSections(model.Sections, theme));
|
||||
? SplitColumns(model, theme, sidebarSections, settings)
|
||||
: (string.Empty, RenderSections(model.Sections, theme, settings));
|
||||
|
||||
var css = BuildCss(theme, accent, headerInk, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn);
|
||||
var body = theme.Layout switch
|
||||
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" => $@"<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>",
|
||||
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}<section class=""main"">{mainHtml}</section></div>",
|
||||
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}</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>
|
||||
@@ -55,7 +59,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">{page}</main>
|
||||
<main class=""page"">{body}</main>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
@@ -63,39 +67,37 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
return new ThemedCvRenderResult(theme.Id, fileName, html);
|
||||
}
|
||||
|
||||
private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, bool showIcons)
|
||||
private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, IReadOnlyCollection<string> sidebarKeys, CvVariantSettings settings)
|
||||
{
|
||||
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));
|
||||
if (sidebarKeys.Contains(section.Key, StringComparer.OrdinalIgnoreCase))
|
||||
sidebar.Append(RenderSection(section, theme, settings));
|
||||
else
|
||||
mainSections.Add(section);
|
||||
}
|
||||
return (sidebar.ToString(), RenderSections(mainSections, theme));
|
||||
return (sidebar.ToString(), RenderSections(mainSections, theme, settings));
|
||||
}
|
||||
|
||||
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons)
|
||||
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection<string> sidebarSections, string headerStyle)
|
||||
{
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn: true);
|
||||
var contact = theme.SidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
|
||||
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 $@"<aside class=""sidebar"">{header}{contact}{sectionsHtml}</aside>";
|
||||
}
|
||||
|
||||
private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn)
|
||||
private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn, string headerStyle)
|
||||
{
|
||||
var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape);
|
||||
var kicker = theme.HeaderStyle == "kicker" ? @"<div class=""kicker"">Curriculum Vitae</div>" : string.Empty;
|
||||
var kicker = 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}";
|
||||
var headerClass = twoColumn ? "hero" : $"header header-{headerStyle}";
|
||||
return $@"<header class=""{headerClass}"">{photo}<div class=""head-text"">{kicker}{name}{headline}{contact}</div></header>";
|
||||
}
|
||||
|
||||
@@ -118,19 +120,20 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
return $@"<div class=""contact {(sidebar ? "contact-stacked" : "contact-inline")}"">{items}</div>";
|
||||
}
|
||||
|
||||
private static string RenderSections(IEnumerable<CvRenderSection> sections, CvTheme theme)
|
||||
private static string RenderSections(IEnumerable<CvRenderSection> sections, CvTheme theme, CvVariantSettings settings)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var section in sections) sb.Append(RenderSection(section, theme));
|
||||
foreach (var section in sections) sb.Append(RenderSection(section, theme, settings));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderSection(CvRenderSection section, CvTheme theme)
|
||||
private static string RenderSection(CvRenderSection section, CvTheme theme, CvVariantSettings? settings = null)
|
||||
{
|
||||
if (section.IsEmpty) return string.Empty;
|
||||
var inner = section.Kind switch
|
||||
{
|
||||
"bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>",
|
||||
"tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"<p class=""skills-text"">{string.Join(" · ", section.Tags.Select(Enc))}</p>",
|
||||
"tags" => $@"<ul class=""tags"">{string.Join("", section.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>",
|
||||
_ => string.Join("", section.Entries.Select(RenderEntry)),
|
||||
};
|
||||
@@ -190,21 +193,25 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
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)
|
||||
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(t.PageMarginMm * density);
|
||||
var sectionGap = F(t.SectionGapMm * density);
|
||||
var entryGap = F(t.EntryGapMm * density);
|
||||
var headingCss = t.HeadingStyle switch
|
||||
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(t.HeadingSizePt * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}",
|
||||
_ => $".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 = t.Layout == "sidebar-right"
|
||||
? $"minmax(0,1fr) {F(t.SidebarWidthMm)}mm"
|
||||
: $"{F(t.SidebarWidthMm)}mm minmax(0,1fr)";
|
||||
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;}}
|
||||
@@ -225,12 +232,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
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;}}
|
||||
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:{t.Ink};line-height:1.1;overflow-wrap:anywhere;}}
|
||||
.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:{t.Muted};font-size:{F(t.BodySizePt + 0.5)}pt;}}
|
||||
.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};}}
|
||||
@@ -238,7 +245,7 @@ h1,h2{{font-family:{headingFont};}}
|
||||
.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{{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;}}
|
||||
@@ -247,18 +254,19 @@ h1,h2{{font-family:{headingFont};}}
|
||||
.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};}}
|
||||
.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;}}
|
||||
.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;}}
|
||||
.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(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-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
|
||||
|
||||
Reference in New Issue
Block a user