feat(cv): rebuild professional resume studio
This commit is contained in:
@@ -20,6 +20,25 @@ public sealed class CvVariantSettings
|
||||
public string? Language { get; set; }
|
||||
public string? Headline { get; set; } // override the contact headline for this variant
|
||||
|
||||
// Normalized design overrides. Null keeps the selected theme's value, which means old variants
|
||||
// retain their exact appearance while new editor controls can be added without a schema change.
|
||||
public string? TextColor { get; set; }
|
||||
public string? MutedColor { get; set; }
|
||||
public string? HeadingColor { get; set; }
|
||||
public string? BackgroundColor { get; set; }
|
||||
public double? BaseFontSizePt { get; set; }
|
||||
public double? HeadingSizePt { get; set; }
|
||||
public double? LineHeight { get; set; }
|
||||
public double? PageMarginMm { get; set; }
|
||||
public double? SectionGapMm { get; set; }
|
||||
public double? EntryGapMm { get; set; }
|
||||
public string? HeadingStyle { get; set; } // caps-rule | underline | plain | bar
|
||||
public string? HeaderStyle { get; set; } // plain | band | centered | kicker
|
||||
public string? SkillsStyle { get; set; } // tags | text
|
||||
public string? Layout { get; set; } // single | sidebar-left | sidebar-right | header-band
|
||||
public double? SidebarWidthMm { get; set; }
|
||||
public List<string>? SidebarSections { get; set; }
|
||||
|
||||
public bool ShowPhoto { get; set; }
|
||||
public bool ShowPageNumbers { get; set; }
|
||||
public bool ShowIcons { get; set; } = true;
|
||||
@@ -94,8 +113,36 @@ public static class CvVariantSettingsJson
|
||||
s ??= new CvVariantSettings();
|
||||
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
|
||||
s.AccentColor = NormalizeColor(s.AccentColor);
|
||||
s.TextColor = NormalizeColor(s.TextColor);
|
||||
s.MutedColor = NormalizeColor(s.MutedColor);
|
||||
s.HeadingColor = NormalizeColor(s.HeadingColor);
|
||||
s.BackgroundColor = NormalizeColor(s.BackgroundColor);
|
||||
s.HeadingFont = NormalizeFont(s.HeadingFont);
|
||||
s.BodyFont = NormalizeFont(s.BodyFont);
|
||||
s.BaseFontSizePt = Clamp(s.BaseFontSizePt, 7, 13);
|
||||
s.HeadingSizePt = Clamp(s.HeadingSizePt, 9, 20);
|
||||
s.LineHeight = Clamp(s.LineHeight, 1.1, 1.8);
|
||||
s.PageMarginMm = Clamp(s.PageMarginMm, 8, 28);
|
||||
s.SectionGapMm = Clamp(s.SectionGapMm, 2, 14);
|
||||
s.EntryGapMm = Clamp(s.EntryGapMm, 1, 10);
|
||||
s.SidebarWidthMm = Clamp(s.SidebarWidthMm, 45, 85);
|
||||
s.HeadingStyle = NormalizeChoice(s.HeadingStyle, "caps-rule", "underline", "plain", "bar");
|
||||
s.HeaderStyle = NormalizeChoice(s.HeaderStyle, "plain", "band", "centered", "kicker");
|
||||
s.SkillsStyle = NormalizeChoice(s.SkillsStyle, "tags", "text");
|
||||
s.Layout = NormalizeChoice(s.Layout, "single", "sidebar-left", "sidebar-right", "header-band");
|
||||
s.DateFormat = NormalizeChoice(s.DateFormat, "long", "short", "numeric", "year");
|
||||
if (!string.IsNullOrWhiteSpace(s.Language))
|
||||
{
|
||||
var language = s.Language.Trim().ToLowerInvariant();
|
||||
s.Language = language[..Math.Min(12, language.Length)];
|
||||
}
|
||||
else s.Language = null;
|
||||
s.SidebarSections = s.SidebarSections?
|
||||
.Where(key => !string.IsNullOrWhiteSpace(key))
|
||||
.Select(key => key.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
s.Sections ??= new();
|
||||
s.Overrides ??= new();
|
||||
s.CustomSections ??= new();
|
||||
@@ -117,4 +164,15 @@ public static class CvVariantSettingsJson
|
||||
var candidate = value?.Trim();
|
||||
return candidate is not null && AllowedFonts.Contains(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
private static double? Clamp(double? value, double min, double max) =>
|
||||
value is null || double.IsNaN(value.Value) || double.IsInfinity(value.Value)
|
||||
? null
|
||||
: Math.Clamp(value.Value, min, max);
|
||||
|
||||
private static string? NormalizeChoice(string? value, params string[] choices)
|
||||
{
|
||||
var candidate = value?.Trim().ToLowerInvariant();
|
||||
return candidate is not null && choices.Contains(candidate, StringComparer.Ordinal) ? candidate : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,14 @@ public sealed class StructuredCvContact
|
||||
public string? Location { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? LinkedIn { get; set; }
|
||||
public string? GitHub { get; set; }
|
||||
public List<StructuredCvLink> Links { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StructuredCvLink
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StructuredCvJob
|
||||
|
||||
@@ -94,6 +94,8 @@ public static class StructuredCvProfileJson
|
||||
profile.Contact.Location = TrimOrNull(profile.Contact.Location);
|
||||
profile.Contact.Website = TrimOrNull(profile.Contact.Website);
|
||||
profile.Contact.LinkedIn = TrimOrNull(profile.Contact.LinkedIn);
|
||||
profile.Contact.GitHub = NormalizeExternalLink(profile.Contact.GitHub);
|
||||
profile.Contact.Links = NormalizeLinks(profile.Contact.Links);
|
||||
|
||||
profile.Summary = CleanList(profile.Summary);
|
||||
profile.Jobs = (profile.Jobs ?? new List<StructuredCvJob>())
|
||||
@@ -213,6 +215,11 @@ public static class StructuredCvProfileJson
|
||||
primary.Contact.Location ??= secondary.Contact.Location;
|
||||
primary.Contact.Website ??= secondary.Contact.Website;
|
||||
primary.Contact.LinkedIn ??= secondary.Contact.LinkedIn;
|
||||
primary.Contact.GitHub ??= secondary.Contact.GitHub;
|
||||
primary.Contact.Links ??= new();
|
||||
foreach (var link in secondary.Contact.Links ?? new())
|
||||
if (!primary.Contact.Links.Any(existing => string.Equals(existing.Url, link.Url, StringComparison.OrdinalIgnoreCase)))
|
||||
primary.Contact.Links.Add(link);
|
||||
|
||||
primary.Summary = primary.Summary.Count == 0
|
||||
? secondary.Summary
|
||||
@@ -393,9 +400,20 @@ public static class StructuredCvProfileJson
|
||||
contact.Location = NormalizeLocationValue(contact.Location);
|
||||
contact.Website = NormalizeWebsite(contact.Website);
|
||||
contact.LinkedIn = NormalizeLinkedIn(contact.LinkedIn);
|
||||
contact.GitHub = NormalizeExternalLink(contact.GitHub);
|
||||
contact.Links = NormalizeLinks(contact.Links);
|
||||
return contact;
|
||||
}
|
||||
|
||||
private static List<StructuredCvLink> NormalizeLinks(List<StructuredCvLink>? links) =>
|
||||
(links ?? new List<StructuredCvLink>())
|
||||
.Select(link => new StructuredCvLink { Label = TrimOrNull(link?.Label), Url = NormalizeExternalLink(link?.Url) })
|
||||
.Where(link => link.Url is not null)
|
||||
.GroupBy(link => link.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.Take(12)
|
||||
.ToList();
|
||||
|
||||
private static StructuredCvJob NormalizeJob(StructuredCvJob? job)
|
||||
{
|
||||
job ??= new StructuredCvJob();
|
||||
@@ -537,6 +555,16 @@ public static class StructuredCvProfileJson
|
||||
return $"https://www.linkedin.com{path}";
|
||||
}
|
||||
|
||||
private static string? NormalizeExternalLink(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
if (trimmed is null) return null;
|
||||
var candidate = trimmed.Contains("://", StringComparison.Ordinal) ? trimmed : $"https://{trimmed}";
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) return null;
|
||||
if (uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host) || !string.IsNullOrEmpty(uri.UserInfo)) return null;
|
||||
return uri.AbsoluteUri.TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string? NormalizeDateValue(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
@@ -726,6 +754,8 @@ public static class StructuredCvProfileJson
|
||||
AddIf(contactLines, profile.Contact.Location);
|
||||
AddIf(contactLines, profile.Contact.Website);
|
||||
AddIf(contactLines, profile.Contact.LinkedIn);
|
||||
AddIf(contactLines, profile.Contact.GitHub);
|
||||
foreach (var link in profile.Contact.Links) AddIf(contactLines, link.Url);
|
||||
AddSectionIfAny(sections, "Contact", contactLines);
|
||||
AddSectionIfAny(sections, "Professional Summary", profile.Summary);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user