feat(cv): extend templates and extraction

This commit is contained in:
cesnimda
2026-08-27 15:27:48 +02:00
parent cc76f86482
commit ccd0af908c
15 changed files with 586 additions and 54 deletions
+51 -13
View File
@@ -133,11 +133,21 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
var inner = section.Kind switch
{
"bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>",
"paragraphs" => $@"<div class=""paragraphs"">{string.Join("", section.Bullets.Select(item => $@"<p>{Inline(item)}</p>"))}</div>",
"skill-groups" => $@"<div class=""skill-groups"">{string.Join("", section.SkillGroups.Select(RenderSkillGroup))}</div>",
"tags" when section.Key == "skills" && settings?.SkillsStyle == "bullets" => $@"<ul class=""bullets skill-bullets"">{Items(section.Tags)}</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)),
};
return $@"<section class=""section""><h2 class=""section-title"">{Enc(section.Title)}</h2>{inner}</section>";
var flowClass = IsFlowingSection(section) ? " section-flow" : string.Empty;
return $@"<section class=""section section-{Attr(section.Key)}{flowClass}""><h2 class=""section-title""><span class=""section-title-text"">{Enc(section.Title)}</span></h2>{inner}</section>";
}
private static string RenderSkillGroup(CvRenderSkillGroup group)
{
var name = string.IsNullOrWhiteSpace(group.Name) ? string.Empty : $@"<div class=""skill-group-name"">{Enc(group.Name)}</div>";
return $@"<div class=""skill-group"">{name}<ul class=""skill-group-items"">{string.Join("", group.Items.Select(item => $@"<li>{Enc(item)}</li>"))}</ul></div>";
}
private static string RenderEntry(CvRenderEntry entry)
@@ -172,6 +182,17 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return entry.Bullets.Count > 5 || entry.Bullets.Any(IsFlowingItem) || textLength > 900;
}
// A section containing several individually short entries can still exceed a printable page.
// Mark it as flowing so Chromium may move/break individual entries instead of moving the whole
// oversized section to a fresh page and leaving a large blank area behind.
private static bool IsFlowingSection(CvRenderSection section)
{
if (section.Entries.Count > 2 || section.Entries.Any(IsFlowingEntry)) return true;
if (section.SkillGroups.Count > 6) return true;
if (section.Tags.Count > 30) return true;
return section.Bullets.Count > 8 || section.Bullets.Sum(item => item?.Length ?? 0) > 1_000;
}
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
@@ -204,23 +225,23 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm;
var headingCss = headingStyle switch
{
"underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}",
"underline" => ".section-title{border-bottom:1.5px solid var(--cv-accent-soft);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;}}",
_ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(headingSize * 0.85)}pt;color:var(--cv-accent-color);border-bottom:1px solid var(--cv-accent-soft);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{{background:var(--cv-accent-color);color:var(--cv-accent-ink);padding:{margin}mm;}}
.sidebar .section-title{{color:var(--cv-accent-ink);border-color:color-mix(in srgb,var(--cv-accent-ink) 38%,transparent);}}
.sidebar .tag{{border-color:color-mix(in srgb,var(--cv-accent-ink) 45%,transparent);}}
.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:var(--cv-accent-ink);}}
.sidebar a{{color:inherit;}}
.main{{padding:{margin}mm;}}
.hero .name{{color:{t.SidebarInk};}}"
.hero .name{{color:var(--cv-accent-ink);}}"
: $@".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};}}
@@ -229,18 +250,23 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
.header-centered .contact{{justify-content:center;}}
.header-plain{{border-bottom:2px solid {accent};}}";
var numberedSections = t.NumberSections
? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{t.UtilityFont};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}"
: string.Empty;
return $@"
*{{box-sizing:border-box;}}
:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};}}
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;}}
body{{margin:0;background:#e9edf2;color:var(--cv-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:var(--cv-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{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid var(--cv-accent-soft);}}
.photo-square{{border-radius:2mm;}}
.photo-rounded{{border-radius:5mm;}}
.photo-circle{{border-radius:50%;}}
@@ -249,6 +275,7 @@ h1,h2{{font-family:{headingFont};}}
.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;}}
.main a{{color:var(--cv-accent-color);text-decoration-color:var(--cv-accent-soft);}}
.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;}}
@@ -258,9 +285,16 @@ h1,h2{{font-family:{headingFont};}}
{headingCss}
.bullets{{margin:0;padding-left:4.5mm;}}
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}}
.bullets li::marker{{color:var(--cv-accent-color);}}
.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;}}
.tag{{border:1px solid var(--cv-accent-soft);border-radius:999px;padding:.7mm 2.2mm;font-size:{F(bodySize - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}}
.paragraphs p{{margin:0 0 {F(1.8 * density)}mm;white-space:pre-line;}}
.skill-groups{{display:grid;gap:{F(1.7 * density)}mm;}}
.skill-group{{display:grid;grid-template-columns:minmax(24mm,.3fr) minmax(0,1fr);gap:3mm;break-inside:avoid-page;page-break-inside:avoid;}}
.skill-group-name{{font-family:{t.UtilityFont};font-weight:700;color:var(--cv-accent-color);overflow-wrap:anywhere;}}
.skill-group-items{{display:flex;flex-wrap:wrap;gap:.8mm 3mm;list-style:none;margin:0;padding:0;}}
.skill-group-items li{{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;}}
@@ -269,11 +303,15 @@ h1,h2{{font-family:{headingFont};}}
.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}}
.entry-tags{{margin-top:1.4mm;}}
{layoutCss}
{numberedSections}
/* 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;}}
.section-title{{break-after:avoid-page;page-break-after:avoid;}}
.section-title + .entry,.section-title + .bullets,.section-title + .tags,.section-title + .paragraphs,.section-title + .skill-groups{{break-before:avoid-page;page-break-before:avoid;}}
.section{{break-inside:avoid-page;page-break-inside:avoid;}}
.section-flow{{break-inside:auto;page-break-inside:auto;}}
.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;}}