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:
@@ -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;}}
|
||||
";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user