feat(cv): harden multi-page builder
CI and Deploy / test (pull_request) Failing after 3m0s
CI and Deploy / deploy (pull_request) Has been skipped

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:
cesnimda
2026-08-15 14:09:51 +02:00
parent d424633f95
commit 5203ddea72
19 changed files with 508 additions and 206 deletions
+32 -21
View File
@@ -93,11 +93,34 @@ public static class CvVariantResolver
built[key] = new CvRenderSection { Key = key, Title = Trim(other.Title) ?? "Additional", Kind = "bullets", Bullets = Clean(other.Items) };
}
// Determine order + visibility from settings, falling back to the default order then any extras.
var settingByKey = settings.Sections.ToDictionary(s => s.Key, s => s, StringComparer.OrdinalIgnoreCase);
var ordered = settings.Sections.Count > 0
? settings.Sections.Select(s => s.Key).ToList()
: DefaultOrder.ToList();
// Custom sections use the same order list as profile-backed sections. Older variants that do
// not yet contain custom:<key> rows still append them in their stored custom-section order.
var customHiddenByKey = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
foreach (var custom in settings.CustomSections)
{
if (string.IsNullOrWhiteSpace(custom.Key)) continue;
var key = $"custom:{custom.Key}";
customHiddenByKey[key] = custom.Hidden;
built[key] = new CvRenderSection
{
Key = key,
Title = Trim(custom.Title) ?? "Additional",
Kind = "bullets",
Bullets = Clean(custom.Items),
};
}
// Determine order + visibility from settings, falling back to the default order then any
// extras. Tolerate malformed/legacy duplicate keys instead of failing the whole render.
var settingByKey = new Dictionary<string, CvSectionSetting>(StringComparer.OrdinalIgnoreCase);
var ordered = new List<string>();
foreach (var section in settings.Sections)
{
if (string.IsNullOrWhiteSpace(section.Key)) continue;
settingByKey[section.Key] = section;
if (!ordered.Contains(section.Key, StringComparer.OrdinalIgnoreCase)) ordered.Add(section.Key);
}
if (ordered.Count == 0) ordered.AddRange(DefaultOrder);
foreach (var key in built.Keys)
{
if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key);
@@ -105,7 +128,6 @@ public static class CvVariantResolver
foreach (var key in ordered)
{
if (key.StartsWith("custom:", StringComparison.OrdinalIgnoreCase)) continue; // handled below
if (!built.TryGetValue(key, out var section)) continue;
if (settingByKey.TryGetValue(key, out var cfg))
{
@@ -116,22 +138,11 @@ public static class CvVariantResolver
section.Entries = ReorderByKey(section.Entries, cfg.ItemOrder);
}
}
if (!section.IsEmpty) model.Sections.Add(section);
}
// Variant-only custom sections, placed by their position in the order list if present.
foreach (var custom in settings.CustomSections)
{
if (custom.Hidden) continue;
var items = Clean(custom.Items);
if (items.Count == 0) continue;
model.Sections.Add(new CvRenderSection
else if (customHiddenByKey.TryGetValue(key, out var customHidden) && customHidden)
{
Key = $"custom:{custom.Key}",
Title = Trim(custom.Title) ?? "Additional",
Kind = "bullets",
Bullets = items,
});
continue;
}
if (!section.IsEmpty) model.Sections.Add(section);
}
return model;
+45 -21
View File
@@ -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();
+38 -15
View File
@@ -139,7 +139,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
private static string RenderEntry(CvRenderEntry entry)
{
var sb = new StringBuilder();
sb.Append(@"<article class=""entry"">");
sb.Append(IsFlowingEntry(entry) ? @"<article class=""entry entry-flow"">" : @"<article class=""entry"">");
var hasMeta = !string.IsNullOrWhiteSpace(entry.Meta);
sb.Append(@"<div class=""entry-head"">");
sb.Append($@"<div class=""entry-title"">{Enc(entry.Title)}</div>");
@@ -153,7 +153,22 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
}
private static string Items(IEnumerable<string> items) =>
string.Join("", items.Select(i => $"<li>{Inline(i)}</li>"));
string.Join("", items.Select(i => $"<li{(IsFlowingItem(i) ? " class=\"item-flow\"" : string.Empty)}>{Inline(i)}</li>"));
// Short entries stay together. Large entries must be allowed to paginate between bullets or
// paragraphs; forcing an entry taller than the printable area to remain whole clips content in
// Chromium. The renderer only selects the pagination strategy—it never shrinks the text.
private static bool IsFlowingEntry(CvRenderEntry entry)
{
var textLength = (entry.Title?.Length ?? 0)
+ (entry.Subtitle?.Length ?? 0)
+ (entry.Meta?.Length ?? 0)
+ entry.Bullets.Sum(item => item?.Length ?? 0)
+ entry.Tags.Sum(item => item?.Length ?? 0);
return entry.Bullets.Count > 5 || entry.Bullets.Any(IsFlowingItem) || textLength > 900;
}
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
// inert), then re-introduce a tiny whitelist: **bold**, *italic*, __underline__, [text](url) with
@@ -186,8 +201,11 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
"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;}}",
};
var columnTemplate = t.Layout == "sidebar-right"
? $"minmax(0,1fr) {F(t.SidebarWidthMm)}mm"
: $"{F(t.SidebarWidthMm)}mm minmax(0,1fr)";
var layoutCss = twoColumn
? $@".cols{{display:grid;grid-template-columns:{(t.Layout == "sidebar-right" ? $"1fr {F(t.SidebarWidthMm)}mm" : $"{F(t.SidebarWidthMm)}mm 1fr")};min-height:{page.h};}}
? $@".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);}}
@@ -204,12 +222,14 @@ 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:hidden;}}
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{t.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;}}
.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.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;}}
.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-square{{border-radius:2mm;}}
@@ -218,8 +238,8 @@ h1,h2{{font-family:{headingFont};}}
.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-stacked{{flex-direction:column;gap:1.8mm;}}
.contact-item{{display:inline-flex;align-items:center;gap:1.2mm;}}
.contact a{{color:inherit;text-decoration:none;}}
.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;}}
.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;}}
@@ -230,21 +250,24 @@ h1,h2{{font-family:{headingFont};}}
.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;}}
.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;}}
.entry{{margin-bottom:{entryGap}mm;}}
.entry:last-child{{margin-bottom:0;}}
.entry-head{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;}}
.entry-title{{font-weight:700;font-size:{F(t.BodySizePt + 1)}pt;}}
.entry-meta{{color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;white-space:nowrap;}}
.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-tags{{margin-top:1.4mm;}}
{layoutCss}
/* Print quality: keep an entry whole across a page break, keep a heading with its content, and
avoid single dangling lines. Chromium honours these in the Playwright PDF pass. */
.entry{{break-inside:avoid;page-break-inside:avoid;}}
/* 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;}}
.tag,.contact-item{{break-inside:avoid;}}
.bullets li{{orphans:2;widows:2;}}
.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;}}
@media print{{body{{background:transparent;}}}}
@page{{size:{page.w} {page.h};margin:0;}}
";
}