fix: embed portable static fonts for CV preview and PDF fidelity

This commit is contained in:
cesnimda
2026-10-24 19:27:10 +02:00
parent 57dd88322a
commit e80815f8df
50 changed files with 1114 additions and 7 deletions
+51
View File
@@ -0,0 +1,51 @@
using System.Reflection;
using System.Text;
namespace JobTrackerApi.Services;
// Self-contained document fonts keep local-file PDF export and srcDoc preview on the same metrics.
internal static class CvDocumentFonts
{
private static readonly Dictionary<string, string> Styles = new()
{
["Selawik"] = Faces("selawik", ("selawk.ttf", "400", "normal"), ("selawksb.ttf", "600", "normal"), ("selawkb.ttf", "700", "normal")),
["Arimo"] = Faces("arimo", ("Arimo-Regular.ttf", "400", "normal"), ("Arimo-Italic.ttf", "400", "italic"), ("Arimo-Bold.ttf", "700", "normal"), ("Arimo-BoldItalic.ttf", "700", "italic")),
["Gelasio"] = Faces("gelasio", ("Gelasio-Regular.ttf", "400", "normal"), ("Gelasio-Italic.ttf", "400", "italic"), ("Gelasio-SemiBold.ttf", "600", "normal"), ("Gelasio-SemiBoldItalic.ttf", "600", "italic"), ("Gelasio-Bold.ttf", "700", "normal"), ("Gelasio-BoldItalic.ttf", "700", "italic")),
["Roboto"] = Faces("roboto", ("Roboto-Regular.ttf", "400", "normal"), ("Roboto-Italic.ttf", "400", "italic"), ("Roboto-Medium.ttf", "500", "normal"), ("Roboto-MediumItalic.ttf", "500", "italic"), ("Roboto-Bold.ttf", "700", "normal"), ("Roboto-BoldItalic.ttf", "700", "italic")),
["Poppins"] = Faces("poppins", ("Poppins-Regular.ttf", "400", "normal"), ("Poppins-Italic.ttf", "400", "italic"), ("Poppins-SemiBold.ttf", "600", "normal"), ("Poppins-SemiBoldItalic.ttf", "600", "italic"), ("Poppins-Bold.ttf", "700", "normal"), ("Poppins-BoldItalic.ttf", "700", "italic")),
["Roboto Mono"] = Faces("robotomono", ("RobotoMono-Regular.ttf", "400", "normal"), ("RobotoMono-Italic.ttf", "400", "italic"), ("RobotoMono-Medium.ttf", "500", "normal"), ("RobotoMono-MediumItalic.ttf", "500", "italic"), ("RobotoMono-Bold.ttf", "700", "normal"), ("RobotoMono-BoldItalic.ttf", "700", "italic")),
};
internal static string Family(string stack) => stack.TrimStart() switch
{
var s when s.StartsWith("Georgia", StringComparison.Ordinal) => "Gelasio",
var s when s.StartsWith("'Roboto Mono'", StringComparison.Ordinal) || s.StartsWith("Consolas", StringComparison.Ordinal) => "Roboto Mono",
var s when s.StartsWith("'Roboto'", StringComparison.Ordinal) => "Roboto",
var s when s.StartsWith("'Poppins'", StringComparison.Ordinal) => "Poppins",
var s when s.StartsWith("'Segoe UI'", StringComparison.Ordinal) => "Selawik",
_ => "Arimo",
};
internal static string Stack(string original) => $"'{Family(original)}', {original}";
internal static string Embed(params string[] stacks) => string.Concat(stacks.Select(Family).Distinct().Select(family => Styles[family]));
private static string Faces(string family, params (string File, string Weight, string Style)[] faces)
{
var names = new Dictionary<string, string> { ["selawik"] = "Selawik", ["arimo"] = "Arimo", ["gelasio"] = "Gelasio", ["roboto"] = "Roboto", ["poppins"] = "Poppins", ["robotomono"] = "Roboto Mono" };
var css = new StringBuilder();
using var license = new StreamReader(Resource(family + ".OFL.txt"));
css.Append("/*\n").Append(license.ReadToEnd()).Append("\n*/\n");
foreach (var face in faces)
{
using var stream = Resource(face.File);
using var bytes = new MemoryStream();
stream.CopyTo(bytes);
css.Append($"@font-face{{font-family:'{names[family]}';font-style:{face.Style};font-weight:{face.Weight};font-display:block;src:url(data:font/ttf;base64,{Convert.ToBase64String(bytes.ToArray())}) format('truetype');}}\n");
}
return css.ToString();
}
private static Stream Resource(string name) => typeof(CvDocumentFonts).Assembly.GetManifestResourceStream("CvFonts." + name)
?? throw new InvalidOperationException("Required document font resource is missing: " + name);
}
+4 -3
View File
@@ -43,7 +43,8 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
? SplitColumns(model, theme, sidebarSections, settings)
: (string.Empty, RenderSections(model.Sections, theme, settings));
var css = BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, headingFont, bodyFont, headingStyle, density, pageDims, layout, twoColumn);
var css = CvDocumentFonts.Embed(headingFont, bodyFont, theme.UtilityFont)
+ BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, CvDocumentFonts.Stack(headingFont), CvDocumentFonts.Stack(bodyFont), headingStyle, density, pageDims, layout, twoColumn);
var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle);
var body = layout switch
{
@@ -255,7 +256,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
.header-plain{{border-bottom:2px solid {accent};}}";
var numberedSections = t.NumberSections
? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{t.UtilityFont};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}"
? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{CvDocumentFonts.Stack(t.UtilityFont)};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}"
: string.Empty;
return $@"
@@ -304,7 +305,7 @@ h1,h2{{font-family:{headingFont};}}
.paragraphs p{{margin:0 0 {F(1.8 * density)}mm;white-space:pre-line;}}
.skill-groups{{display:grid;gap:{F(1.7 * density)}mm;}}
.skill-group{{display:grid;grid-template-columns:minmax(24mm,.3fr) minmax(0,1fr);gap:3mm;break-inside:avoid-page;page-break-inside:avoid;}}
.skill-group-name{{font-family:{t.UtilityFont};font-weight:700;color:var(--cv-accent-color);overflow-wrap:anywhere;}}
.skill-group-name{{font-family:{CvDocumentFonts.Stack(t.UtilityFont)};font-weight:700;color:var(--cv-accent-color);overflow-wrap:anywhere;}}
.skill-group-items{{display:flex;flex-wrap:wrap;gap:.8mm 3mm;list-style:none;margin:0;padding:0;}}
.skill-group-items li{{overflow-wrap:anywhere;}}
.entry{{margin-bottom:{entryGap}mm;}}