feat(cv): harden multi-page builder
Wrap pathological content, paginate oversized entries, measure A4 and Letter previews correctly, unify section ordering, and gate stored-output actions on saved state.
This commit is contained in:
@@ -65,7 +65,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#eef2f7; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
|
||||
.header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; border-bottom:2px solid var(--accent); padding-bottom:8mm; margin-bottom:7mm; }}
|
||||
.header {{ display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6mm; border-bottom:2px solid var(--accent); padding-bottom:8mm; margin-bottom:7mm; }}
|
||||
.name {{ margin:0; font-size:25pt; letter-spacing:.02em; }}
|
||||
.headline {{ margin-top:2mm; color:var(--muted); font-size:11pt; }}
|
||||
.meta {{ margin-top:3mm; display:flex; flex-wrap:wrap; gap:3mm; color:var(--muted); font-size:9pt; }}
|
||||
@@ -140,9 +140,9 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
var sidebarSections = new StringBuilder();
|
||||
sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[]
|
||||
{
|
||||
$"Name\n{Encode(candidateName)}",
|
||||
$"Target role\n{Encode(jobTitle)}",
|
||||
string.IsNullOrWhiteSpace(companyName) ? null : $"Company focus\n{Encode(companyName)}"
|
||||
$"Name\n{candidateName}",
|
||||
$"Target role\n{jobTitle}",
|
||||
string.IsNullOrWhiteSpace(companyName) ? null : $"Company focus\n{companyName}"
|
||||
}));
|
||||
if (document.CustomSections.Count > 0)
|
||||
{
|
||||
@@ -170,7 +170,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
:root {{ --accent:{accent}; --ink:#1f2937; --muted:#4b5563; --line:#d1d5db; --sidebar:#f3f4f6; --paper:#fff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#edf2f7; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; display:grid; grid-template-columns:34% 66%; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; display:grid; grid-template-columns:34% minmax(0,66%); }}
|
||||
.sidebar {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 8%, white)); color:#fff; padding:12mm 8mm 12mm 10mm; }}
|
||||
.hero {{ margin:-12mm -8mm 8mm -10mm; padding:10mm 10mm 8mm 10mm; background:var(--accent); }}
|
||||
.hero.curved {{ border-bottom-right-radius:28mm; }}
|
||||
@@ -224,7 +224,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
|
||||
.monarch-shell {{ border:1px solid var(--line); padding:10mm; position:relative; }}
|
||||
.monarch-shell::before {{ content:''; position:absolute; inset:6mm; border:1px solid color-mix(in srgb, var(--line) 70%, white); pointer-events:none; }}
|
||||
.monarch-header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; align-items:center; margin-bottom:8mm; }}
|
||||
.monarch-header {{ display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6mm; align-items:center; margin-bottom:8mm; }}
|
||||
.monarch-kicker {{ display:inline-block; text-transform:uppercase; letter-spacing:.3em; font-size:8pt; color:var(--accent); margin-bottom:2mm; }}
|
||||
.monarch-name {{ margin:0; font-size:28pt; line-height:1.05; }}
|
||||
.monarch-headline {{ margin-top:2mm; font-size:11pt; color:var(--muted); max-width:130mm; }}
|
||||
@@ -274,7 +274,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#d9e8ef; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:0; }}
|
||||
.fjord-grid {{ display:grid; grid-template-columns:72mm 1fr; min-height:297mm; }}
|
||||
.fjord-grid {{ display:grid; grid-template-columns:72mm minmax(0,1fr); min-height:297mm; }}
|
||||
.fjord-rail {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 15%, white)); color:white; padding:16mm 8mm; }}
|
||||
.fjord-name {{ margin:0; font-size:21pt; line-height:1.08; }}
|
||||
.fjord-headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
|
||||
@@ -335,22 +335,29 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
};
|
||||
|
||||
return $@"
|
||||
.page,.page *{{min-width:0;}}
|
||||
.page{{overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
|
||||
.name,.headline,.meta,.monarch-name,.monarch-headline,.monarch-company,.fjord-name,.fjord-headline,.fjord-meta,.sidebar-item{{overflow-wrap:anywhere;word-break:break-word;}}
|
||||
.section{{margin-top:6mm;}}
|
||||
{headingCss}
|
||||
.summary,.custom-list,.education-list,.experience-bullets{{margin:0;padding-left:4.5mm;}}
|
||||
.summary li,.custom-list li,.education-list li,.experience-bullets li{{margin:0 0 1.6mm 0;line-height:1.42;}}
|
||||
.summary li,.custom-list li,.education-list li,.experience-bullets li{{margin:0 0 1.6mm 0;line-height:1.42;overflow-wrap:anywhere;break-inside:avoid-page;page-break-inside:avoid;orphans:2;widows:2;}}
|
||||
.summary li.item-flow,.custom-list li.item-flow,.education-list li.item-flow,.experience-bullets li.item-flow{{break-inside:auto;page-break-inside:auto;}}
|
||||
.skills{{list-style:none;padding-left:0;display:flex;flex-wrap:wrap;gap:2mm;}}
|
||||
.skill-pill{{border:1px solid var(--line);border-radius:999px;padding:1mm 2.4mm;font-size:9pt;}}
|
||||
.entry{{margin-bottom:4.8mm;}}
|
||||
.entry-header{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;margin-bottom:1.2mm;}}
|
||||
.entry-title{{font-weight:700;font-size:11pt;}}
|
||||
.entry-meta{{color:var(--muted);font-size:9pt;text-align:right;white-space:nowrap;}}
|
||||
.entry-subtitle{{color:var(--muted);font-size:9.5pt;margin-bottom:1.3mm;}}";
|
||||
.skill-pill{{border:1px solid var(--line);border-radius:999px;padding:1mm 2.4mm;font-size:9pt;max-width:100%;overflow-wrap:anywhere;}}
|
||||
.entry{{margin-bottom:4.8mm;break-inside:avoid-page;page-break-inside:avoid;}}
|
||||
.entry.entry-flow{{break-inside:auto;page-break-inside:auto;}}
|
||||
.entry-header{{display:flex;justify-content:space-between;gap:1.5mm 4mm;align-items:baseline;flex-wrap:wrap;margin-bottom:1.2mm;break-after:avoid-page;page-break-after:avoid;}}
|
||||
.entry-title{{font-weight:700;font-size:11pt;flex:1 1 50mm;overflow-wrap:anywhere;}}
|
||||
.entry-meta{{color:var(--muted);font-size:9pt;text-align:right;white-space:normal;max-width:100%;overflow-wrap:anywhere;}}
|
||||
.entry-subtitle{{color:var(--muted);font-size:9.5pt;margin-bottom:1.3mm;overflow-wrap:anywhere;}}
|
||||
.section-title{{break-after:avoid-page;page-break-after:avoid;}}
|
||||
@media print{{body{{background:transparent;}}}}";
|
||||
}
|
||||
|
||||
private static string RenderSidebarMetaSection(string title, IEnumerable<string?> items)
|
||||
{
|
||||
var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $"<p class=\"sidebar-item\">{item}</p>"));
|
||||
var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $"<p class=\"sidebar-item\">{Encode(item)}</p>"));
|
||||
if (string.IsNullOrWhiteSpace(content)) return string.Empty;
|
||||
return $"<section class=\"sidebar-section\"><h2 class=\"sidebar-title\">{Encode(title)}</h2>{content}</section>";
|
||||
}
|
||||
@@ -359,7 +366,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
{
|
||||
if (items.Count == 0) return string.Empty;
|
||||
var tag = bulletList ? "summary" : "custom-list";
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(title)}</h2><ul class=\"{tag}\">{string.Join(string.Empty, items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>";
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(title)}</h2><ul class=\"{tag}\">{string.Join(string.Empty, items.Select(RenderListItem))}</ul></section>";
|
||||
}
|
||||
|
||||
private static string RenderSkillSection(IReadOnlyCollection<string> skills)
|
||||
@@ -376,10 +383,12 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
{
|
||||
var subtitle = string.Join(" · ", new[] { entry.Company, entry.Location }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Encode));
|
||||
var dateRange = FormatDateRange(entry.Start, entry.End, entry.IsCurrent);
|
||||
items.Append("<article class=\"entry\">");
|
||||
items.Append(IsFlowingEntry(entry.Title, subtitle, dateRange, entry.Bullets)
|
||||
? "<article class=\"entry entry-flow\">"
|
||||
: "<article class=\"entry\">");
|
||||
items.Append($"<div class=\"entry-header\"><div class=\"entry-title\">{Encode(entry.Title)}</div><div class=\"entry-meta\">{Encode(dateRange)}</div></div>");
|
||||
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
|
||||
if (entry.Bullets.Count > 0) items.Append($"<ul class=\"experience-bullets\">{string.Join(string.Empty, entry.Bullets.Select(bullet => $"<li>{Encode(bullet)}</li>"))}</ul>");
|
||||
if (entry.Bullets.Count > 0) items.Append($"<ul class=\"experience-bullets\">{string.Join(string.Empty, entry.Bullets.Select(RenderListItem))}</ul>");
|
||||
items.Append("</article>");
|
||||
}
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">Professional Experience</h2>{items}</section>";
|
||||
@@ -394,13 +403,15 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
var subtitle = string.Join(" · ", new[] { entry.Institution, entry.Location, FormatDateRange(entry.Start, entry.End, false) }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(Encode));
|
||||
items.Append("<article class=\"entry\">");
|
||||
items.Append(IsFlowingEntry(entry.Qualification, subtitle, null, entry.Details)
|
||||
? "<article class=\"entry entry-flow\">"
|
||||
: "<article class=\"entry\">");
|
||||
var title = string.IsNullOrWhiteSpace(entry.QualificationLevel)
|
||||
? entry.Qualification
|
||||
: $"{entry.Qualification} ({entry.QualificationLevel})";
|
||||
items.Append($"<div class=\"entry-title\">{Encode(title)}</div>");
|
||||
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
|
||||
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(detail => $"<li>{Encode(detail)}</li>"))}</ul>");
|
||||
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(RenderListItem))}</ul>");
|
||||
items.Append("</article>");
|
||||
}
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">Education</h2>{items}</section>";
|
||||
@@ -409,9 +420,22 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
private static string RenderCustomSection(TailoredCvCustomSection section)
|
||||
{
|
||||
if (section.Items.Count == 0) return string.Empty;
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(section.Title ?? "Additional Information")}</h2><ul class=\"custom-list\">{string.Join(string.Empty, section.Items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>";
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(section.Title ?? "Additional Information")}</h2><ul class=\"custom-list\">{string.Join(string.Empty, section.Items.Select(RenderListItem))}</ul></section>";
|
||||
}
|
||||
|
||||
private static string RenderListItem(string? value) =>
|
||||
$"<li{(IsFlowingItem(value) ? " class=\"item-flow\"" : string.Empty)}>{Encode(value)}</li>";
|
||||
|
||||
private static bool IsFlowingEntry(string? title, string? subtitle, string? meta, IEnumerable<string> items)
|
||||
{
|
||||
var values = items.ToList();
|
||||
var textLength = (title?.Length ?? 0) + (subtitle?.Length ?? 0) + (meta?.Length ?? 0)
|
||||
+ values.Sum(item => item?.Length ?? 0);
|
||||
return values.Count > 5 || values.Any(IsFlowingItem) || textLength > 900;
|
||||
}
|
||||
|
||||
private static bool IsFlowingItem(string? value) => (value?.Length ?? 0) > 360;
|
||||
|
||||
private static string FormatDateRange(string? start, string? end, bool isCurrent)
|
||||
{
|
||||
var normalizedStart = (start ?? string.Empty).Trim();
|
||||
|
||||
Reference in New Issue
Block a user