feat(cv): extend templates and extraction
This commit is contained in:
@@ -25,13 +25,20 @@ public sealed class CvRenderSection
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
// bullets | tags | entries
|
||||
// bullets | tags | paragraphs | skill-groups | entries
|
||||
public string Kind { get; set; } = "entries";
|
||||
public List<string> Bullets { get; set; } = new();
|
||||
public List<string> Tags { get; set; } = new();
|
||||
public List<CvRenderEntry> Entries { get; set; } = new();
|
||||
public List<CvRenderSkillGroup> SkillGroups { get; set; } = new();
|
||||
|
||||
public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0;
|
||||
public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0 && SkillGroups.Count == 0;
|
||||
}
|
||||
|
||||
public sealed class CvRenderSkillGroup
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public List<string> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class CvRenderEntry
|
||||
@@ -114,7 +121,7 @@ public static class CvVariantResolver
|
||||
{
|
||||
Key = key,
|
||||
Title = Trim(custom.Title) ?? "Additional",
|
||||
Kind = "bullets",
|
||||
Kind = custom.ContentType == "paragraphs" ? "paragraphs" : "bullets",
|
||||
Bullets = Clean(custom.Items),
|
||||
};
|
||||
}
|
||||
@@ -142,6 +149,11 @@ public static class CvVariantResolver
|
||||
{
|
||||
if (cfg.Hidden) continue;
|
||||
if (!string.IsNullOrWhiteSpace(cfg.Title)) section.Title = cfg.Title!.Trim();
|
||||
if (cfg.Items is not null && section.Kind is "bullets" or "tags")
|
||||
{
|
||||
if (section.Kind == "tags") section.Tags = Clean(cfg.Items);
|
||||
else section.Bullets = Clean(cfg.Items);
|
||||
}
|
||||
if (cfg.ItemOrder is { Count: > 0 } && section.Entries.Count > 1)
|
||||
{
|
||||
section.Entries = ReorderByKey(section.Entries, cfg.ItemOrder);
|
||||
@@ -154,6 +166,20 @@ public static class CvVariantResolver
|
||||
if (!section.IsEmpty) model.Sections.Add(section);
|
||||
}
|
||||
|
||||
if (settings.SkillGroups is { Count: > 0 })
|
||||
{
|
||||
var skills = model.Sections.FirstOrDefault(section => section.Key.Equals("skills", StringComparison.OrdinalIgnoreCase));
|
||||
if (skills is not null)
|
||||
{
|
||||
skills.Kind = "skill-groups";
|
||||
skills.Tags.Clear();
|
||||
skills.SkillGroups = settings.SkillGroups
|
||||
.Select(group => new CvRenderSkillGroup { Name = Trim(group.Name) ?? string.Empty, Items = Clean(group.Items) })
|
||||
.Where(group => group.Items.Count > 0)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;}}
|
||||
|
||||
Reference in New Issue
Block a user