feat(cv): rebuild professional resume studio

This commit is contained in:
cesnimda
2026-08-24 20:21:23 +02:00
parent dca5daa1a2
commit 367b70681a
27 changed files with 827 additions and 187 deletions
@@ -18,13 +18,15 @@ public sealed class CvVariantController : ControllerBase
private readonly ICvVariantService _variants;
private readonly ICvPdfExporter _pdf;
private readonly ISummarizerService _ai;
private readonly IThemedCvRenderer _renderer;
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai)
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai, IThemedCvRenderer? renderer = null)
{
_users = users;
_variants = variants;
_pdf = pdf;
_ai = ai;
_renderer = renderer ?? new ThemedCvRenderer();
}
public sealed record VariantDto(int Id, string Name, CvVariantSettings Settings, bool IsPublic, string PublicSlug, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
@@ -61,6 +63,20 @@ public sealed class CvVariantController : ControllerBase
return Ok(themes);
}
// Render-only sample for visual template thumbnails. This in-memory content is never written to
// the authenticated user's master profile or to a CV variant.
[HttpGet("themes/{themeId}/preview")]
public async Task<ActionResult<RenderDto>> GetThemePreview(string themeId)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!CvThemeCatalog.Exists(themeId)) return NotFound();
var theme = CvThemeCatalog.Resolve(themeId);
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, ShowIcons = true });
var render = _renderer.Render(TemplatePreviewModel(), theme, settings);
return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName));
}
// The master profile as sections+entries (with ItemKeys) — the Content tab reads this to render
// editable rows without duplicating the profile shape on the client.
[HttpGet("outline")]
@@ -88,6 +104,8 @@ public sealed class CvVariantController : ControllerBase
return BadRequest("Unknown theme.");
if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId))
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro.");
if (request?.JobApplicationId is int jobApplicationId && !await _variants.CanAssociateJobAsync(user.Id, jobApplicationId, ct))
return BadRequest("The job application is unavailable.");
var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct);
return Ok(ToDto(variant));
}
@@ -228,6 +246,7 @@ public sealed class CvVariantController : ControllerBase
"shorten" => "Make the text more concise without losing meaning.",
"expand" => "Expand the text with more concrete, relevant detail — but never invent facts.",
"grammar" => "Fix grammar, spelling and punctuation only. Keep wording and meaning.",
"impact" => "Strengthen the text by identifying where the user could add measurable impact. Use clearly marked placeholders for missing numbers and never invent a metric or result.",
"ats" => "Rewrite for ATS keyword optimisation: strong action verbs, quantified impact, clear phrasing. Do not invent facts.",
"bullets" => "Rewrite as 35 tight achievement bullet points, each starting with a strong action verb. Do not invent facts.",
"summary" => "Write a concise professional summary (23 sentences) from this text. Do not invent facts.",
@@ -241,6 +260,34 @@ public sealed class CvVariantController : ControllerBase
private async Task<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes);
private static CvRenderModel TemplatePreviewModel() => new()
{
FullName = "Alex Morgan",
Headline = "Product & Platform Engineer",
Contact =
[
new() { Icon = "email", Value = "alex@example.com", Href = "mailto:alex@example.com" },
new() { Icon = "location", Value = "Oslo, Norway" },
new() { Icon = "web", Value = "alexmorgan.dev", Href = "https://alexmorgan.dev" },
],
Sections =
[
new() { Key = "summary", Title = "Profile", Kind = "bullets", Bullets = ["Engineer focused on clear products, resilient systems and measurable delivery."] },
new()
{
Key = "experience", Title = "Experience", Kind = "entries",
Entries =
[
new() { Title = "Senior Platform Engineer", Subtitle = "Northstar Labs", Meta = "2022 Present", Bullets = ["Led platform improvements across product teams.", "Reduced release lead time through safer automation."] },
new() { Title = "Software Engineer", Subtitle = "Studio Works", Meta = "2019 2022", Bullets = ["Built accessible customer workflows and APIs."] },
],
},
new() { Key = "skills", Title = "Skills", Kind = "tags", Tags = ["C#", "React", "Cloud", "Design systems", "Delivery"] },
new() { Key = "education", Title = "Education", Kind = "entries", Entries = [new() { Title = "BSc Computer Science", Subtitle = "University of Oslo", Meta = "2016 2019" }] },
new() { Key = "languages", Title = "Languages", Kind = "tags", Tags = ["English", "Norwegian"] },
],
};
private static CvRenderPerson Person(ApplicationUser user)
{
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
+58
View File
@@ -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);
@@ -98,6 +98,7 @@ public sealed class AccountDataExportService(
user.StripeLastEventCreatedUtc,
user.AiEnabled,
user.ExternalAiProcessingAllowed,
user.EmailFollowUpRemindersEnabled,
Roles = roles,
Claims = claims,
ExternalLogins = logins,
+49 -8
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
@@ -64,10 +65,17 @@ public static class CvVariantResolver
};
AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}");
AddContact(model.Contact, "phone", profile.Contact.Phone, null);
AddContact(model.Contact, "phone", profile.Contact.Phone, v => $"tel:{new string(v.Where(character => char.IsDigit(character) || character == '+').ToArray())}");
AddContact(model.Contact, "location", profile.Contact.Location, null);
AddContact(model.Contact, "web", profile.Contact.Website, AsUrl);
AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl);
AddContact(model.Contact, "web", profile.Contact.GitHub, AsUrl);
foreach (var link in profile.Contact.Links)
{
var url = Trim(link.Url);
if (url is null) continue;
model.Contact.Add(new CvContactItem { Icon = "web", Value = Trim(link.Label) ?? url, Href = AsUrl(url) });
}
var built = new Dictionary<string, CvRenderSection>(StringComparer.OrdinalIgnoreCase)
{
@@ -84,6 +92,7 @@ public static class CvVariantResolver
["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations),
["references"] = BulletSection("references", "References", profile.References),
};
ApplyLanguageLabels(built, settings.Language);
// OtherSections from the master profile become body sections keyed other:<n>.
for (var i = 0; i < profile.OtherSections.Count; i++)
@@ -160,7 +169,7 @@ public static class CvVariantResolver
Key = job.Id,
Title = Trim(ov?.Title) ?? Trim(job.Title),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location),
Meta = DateRange(job.Start, job.End, job.IsCurrent),
Meta = DateRange(job.StartDate ?? job.Start, job.EndDate ?? job.End, job.IsCurrent, settings),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(job.Bullets),
Tags = Clean(job.Skills),
});
@@ -181,7 +190,7 @@ public static class CvVariantResolver
Key = ed.Id,
Title = Trim(ov?.Title) ?? title,
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location),
Meta = DateRange(ed.Start, ed.End, false),
Meta = DateRange(ed.StartDate ?? ed.Start, ed.EndDate ?? ed.End, false, settings),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(ed.Details),
});
}
@@ -200,7 +209,7 @@ public static class CvVariantResolver
Key = pr.Id,
Title = Trim(ov?.Title) ?? Trim(pr.Name),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location),
Meta = DateRange(pr.Start, pr.End, false),
Meta = DateRange(pr.StartDate ?? pr.Start, pr.EndDate ?? pr.End, false, settings),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(pr.Bullets),
Tags = Clean(pr.Skills),
});
@@ -279,13 +288,45 @@ public static class CvVariantResolver
return string.IsNullOrWhiteSpace(joined) ? null : joined;
}
private static string? DateRange(string? start, string? end, bool isCurrent)
private static string? DateRange(string? start, string? end, bool isCurrent, CvVariantSettings settings)
{
var s = Trim(start);
var e = Trim(end);
var s = FormatDate(Trim(start), settings);
var e = FormatDate(Trim(end), settings);
if (s is null && e is null) return null;
if (s is null) return e;
return $"{s} {(isCurrent ? "Present" : e ?? "Present")}";
var present = IsNorwegian(settings.Language) ? "nå" : "Present";
return $"{s} {(isCurrent ? present : e ?? present)}";
}
private static string? FormatDate(string? value, CvVariantSettings settings)
{
if (value is null || settings.DateFormat is null) return value;
if (!DateTime.TryParseExact(value, new[] { "yyyy-MM", "yyyy-M", "yyyy-MM-dd", "yyyy" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return value;
var culture = IsNorwegian(settings.Language) ? CultureInfo.GetCultureInfo("nb-NO") : CultureInfo.GetCultureInfo("en-GB");
return settings.DateFormat switch
{
"year" => date.ToString("yyyy", culture),
"numeric" => date.ToString("MM/yyyy", culture),
"long" => date.ToString("MMMM yyyy", culture),
_ => date.ToString("MMM yyyy", culture),
};
}
private static bool IsNorwegian(string? language) => language?.StartsWith("no", StringComparison.OrdinalIgnoreCase) == true
|| language?.StartsWith("nb", StringComparison.OrdinalIgnoreCase) == true
|| language?.StartsWith("nn", StringComparison.OrdinalIgnoreCase) == true;
private static void ApplyLanguageLabels(Dictionary<string, CvRenderSection> sections, string? language)
{
if (!IsNorwegian(language)) return;
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["summary"] = "Profil", ["experience"] = "Arbeidserfaring", ["education"] = "Utdanning",
["projects"] = "Prosjekter", ["skills"] = "Ferdigheter", ["certifications"] = "Sertifiseringer",
["languages"] = "Språk", ["interests"] = "Interesser", ["awards"] = "Priser og utmerkelser",
["publications"] = "Publikasjoner", ["organisations"] = "Organisasjoner", ["references"] = "Referanser",
};
foreach (var (key, label) in labels) if (sections.TryGetValue(key, out var section)) section.Title = label;
}
private static List<string> Clean(IEnumerable<string>? items) =>
+39 -4
View File
@@ -4,7 +4,18 @@ using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed record CvVariantSummary(int Id, string Name, string ThemeId, string PublicSlug, bool IsPublic, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
public sealed record CvVariantSummary(
int Id,
string Name,
string ThemeId,
string Language,
string PublicSlug,
bool IsPublic,
int Version,
int? JobApplicationId,
DateTimeOffset UpdatedAtUtc,
string? JobTitle = null,
string? CompanyName = null);
public sealed record CvVariantVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
// Person + photo needed to render, resolved from ApplicationUser by the controller so this service
@@ -14,6 +25,7 @@ public sealed record CvRenderPerson(string FallbackName, string? PhotoDataUrl);
public interface ICvVariantService
{
Task<IReadOnlyList<CvVariantSummary>> ListAsync(string ownerUserId, CancellationToken ct);
Task<bool> CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct);
Task<CvVariant> CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct);
Task<CvVariant?> SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct);
@@ -49,18 +61,26 @@ public sealed class CvVariantService : ICvVariantService
public async Task<IReadOnlyList<CvVariantSummary>> ListAsync(string ownerUserId, CancellationToken ct)
{
var query = _db.CvVariants.Where(x => x.OwnerUserId == ownerUserId);
var query = _db.CvVariants
.Include(x => x.JobApplication)
.ThenInclude(x => x!.Company)
.Where(x => x.OwnerUserId == ownerUserId);
var variants = _db.Database.IsSqlite()
? (await query.ToListAsync(ct)).OrderByDescending(x => x.UpdatedAtUtc).ToList()
: await query.OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct);
return variants.Select(Summarize).ToList();
}
public Task<bool> CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
_db.JobApplications.AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct);
public Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct) =>
_db.CvVariants.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct);
public async Task<CvVariant> CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct)
{
if (jobApplicationId is not null && !await CanAssociateJobAsync(ownerUserId, jobApplicationId.Value, ct))
throw new ArgumentException("The job application is unavailable.", nameof(jobApplicationId));
var now = DateTimeOffset.UtcNow;
var normalized = CvVariantSettingsJson.Normalize(settings);
var variant = new CvVariant
@@ -110,7 +130,11 @@ public sealed class CvVariantService : ICvVariantService
var source = await GetAsync(ownerUserId, id, ct);
if (source is null) return null;
var settings = CvVariantSettingsJson.Deserialize(source.SettingsJson);
return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, source.JobApplicationId, settings, ct);
var associatedJobId = source.JobApplicationId is not null
&& await CanAssociateJobAsync(ownerUserId, source.JobApplicationId.Value, ct)
? source.JobApplicationId
: null;
return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, associatedJobId, settings, ct);
}
public async Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct)
@@ -202,7 +226,18 @@ public sealed class CvVariantService : ICvVariantService
private static CvVariantSummary Summarize(CvVariant v)
{
var settings = CvVariantSettingsJson.Deserialize(v.SettingsJson);
return new CvVariantSummary(v.Id, v.Name, settings.ThemeId, v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc);
return new CvVariantSummary(
v.Id,
v.Name,
settings.ThemeId,
settings.Language ?? "en",
v.PublicSlug,
v.IsPublic,
v.Version,
v.JobApplicationId,
v.UpdatedAtUtc,
v.JobApplication?.JobTitle,
v.JobApplication?.Company?.Name);
}
private static string CleanName(string? name) => string.IsNullOrWhiteSpace(name) ? "Untitled CV" : name.Trim();
+55 -47
View File
@@ -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