feat(cv): extend templates and extraction

This commit is contained in:
cesnimda
2026-08-27 15:27:48 +02:00
parent cc76f86482
commit ccd0af908c
15 changed files with 586 additions and 54 deletions
+53 -7
View File
@@ -210,6 +210,21 @@ public sealed class CvBuilderTests
Assert.DoesNotContain("transform:scale", html);
}
[Fact]
public void Multi_entry_sections_flow_between_entries_instead_of_leaving_a_blank_page()
{
var profile = Rich();
profile.Jobs.Add(new StructuredCvJob { Id = "job3", Title = "Engineer III", Company = "Example", Bullets = { "Shipped reliable services." } });
var settings = new CvVariantSettings { ThemeId = "code" };
var model = CvVariantResolver.Build(profile, settings, "F", null);
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("code"), settings).Html;
Assert.Contains("section section-experience section-flow", html);
Assert.Contains(".section-flow{break-inside:auto", html);
Assert.DoesNotContain(":has(", html);
}
[Fact]
public void Header_band_and_sidebar_contact_text_own_their_contrasting_palette()
{
@@ -217,12 +232,11 @@ public sealed class CvBuilderTests
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
var modern = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern" }).Html;
var lightAccent = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern", AccentColor = "#f8fafc" }).Html;
var technicalTheme = CvThemeCatalog.Resolve("technical");
var technical = renderer.Render(model, technicalTheme, new CvVariantSettings { ThemeId = "technical" }).Html;
var technical = renderer.Render(model, CvThemeCatalog.Resolve("technical"), new CvVariantSettings { ThemeId = "technical" }).Html;
Assert.Contains(".header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{color:#fff;}", modern);
Assert.Contains(".header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{color:#000;}", lightAccent);
Assert.Contains($".sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{technicalTheme.SidebarInk};}}", technical);
Assert.Contains(".sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{color:var(--cv-accent-ink);}", technical);
}
[Fact]
@@ -272,8 +286,8 @@ public sealed class CvBuilderTests
Assert.Equal(13, settings.BaseFontSizePt);
Assert.Contains("grid-template-columns:minmax(0,1fr) 70mm", html);
Assert.Contains("color:#123456", html);
Assert.Contains("background:#fafafa", html);
Assert.Contains("--cv-ink:#123456", html);
Assert.Contains("--cv-paper:#fafafa", html);
Assert.Contains("line-height:1.55", html);
Assert.Contains("font-size:13pt", html);
Assert.Contains("class=\"skills-text\">C# · SQL</p>", html);
@@ -437,12 +451,44 @@ public sealed class CvBuilderTests
Assert.Equal("user-1", await svc.GetPublicOwnerAsync(v.PublicSlug, default));
}
[Fact]
public void Premium_theme_policy_keeps_three_free_themes_available()
public void Premium_theme_policy_keeps_free_themes_available()
{
Assert.Equal(3, CvThemeCatalog.Themes.Count(t => CvThemeCatalog.CanUse(t.Id, false)));
Assert.Equal(4, CvThemeCatalog.Themes.Count(t => CvThemeCatalog.CanUse(t.Id, false)));
Assert.Equal(5, CvThemeCatalog.Themes.Count(t => t.Premium));
Assert.All(CvThemeCatalog.Themes, t => Assert.True(CvThemeCatalog.CanUse(t.Id, true)));
Assert.False(CvThemeCatalog.CanUse("unknown", true));
}
[Fact]
public void Code_theme_uses_registry_driven_numbered_sections_and_shared_accent_tokens()
{
var settings = new CvVariantSettings { ThemeId = "code", AccentColor = "#126b55" };
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("code"), settings).Html;
Assert.Contains("counter-reset:cv-section", html);
Assert.Contains("--cv-accent-color:#126b55", html);
Assert.Contains("Consolas", html);
Assert.True(CvThemeCatalog.Resolve("code").AtsFriendly);
}
[Fact]
public void Section_overrides_skill_groups_and_custom_paragraphs_share_the_render_model()
{
var settings = new CvVariantSettings
{
Sections = { new CvSectionSetting { Key = "summary", Items = new() { "Tailored summary" } } },
SkillsStyle = "grouped",
SkillGroups = new() { new CvSkillGroupSetting { Name = "Backend", Items = { "C#", ".NET" } } },
CustomSections = { new CvCustomSectionSetting { Key = "decl", Title = "Declaration", ContentType = "paragraphs", Items = { "I confirm these details." } } },
};
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("modern"), settings).Html;
Assert.Equal(new[] { "Tailored summary" }, model.Sections.Single(section => section.Key == "summary").Bullets);
Assert.Contains("class=\"skill-group-name\">Backend", html);
Assert.Contains("class=\"paragraphs\"><p>I confirm these details.</p>", html);
}
}
+11 -4
View File
@@ -18,15 +18,16 @@ namespace JobTrackerApi.Tests;
public sealed class CvCorpusHarnessTests
{
private static readonly string CorpusRoot = "/home/pi/cvs";
private const string DefaultCorpusRoot = "/home/pi/cvs";
[Fact]
public async Task Local_cv_corpus_harness_produces_repeatable_parse_report_when_available()
{
if (!Directory.Exists(CorpusRoot)) return;
var corpusRoot = ResolveCorpusRoot();
if (!Directory.Exists(corpusRoot)) return;
var ignoredPatterns = ResolveIgnoredPatterns();
var files = Directory.EnumerateFiles(CorpusRoot, "*.*", SearchOption.TopDirectoryOnly)
var files = Directory.EnumerateFiles(corpusRoot, "*.*", SearchOption.TopDirectoryOnly)
.Where(path => path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
@@ -143,7 +144,7 @@ public sealed class CvCorpusHarnessTests
}
var summary = new CvBenchmarkSummary(
CorpusRoot,
corpusRoot,
outputRoot,
DateTimeOffset.UtcNow,
entries.Count,
@@ -202,6 +203,12 @@ public sealed class CvCorpusHarnessTests
return Path.Combine(Path.GetTempPath(), "jobtracker-cv-benchmark", DateTime.UtcNow.ToString("yyyyMMddHHmmss"));
}
private static string ResolveCorpusRoot()
{
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_CORPUS_DIR");
return string.IsNullOrWhiteSpace(configured) ? DefaultCorpusRoot : configured.Trim();
}
private static string ResolveApprovedFixturesRoot(string outputRoot)
{
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_APPROVED_DIR");
@@ -1,6 +1,8 @@
using System.Reflection;
using System.IO.Compression;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace JobTrackerApi.Tests;
@@ -137,10 +139,150 @@ public sealed class CvExtractionCoverageTests
Assert.Equal(4, profile.Jobs.Count);
}
[Fact]
public void Numbered_editorial_headings_are_canonicalized_without_personal_fixture_data()
{
var profile = InvokeHeuristicBuilder("""
01 PROFESSIONAL SUMMARY
Backend engineer focused on reliable services and clear delivery.
02 CORE COMPETENCIES
C#
Docker
03 PROFESSIONAL EXPERIENCE
Software Engineer
Example Council
2020Present
- Built maintainable APIs
""");
Assert.NotEmpty(profile.Summary);
Assert.Contains("C#", profile.Skills);
Assert.Contains(profile.Jobs, job => job.Title == "Software Engineer");
}
[Fact]
public void Unknown_markdown_sections_are_preserved_for_import_review()
{
var profile = InvokeHeuristicBuilder("""
# Professional Summary
Engineer with broad delivery experience.
# Community Leadership
Mentored early-career developers
Organised a monthly meetup
""");
var custom = Assert.Single(profile.OtherSections, section => section.Title == "Community Leadership");
Assert.Contains(custom.Items, item => item.Contains("Mentored", System.StringComparison.Ordinal));
}
[Fact]
public void Contact_detection_uses_the_location_segment_and_rejects_section_headings()
{
var profile = InvokeHeuristicBuilder("""
Alex Example
Platform Engineer
Tønsberg, Norway · +47 412 34 567 · alex.example@example.test
portfolio.example.dev · linkedin.com/in/alex-example
Core Competencies
Backend Development · System Design
""");
Assert.Equal("Tønsberg, Norway", profile.Contact.Location);
Assert.Equal("portfolio.example.dev", profile.Contact.Website);
}
[Fact]
public void Norwegian_section_headings_are_canonicalized()
{
var profile = InvokeHeuristicBuilder("""
Sammendrag
Utvikler med erfaring fra robuste tjenester.
Teknisk kompetanse
C#
Docker
Arbeidserfaring
Systemutvikler
Eksempelkommune
2020Present
- Bygget pålitelige tjenester
Utdanning
Bachelor i informatikk
Eksempelhøgskolen
20172020
Språk
Norsk: B2
English: Native
""");
Assert.NotEmpty(profile.Summary);
Assert.Contains("C#", profile.Skills);
Assert.NotEmpty(profile.Jobs);
Assert.NotEmpty(profile.Education);
Assert.Equal(2, profile.Languages.Count);
}
[Fact]
public void Norwegian_inline_language_pairs_keep_their_own_proficiency()
{
var profile = InvokeHeuristicBuilder("""
Språk
Engelsk morsmål Norsk A2/B1, under aktiv utvikling mot B2
""");
Assert.Contains(profile.Languages, language => language.Name == "English" && language.Level == "Native");
Assert.Contains(profile.Languages, language => language.Name == "Norwegian" && language.Level == "A2/B1");
}
[Fact]
public async Task Docx_fallback_preserves_paragraph_and_table_boundaries()
{
await using var package = new MemoryStream();
using (var archive = new ZipArchive(package, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry("word/document.xml");
await using var stream = entry.Open();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync("""
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>
<w:p><w:r><w:t>Technical Skills</w:t></w:r></w:p>
<w:tbl>
<w:tr><w:tc><w:p><w:r><w:t>Backend</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>C#, .NET</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:p><w:r><w:t>DevOps</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>Docker, Linux</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:pPr><w:pStyle w:val="CVRole" /></w:pPr><w:r><w:t>Example Engineer</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="CVBullet" /></w:pPr><w:r><w:t>Built reliable services</w:t></w:r></w:p>
</w:body></w:document>
""");
}
package.Position = 0;
var upload = new FormFile(package, 0, package.Length, "file", "sanitized.docx");
var method = typeof(ProfileCvController).GetMethod("ExtractTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!;
var task = Assert.IsAssignableFrom<Task<string>>(method.Invoke(null, new object[] { upload, ".docx" }));
var extracted = await task;
Assert.Contains("Technical Skills\n", extracted);
Assert.Contains("Backend | C#, .NET", extracted);
Assert.Contains("DevOps | Docker, Linux", extracted);
Assert.Contains("\n\nExample Engineer\n- Built reliable services", extracted);
}
private static StructuredCvProfile InvokeProfileBuilder(string markdown)
{
var method = typeof(ProfileCvController).GetMethod("BuildStructuredCvFromNormalizedMarkdown", BindingFlags.NonPublic | BindingFlags.Static)!;
return Assert.IsType<StructuredCvProfile>(method.Invoke(null, new object[] { markdown }));
}
private static StructuredCvProfile InvokeHeuristicBuilder(string text)
{
var method = typeof(ProfileCvController).GetMethod("BuildHeuristicStructuredCv", BindingFlags.NonPublic | BindingFlags.Static)!;
return Assert.IsType<StructuredCvProfile>(method.Invoke(null, new object[] { text, text }));
}
}
@@ -35,6 +35,14 @@ public sealed class HumanLanguageCatalogTests
Assert.Equal("Norwegian", HumanLanguageCatalog.NormalizeLanguageName(alias));
}
[Fact]
public void Norwegian_cv_terms_normalize_without_changing_the_canonical_storage_language()
{
Assert.Equal("English", HumanLanguageCatalog.NormalizeLanguageName("engelsk"));
Assert.Equal("Native", HumanLanguageCatalog.ExtractLevel("morsmål"));
Assert.Equal("Professional working proficiency", HumanLanguageCatalog.ExtractLevel("profesjonelt arbeidsnivå"));
}
[Theory]
[InlineData("C#")]
[InlineData("Leadership")]
@@ -59,6 +59,7 @@ public sealed class CvVariantController : ControllerBase
requiresPro = t.Premium,
available = proThemes || !t.Premium,
swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
supportedSettings = t.SupportedSettings,
});
return Ok(themes);
}
@@ -1,7 +1,9 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using JobTrackerApi.Data;
using JobTrackerApi.Services;
using JobTrackerApi.Models;
@@ -256,11 +258,28 @@ public sealed partial class ProfileCvController : ControllerBase
}
var skills = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var skill in ExtractSkillsHeuristically(rawSource))
var skillSections = sections.Where(section => section.Name == "Skills").ToList();
var skillsSource = string.Join("\n", skillSections.Select(section => section.Content));
if (string.IsNullOrWhiteSpace(skillsSource)) skillsSource = rawSource;
foreach (var skill in ExtractSkillsHeuristically(skillsSource))
{
skills.Add(skill);
}
profile.Skills = skills.ToList();
// Once a heading has positively bounded a Skills section, its list items are stronger
// evidence than the conservative whole-document vocabulary. Preserve domain-specific items
// without mining arbitrary prose as skills.
if (skillSections.Count > 0)
{
foreach (var item in SplitListLike(skillsSource))
{
var cleaned = CleanSkillGroupPrefix(item);
if (cleaned.Length is >= 2 and <= 80 && cleaned.Any(char.IsLetter)) skills.Add(cleaned);
}
}
profile.Skills = skills.Select(CleanSkillGroupPrefix)
.Where(skill => skill.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var educationSection = sections.FirstOrDefault(section => section.Name == "Education");
if (!string.IsNullOrWhiteSpace(educationSection.Content))
@@ -290,6 +309,20 @@ public sealed partial class ProfileCvController : ControllerBase
profile.Jobs = ParseJobsHeuristically(normalized);
}
var handledSections = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"General", "Contact", "Professional Summary", "Skills", "Work Experience", "Education",
"Projects", "Certifications", "Languages", "Interests", "Awards", "Publications",
"Organisations", "References", "Selected Achievements",
};
foreach (var section in sections.Where(section => !handledSections.Contains(section.Name)))
{
var items = SplitListLike(section.Content);
if (items.Count == 0 && !string.IsNullOrWhiteSpace(section.Content)) items.Add(section.Content.Trim());
if (items.Count > 0 && !profile.OtherSections.Any(existing => string.Equals(existing.Title, section.Name, StringComparison.OrdinalIgnoreCase)))
profile.OtherSections.Add(new StructuredCvOtherSection { Title = section.Name, Items = items });
}
if (profile.OtherSections.Count == 0 && sections.Any(section => section.Name == "General"))
{
var general = sections.First(section => section.Name == "General");
@@ -402,6 +435,8 @@ public sealed partial class ProfileCvController : ControllerBase
{
foreach (Match match in Regex.Matches(rawSource, @"\b(?:https?://)?(?:www\.)?[A-Z0-9.-]+\.[A-Z]{2,}(?:/[A-Z0-9._~:/?#\[\]@!$&'()*+,;=-]*)?", RegexOptions.IgnoreCase))
{
if ((match.Index > 0 && rawSource[match.Index - 1] == '@')
|| (match.Index + match.Length < rawSource.Length && rawSource[match.Index + match.Length] == '@')) continue;
var candidate = NormalizeDetectedWebsite(match.Value, email);
if (candidate is null) continue;
if (candidate.Contains("linkedin.com", StringComparison.OrdinalIgnoreCase)) continue;
@@ -427,9 +462,12 @@ public sealed partial class ProfileCvController : ControllerBase
var lines = source.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var rawLine in lines.Take(10))
{
var line = Regex.Replace(rawLine, @",?\s*(Hobbies|Education)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim(' ', ',');
if (!IsPlausibleLocationValue(line, fullName)) continue;
return line;
foreach (var segment in Regex.Split(rawLine, @"\s*(?:[•·|]|\s{2,})\s*").Where(value => !string.IsNullOrWhiteSpace(value)))
{
var line = Regex.Replace(segment, @",?\s*(Hobbies|Education)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim(' ', ',');
if (!IsPlausibleLocationValue(line, fullName)) continue;
return line;
}
}
return IsPlausibleLocationValue(normalizedFallback, fullName) ? normalizedFallback : null;
@@ -439,6 +477,7 @@ public sealed partial class ProfileCvController : ControllerBase
{
var candidate = NullIfWhitespace(value);
if (candidate is null) return false;
if (CanonicalizeSectionHeading(candidate) is not null) return false;
if (LooksLikeRoleOrHeadline(candidate)) return false;
if (!string.IsNullOrWhiteSpace(fullName))
{
@@ -457,15 +496,15 @@ public sealed partial class ProfileCvController : ControllerBase
var normalized = Regex.Replace(candidate, @"\s+", " ").Trim(' ', ',');
if (normalized.Length > 80) return false;
if (Regex.IsMatch(normalized, @"^[A-Z][A-Za-z.' -]+,\s*[A-Z][A-Za-z.' -]+(?:,\s*[A-Z][A-Za-z.' -]+)?$")) return true;
if (Regex.IsMatch(normalized, @"^[A-Z][A-Za-z.' -]+(?:\s+[A-Z][A-Za-z.' -]+){0,2}$") && !LooksLikeRoleOrHeadline(normalized)) return true;
if (Regex.IsMatch(normalized, @"^\p{Lu}[\p{L}.' -]+,\s*\p{Lu}[\p{L}.' -]+(?:,\s*\p{Lu}[\p{L}.' -]+)?$")) return true;
if (Regex.IsMatch(normalized, @"^\p{Lu}[\p{L}.' -]+(?:\s+\p{Lu}[\p{L}.' -]+){0,2}$") && !LooksLikeRoleOrHeadline(normalized)) return true;
return false;
}
private static bool LooksLikeRoleOrHeadline(string value)
{
return Regex.IsMatch(value, @"\b(real estate agent|developer|engineer|manager|consultant|specialist|analyst|designer|technician|administrator|architect|director|coordinator|assistant|lead|owner|founder|recruiter|teacher|writer|producer|officer|supervisor|sales)\b", RegexOptions.IgnoreCase);
return Regex.IsMatch(value, @"\b(real estate agent|developer|development|engineer|manager|consultant|specialist|analyst|designer|technician|administrator|architect|director|coordinator|assistant|lead|owner|founder|recruiter|teacher|writer|producer|officer|supervisor|sales|competencies)\b", RegexOptions.IgnoreCase);
}
private static bool LooksLikePersonName(string value)
@@ -527,6 +566,23 @@ public sealed partial class ProfileCvController : ControllerBase
private static List<StructuredCvLanguage> ParseLanguagesHeuristically(string content)
{
var languages = new List<StructuredCvLanguage>();
foreach (var line in content.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var paired = Regex.Split(line, @"\s*(?:[•·|]|[—–](?!\d))\s*")
.Select(item => item.Trim())
.Where(item => item.Length > 0)
.ToList();
if (paired.Count < 2) continue;
for (var index = 0; index + 1 < paired.Count; index += 2)
{
var level = HumanLanguageCatalog.ExtractLevel(paired[index + 1]);
if (level is null) continue;
foreach (var name in HumanLanguageCatalog.ExtractLanguageNames(paired[index]))
languages.Add(new StructuredCvLanguage { Name = name, Level = level });
}
}
var candidates = Regex.Split(content.Replace("\r\n", "\n"), @"[\n,;]+|(?<=[.!?])\s+")
.Select(item => item.Trim())
.Where(item => item.Length > 1);
@@ -551,6 +607,12 @@ public sealed partial class ProfileCvController : ControllerBase
private static List<StructuredCvEducation> ParseEducationHeuristically(string content)
{
var normalized = content.Replace("\r\n", "\n").Trim();
var direct = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Education", Content = normalized } }).Education;
if (direct.Count == 1 && direct.Any(item => !string.IsNullOrWhiteSpace(item.Institution)
|| !string.IsNullOrWhiteSpace(item.Start) || !string.IsNullOrWhiteSpace(item.End)))
{
return direct;
}
var blocks = Regex.Split(normalized, @"\n\s*\n|(?=###\s+)|(?=(?:Bachelor|Master|Doctor|Associate|Diploma|Certificate|BSc|BA|MSc|MA|PhD)\b)", RegexOptions.IgnoreCase)
.Select(block => block.Trim())
.Where(block => block.Length > 0)
@@ -794,11 +856,11 @@ public sealed partial class ProfileCvController : ControllerBase
foreach (var raw in lines)
{
var line = raw.Trim();
var canonicalHeading = CanonicalizeSectionHeading(line);
if (canonicalHeading is not null)
var sectionHeading = CanonicalizeSectionHeading(line) ?? DetectCustomSectionHeading(line);
if (sectionHeading is not null)
{
Flush();
currentName = canonicalHeading;
currentName = sectionHeading;
continue;
}
@@ -1220,7 +1282,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
private static string CleanSkillGroupPrefix(string skill)
=> Regex.Replace(skill.Trim(), @"^(?:Development|DevOps(?:\s*&\s*Infrastructure)?|Infrastructure|Practices|Tools|Technologies|Technical Skills)\s*:\s*", string.Empty, RegexOptions.IgnoreCase).Trim();
=> Regex.Replace(skill.Trim(), @"^(?:Development|Programming Languages?|Backend|Frontend|Databases?|DevOps(?:\s*(?:&|og)\s*(?:CI/CD|Infrastructure))?|Infrastructure|Operations|Practices|Tools|Technologies|Technical Skills|Programmeringsspråk|Databaser|Infrastruktur|Drift|Praksis|Metodikk)\s*:\s*", string.Empty, RegexOptions.IgnoreCase).Trim();
private static string SeparateGluedDateAndTitle(string text)
=> Regex.Replace(text, @"(?<date>\b\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current))(?<title>[\p{L}][^\r\n]*)", "${title}\n${date}", RegexOptions.IgnoreCase);
@@ -1258,13 +1320,30 @@ public sealed partial class ProfileCvController : ControllerBase
normalized = normalized.TrimStart('#').Trim();
}
normalized = normalized.TrimEnd(':').Trim();
normalized = Regex.Replace(normalized, @"^\s*\d{1,2}\s*(?:[-–—.:)]\s*)+", string.Empty).Trim();
normalized = Regex.Replace(normalized, @"\s+", " ").TrimEnd(':').Trim();
if (normalized.Length == 0 || normalized.Length > 60) return null;
if (normalized.Contains('.') || normalized.Contains(" ")) return null;
return SectionAliases.TryGetValue(normalized, out var canonical) ? canonical : null;
}
private static string? DetectCustomSectionHeading(string line)
{
if (string.IsNullOrWhiteSpace(line)) return null;
var markdown = line.TrimStart().StartsWith("#", StringComparison.Ordinal);
var candidate = line.Trim().TrimStart('#').Trim();
candidate = Regex.Replace(candidate, @"^\s*\d{1,2}\s*(?:[-–—.:)]\s*)+", string.Empty).Trim().TrimEnd(':').Trim();
if (candidate.Length is < 3 or > 60 || candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length > 7) return null;
if (Regex.IsMatch(candidate, @"\d{4}|@|https?://|www\.|^[•+*-]")) return null;
var letters = candidate.Where(char.IsLetter).ToArray();
// A lone all-caps token is commonly a skill (SQL, AWS, DOCKER), not a new section.
// Unknown single-word headings remain available through explicit Markdown headings.
var upperHeading = letters.Length >= 3 && letters.All(char.IsUpper) && candidate.Contains(' ');
if (!markdown && !upperHeading) return null;
return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(candidate.ToLowerInvariant());
}
private static bool HasRecoverableSectionSignals(string text)
{
var sections = ParseSections(text);
@@ -1310,9 +1389,39 @@ public sealed partial class ProfileCvController : ControllerBase
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream, Encoding.UTF8);
var xml = await reader.ReadToEndAsync();
var withoutTags = Regex.Replace(xml, "<[^>]+>", " ");
var decoded = System.Net.WebUtility.HtmlDecode(withoutTags) ?? string.Empty;
return Regex.Replace(decoded, @"\s+", " ").Trim();
var document = XDocument.Parse(xml, LoadOptions.PreserveWhitespace);
XNamespace word = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
var body = document.Root?.Element(word + "body");
if (body is null) return string.Empty;
static string Text(XElement element, XNamespace ns) => string.Concat(
element.Descendants(ns + "t").Select(node => node.Value));
var blocks = new List<string>();
foreach (var block in body.Elements())
{
if (block.Name == word + "p")
{
var paragraph = Text(block, word).Trim();
if (paragraph.Length == 0) continue;
var style = block.Element(word + "pPr")?.Element(word + "pStyle")?.Attribute(word + "val")?.Value ?? string.Empty;
if (style.Contains("Role", StringComparison.OrdinalIgnoreCase) && blocks.Count > 0) blocks.Add(string.Empty);
blocks.Add(style.Contains("Bullet", StringComparison.OrdinalIgnoreCase) ? $"- {paragraph}" : paragraph);
continue;
}
if (block.Name != word + "tbl") continue;
foreach (var row in block.Elements(word + "tr"))
{
var cells = row.Elements(word + "tc")
.Select(cell => string.Join(" ", cell.Elements(word + "p").Select(paragraph => Text(paragraph, word).Trim()).Where(value => value.Length > 0)))
.Where(value => value.Length > 0)
.ToList();
if (cells.Count > 0) blocks.Add(string.Join(" | ", cells));
}
}
return string.Join("\n", blocks).Trim();
}
return string.Empty;
@@ -126,8 +126,15 @@ public sealed partial class ProfileCvController : ControllerBase
AnnotateStructuredCv(sectionFallback, "repair", 0.56);
var heuristicFallback = BuildHeuristicStructuredCv(parseSource, text);
AnnotateStructuredCv(heuristicFallback, "deterministic", 0.68);
var heuristicSummary = heuristicFallback.Summary.ToList();
var heuristicSkills = heuristicFallback.Skills.ToList();
heuristicFallback.Sections = new List<StructuredCvSection>();
var fallback = StructuredCvProfileJson.Merge(heuristicFallback, sectionFallback);
// The section parser deliberately treats comma-separated text as lists. A prose summary is
// better represented by the sentence-aware deterministic parser, otherwise one paragraph
// is duplicated as many comma fragments during fallback merging.
if (heuristicSummary.Count > 0) fallback.Summary = heuristicSummary;
if (heuristicSkills.Count > 0) fallback.Skills = heuristicSkills;
if (classifierFallback is not null)
{
fallback = StructuredCvProfileJson.Merge(classifierFallback, fallback);
@@ -160,7 +167,10 @@ public sealed partial class ProfileCvController : ControllerBase
}
else if (ArePlausibleJobs(merged.Jobs, merged.Contact.FullName))
{
if (ScoreJobs(reparsedJobs, merged.Contact.FullName) > ScoreJobs(merged.Jobs, merged.Contact.FullName))
var firstJobCameFromClassifier = merged.Metadata.Fields.TryGetValue("jobs[0].title", out var firstJobMetadata)
&& string.Equals(firstJobMetadata.Method, "classifier", StringComparison.OrdinalIgnoreCase);
if (!firstJobCameFromClassifier
&& ScoreJobs(reparsedJobs, merged.Contact.FullName) > ScoreJobs(merged.Jobs, merged.Contact.FullName))
{
merged.Jobs = reparsedJobs;
}
@@ -40,14 +40,23 @@ public sealed partial class ProfileCvController : ControllerBase
["core skills"] = "Skills",
["skills"] = "Skills",
["technical skills"] = "Skills",
["technologies"] = "Skills",
["tech stack"] = "Skills",
["competencies"] = "Skills",
["core competencies"] = "Skills",
["experience"] = "Work Experience",
["professional experience"] = "Work Experience",
["career history"] = "Work Experience",
["work history"] = "Work Experience",
["experience highlights"] = "Work Experience",
["work experience"] = "Work Experience",
["employment history"] = "Work Experience",
["selected achievements"] = "Selected Achievements",
["achievements"] = "Selected Achievements",
["projects"] = "Projects",
["selected projects"] = "Projects",
["education"] = "Education",
["qualifications"] = "Education",
["certifications"] = "Certifications",
["certificates"] = "Certifications",
["languages"] = "Languages",
@@ -61,12 +70,34 @@ public sealed partial class ProfileCvController : ControllerBase
["organizations"] = "Organisations",
["memberships"] = "Organisations",
["references"] = "References",
["courses"] = "Courses",
["training"] = "Courses",
["volunteer experience"] = "Volunteer Experience",
["volunteering"] = "Volunteer Experience",
["additional experience"] = "Additional Experience",
["declaration"] = "Declaration",
["sammendrag"] = "Professional Summary",
["profil"] = "Professional Summary",
["kjernekompetanse"] = "Skills",
["tekniske ferdigheter"] = "Skills",
["teknisk kompetanse"] = "Skills",
["arbeidserfaring"] = "Work Experience",
["yrkeserfaring"] = "Work Experience",
["utvalgte prosjekter"] = "Projects",
["prosjekter"] = "Projects",
["tilleggserfaring"] = "Additional Experience",
["utdanning"] = "Education",
["sertifiseringer"] = "Certifications",
["språk"] = "Languages",
["interesser"] = "Interests",
["referanser"] = "References",
["lenker"] = "Links",
};
private const long MaxFileSizeBytes = 5 * 1024 * 1024;
private const int ExtractionRunRetentionCount = 20;
private const string ParserVersion = "m005-s01";
private const string NormalizerVersion = "m005-s01";
private const string ParserVersion = "m005-s03";
private const string NormalizerVersion = "m005-s03";
private const string LlmPromptVersion = "m005-s01";
private readonly UserManager<ApplicationUser> _users;
+23 -1
View File
@@ -28,6 +28,7 @@ public sealed class CvTheme
// Typography
public string HeadingFont { get; init; } = "Georgia, 'Times New Roman', serif";
public string BodyFont { get; init; } = "Arial, Helvetica, sans-serif";
public string UtilityFont { get; init; } = "'Roboto Mono', Consolas, monospace";
public double NameSizePt { get; init; } = 24;
public double HeadingSizePt { get; init; } = 12;
public double BodySizePt { get; init; } = 10;
@@ -51,6 +52,15 @@ public sealed class CvTheme
// the single-column themes; surfaced in GET /api/cv/themes so the picker can badge it.
public bool AtsFriendly { get; init; }
public bool Premium { get; init; }
public bool NumberSections { get; init; }
// Builder options this template intentionally exposes. The renderer remains generic; the
// registry tells the editor which controls make sense for a selected template.
public List<string> SupportedSettings { get; init; } = new()
{
"accent", "typography", "spacing", "skills", "page", "heading", "header",
"layout", "sidebar", "photo", "icons",
};
// Which section keys render in the sidebar for two-column layouts (ignored for single/header-band).
public List<string> SidebarSections { get; init; } = new() { "contact", "skills", "languages" };
@@ -58,7 +68,7 @@ public sealed class CvTheme
public static class CvThemeCatalog
{
// 8 professional themes, all data. To add one: append here.
// Professional themes, all data. To add one: append here.
public static readonly IReadOnlyList<CvTheme> Themes = new List<CvTheme>
{
new()
@@ -134,6 +144,18 @@ public static class CvThemeCatalog
PhotoShape = "circle", DefaultIcons = true,
SidebarSections = new() { "contact", "skills", "languages", "interests" },
},
new()
{
Id = "code", Name = "Code", Category = "Technical",
Description = "Developer-focused editorial layout with numbered sections and precise mono details.",
Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule",
Accent = "#0b7a63", Ink = "#171a20", Muted = "#555e6b", Line = "#d8dde3", Paper = "#ffffff",
HeadingFont = "'Segoe UI', Roboto, Arial, sans-serif", BodyFont = "'Segoe UI', Roboto, Arial, sans-serif",
UtilityFont = "Consolas, 'Courier New', monospace", NameSizePt = 25.5, HeadingSizePt = 9.5,
BodySizePt = 9.4, LineHeight = 1.43, PageMarginMm = 15.24, SectionGapMm = 5.6, EntryGapMm = 4.3,
PhotoShape = "none", DefaultIcons = false, AtsFriendly = true, NumberSections = true,
SupportedSettings = new() { "accent", "typography", "spacing", "skills", "page" },
},
};
public static CvTheme Resolve(string? id)
+29 -2
View File
@@ -34,7 +34,8 @@ public sealed class CvVariantSettings
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? SkillsStyle { get; set; } // tags | text | bullets | grouped
public List<CvSkillGroupSetting>? SkillGroups { get; set; }
public string? Layout { get; set; } // single | sidebar-left | sidebar-right | header-band
public double? SidebarWidthMm { get; set; }
public List<string>? SidebarSections { get; set; }
@@ -62,6 +63,9 @@ public sealed class CvSectionSetting
// master order and come last. Null => master profile order. Lets a variant reorder entries without
// touching the master profile.
public List<string>? ItemOrder { get; set; }
// Optional CV-specific content for bullet/tag sections. Null uses master data; an empty list is
// an intentional empty override. Entry sections continue to use stable per-item overrides.
public List<string>? Items { get; set; }
}
public sealed class CvItemOverride
@@ -78,6 +82,14 @@ public sealed class CvCustomSectionSetting
public string? Title { get; set; }
public List<string> Items { get; set; } = new();
public bool Hidden { get; set; }
public string? PresetKey { get; set; }
public string? ContentType { get; set; } // paragraphs | bullets | entries
}
public sealed class CvSkillGroupSetting
{
public string Name { get; set; } = string.Empty;
public List<string> Items { get; set; } = new();
}
public static class CvVariantSettingsJson
@@ -128,7 +140,7 @@ public static class CvVariantSettingsJson
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.SkillsStyle = NormalizeChoice(s.SkillsStyle, "tags", "text", "bullets", "grouped");
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))
@@ -146,6 +158,21 @@ public static class CvVariantSettingsJson
s.Sections ??= new();
s.Overrides ??= new();
s.CustomSections ??= new();
s.SkillGroups = s.SkillGroups?
.Where(group => !string.IsNullOrWhiteSpace(group.Name) || group.Items.Any(item => !string.IsNullOrWhiteSpace(item)))
.Take(30)
.Select(group => new CvSkillGroupSetting
{
Name = (group.Name ?? string.Empty).Trim(),
Items = group.Items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => item.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Take(100).ToList(),
})
.ToList();
foreach (var section in s.CustomSections)
{
section.ContentType = NormalizeChoice(section.ContentType, "paragraphs", "bullets", "entries") ?? "bullets";
section.PresetKey = string.IsNullOrWhiteSpace(section.PresetKey) ? null : section.PresetKey.Trim().ToLowerInvariant();
section.Items ??= new();
}
return s;
}
+10 -1
View File
@@ -11,7 +11,7 @@ public static class HumanLanguageCatalog
private static readonly Regex WordRegex = new(@"\p{L}+", RegexOptions.Compiled);
private static readonly Regex LevelRegex = new(
@"\b(native(?:\s+speaker)?|fluent|advanced|intermediate|beginner|basic|conversational|elementary|professional\s+working\s+proficiency|working\s+proficiency|limited\s+working\s+proficiency|full\s+professional\s+proficiency|a1|a2|b1|b2|c1|c2|a1\s*/\s*a2|a2\s*/\s*b1|b1\s*/\s*b2|b2\s*/\s*c1|c1\s*/\s*c2)\b",
@"\b(native(?:\s+speaker)?|fluent|advanced|intermediate|beginner|basic|conversational|elementary|professional\s+working\s+proficiency|working\s+proficiency|limited\s+working\s+proficiency|full\s+professional\s+proficiency|morsmål|flytende|avansert|mellomnivå|nybegynner|grunnleggende|samtalenivå|profesjonelt\s+arbeidsnivå|a1\s*/\s*a2|a2\s*/\s*b1|b1\s*/\s*b2|b2\s*/\s*c1|c1\s*/\s*c2|a1|a2|b1|b2|c1|c2)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static string? NormalizeLanguageName(string? raw)
@@ -79,6 +79,14 @@ public static class HumanLanguageCatalog
"working proficiency" => "Working proficiency",
"limited working proficiency" => "Limited working proficiency",
"full professional proficiency" => "Full professional proficiency",
"morsmål" => "Native",
"flytende" => "Fluent",
"avansert" => "Advanced",
"mellomnivå" => "Intermediate",
"nybegynner" => "Beginner",
"grunnleggende" => "Basic",
"samtalenivå" => "Conversational",
"profesjonelt arbeidsnivå" => "Professional working proficiency",
_ when Regex.IsMatch(compact, @"^[ABC][12](?:\s*/\s*[ABC][12])?$", RegexOptions.IgnoreCase) => compact.ToUpperInvariant().Replace(" ", string.Empty),
_ => compact,
};
@@ -163,6 +171,7 @@ public static class HumanLanguageCatalog
// means. They must beat culture enumeration (see Override), because ICU carries "Norwegian
// Bokmål"/"Norwegian Nynorsk" and "Chinese (Simplified/Traditional)" as their own cultures.
Override("norsk", "Norwegian");
Override("engelsk", "English");
Override("bokmal", "Norwegian");
Override("bokmål", "Norwegian");
Override("nynorsk", "Norwegian");
+29 -3
View File
@@ -25,13 +25,20 @@ public sealed class CvRenderSection
{
public string Key { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
// bullets | tags | entries
// bullets | tags | paragraphs | skill-groups | entries
public string Kind { get; set; } = "entries";
public List<string> Bullets { get; set; } = new();
public List<string> Tags { get; set; } = new();
public List<CvRenderEntry> Entries { get; set; } = new();
public List<CvRenderSkillGroup> SkillGroups { get; set; } = new();
public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0;
public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0 && SkillGroups.Count == 0;
}
public sealed class CvRenderSkillGroup
{
public string Name { get; set; } = string.Empty;
public List<string> Items { get; set; } = new();
}
public sealed class CvRenderEntry
@@ -114,7 +121,7 @@ public static class CvVariantResolver
{
Key = key,
Title = Trim(custom.Title) ?? "Additional",
Kind = "bullets",
Kind = custom.ContentType == "paragraphs" ? "paragraphs" : "bullets",
Bullets = Clean(custom.Items),
};
}
@@ -142,6 +149,11 @@ public static class CvVariantResolver
{
if (cfg.Hidden) continue;
if (!string.IsNullOrWhiteSpace(cfg.Title)) section.Title = cfg.Title!.Trim();
if (cfg.Items is not null && section.Kind is "bullets" or "tags")
{
if (section.Kind == "tags") section.Tags = Clean(cfg.Items);
else section.Bullets = Clean(cfg.Items);
}
if (cfg.ItemOrder is { Count: > 0 } && section.Entries.Count > 1)
{
section.Entries = ReorderByKey(section.Entries, cfg.ItemOrder);
@@ -154,6 +166,20 @@ public static class CvVariantResolver
if (!section.IsEmpty) model.Sections.Add(section);
}
if (settings.SkillGroups is { Count: > 0 })
{
var skills = model.Sections.FirstOrDefault(section => section.Key.Equals("skills", StringComparison.OrdinalIgnoreCase));
if (skills is not null)
{
skills.Kind = "skill-groups";
skills.Tags.Clear();
skills.SkillGroups = settings.SkillGroups
.Select(group => new CvRenderSkillGroup { Name = Trim(group.Name) ?? string.Empty, Items = Clean(group.Items) })
.Where(group => group.Items.Count > 0)
.ToList();
}
}
return model;
}
+51 -13
View File
@@ -133,11 +133,21 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
var inner = section.Kind switch
{
"bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>",
"paragraphs" => $@"<div class=""paragraphs"">{string.Join("", section.Bullets.Select(item => $@"<p>{Inline(item)}</p>"))}</div>",
"skill-groups" => $@"<div class=""skill-groups"">{string.Join("", section.SkillGroups.Select(RenderSkillGroup))}</div>",
"tags" when section.Key == "skills" && settings?.SkillsStyle == "bullets" => $@"<ul class=""bullets skill-bullets"">{Items(section.Tags)}</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)),
};
return $@"<section class=""section""><h2 class=""section-title"">{Enc(section.Title)}</h2>{inner}</section>";
var flowClass = IsFlowingSection(section) ? " section-flow" : string.Empty;
return $@"<section class=""section section-{Attr(section.Key)}{flowClass}""><h2 class=""section-title""><span class=""section-title-text"">{Enc(section.Title)}</span></h2>{inner}</section>";
}
private static string RenderSkillGroup(CvRenderSkillGroup group)
{
var name = string.IsNullOrWhiteSpace(group.Name) ? string.Empty : $@"<div class=""skill-group-name"">{Enc(group.Name)}</div>";
return $@"<div class=""skill-group"">{name}<ul class=""skill-group-items"">{string.Join("", group.Items.Select(item => $@"<li>{Enc(item)}</li>"))}</ul></div>";
}
private static string RenderEntry(CvRenderEntry entry)
@@ -172,6 +182,17 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return entry.Bullets.Count > 5 || entry.Bullets.Any(IsFlowingItem) || textLength > 900;
}
// A section containing several individually short entries can still exceed a printable page.
// Mark it as flowing so Chromium may move/break individual entries instead of moving the whole
// oversized section to a fresh page and leaving a large blank area behind.
private static bool IsFlowingSection(CvRenderSection section)
{
if (section.Entries.Count > 2 || section.Entries.Any(IsFlowingEntry)) return true;
if (section.SkillGroups.Count > 6) return true;
if (section.Tags.Count > 30) return true;
return section.Bullets.Count > 8 || section.Bullets.Sum(item => item?.Length ?? 0) > 1_000;
}
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
@@ -204,23 +225,23 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm;
var headingCss = headingStyle switch
{
"underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}",
"underline" => ".section-title{border-bottom:1.5px solid var(--cv-accent-soft);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(headingSize * 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:var(--cv-accent-color);border-bottom:1px solid var(--cv-accent-soft);padding-bottom:1.4mm;}}",
};
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;}}
.sidebar .section-title{{color:{t.SidebarInk};border-color:rgba(255,255,255,.35);}}
.sidebar .tag{{border-color:rgba(255,255,255,.4);}}
.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{t.SidebarInk};}}
.sidebar{{background:var(--cv-accent-color);color:var(--cv-accent-ink);padding:{margin}mm;}}
.sidebar .section-title{{color:var(--cv-accent-ink);border-color:color-mix(in srgb,var(--cv-accent-ink) 38%,transparent);}}
.sidebar .tag{{border-color:color-mix(in srgb,var(--cv-accent-ink) 45%,transparent);}}
.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:var(--cv-accent-ink);}}
.sidebar a{{color:inherit;}}
.main{{padding:{margin}mm;}}
.hero .name{{color:{t.SidebarInk};}}"
.hero .name{{color:var(--cv-accent-ink);}}"
: $@".main{{padding:0 {margin}mm {margin}mm {margin}mm;}}
.header{{padding:{margin}mm {margin}mm {F(t.SectionGapMm * density)}mm {margin}mm;display:flex;gap:6mm;align-items:center;}}
.header-band{{background:{accent};color:{headerInk};}}
@@ -229,18 +250,23 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
.header-centered .contact{{justify-content:center;}}
.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);}}"
: string.Empty;
return $@"
*{{box-sizing:border-box;}}
:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};}}
html,body{{min-width:0;}}
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;}}
body{{margin:0;background:#e9edf2;color:var(--cv-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:var(--cv-paper);overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
h1,h2{{font-family:{headingFont};}}
.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:{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};}}
.photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid var(--cv-accent-soft);}}
.photo-square{{border-radius:2mm;}}
.photo-rounded{{border-radius:5mm;}}
.photo-circle{{border-radius:50%;}}
@@ -249,6 +275,7 @@ h1,h2{{font-family:{headingFont};}}
.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;}}
.main a{{color:var(--cv-accent-color);text-decoration-color:var(--cv-accent-soft);}}
.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;}}
@@ -258,9 +285,16 @@ h1,h2{{font-family:{headingFont};}}
{headingCss}
.bullets{{margin:0;padding-left:4.5mm;}}
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}}
.bullets li::marker{{color:var(--cv-accent-color);}}
.tags{{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:1.8mm;}}
.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;}}
.tag{{border:1px solid var(--cv-accent-soft);border-radius:999px;padding:.7mm 2.2mm;font-size:{F(bodySize - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}}
.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-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;}}
.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;}}
@@ -269,11 +303,15 @@ h1,h2{{font-family:{headingFont};}}
.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}}
.entry-tags{{margin-top:1.4mm;}}
{layoutCss}
{numberedSections}
/* 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;}}
.section-title{{break-after:avoid-page;page-break-after:avoid;}}
.section-title + .entry,.section-title + .bullets,.section-title + .tags,.section-title + .paragraphs,.section-title + .skill-groups{{break-before:avoid-page;page-break-before:avoid;}}
.section{{break-inside:avoid-page;page-break-inside:avoid;}}
.section-flow{{break-inside:auto;page-break-inside:auto;}}
.tag,.contact-item{{break-inside:avoid;}}
.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;}}
+32 -4
View File
@@ -1045,8 +1045,20 @@ async def purge_cache():
def _normalize_text(value: str) -> str:
value = value.replace("\x00", " ")
return re.sub(r"\s+", " ", value).strip()
"""Normalize extraction noise without destroying section, bullet, or table boundaries."""
value = value.replace("\x00", " ").replace("\r\n", "\n").replace("\r", "\n")
lines = []
blank = False
for raw_line in value.split("\n"):
line = re.sub(r"[\t\f\v]+", " ", raw_line).strip()
if not line:
if lines and not blank:
lines.append("")
blank = True
continue
lines.append(line)
blank = False
return "\n".join(lines).strip()
def _ocr_image(image: Image.Image) -> str:
@@ -1063,7 +1075,10 @@ def _extract_pdf_text(data: bytes) -> tuple[str, bool, int]:
reader = PdfReader(io.BytesIO(data))
page_count = len(reader.pages)
for page in reader.pages:
extracted_pages.append(page.extract_text() or "")
try:
extracted_pages.append(page.extract_text(extraction_mode="layout") or "")
except TypeError:
extracted_pages.append(page.extract_text() or "")
except Exception:
extracted_pages = []
@@ -1084,7 +1099,20 @@ def _extract_pdf_text(data: bytes) -> tuple[str, bool, int]:
def _extract_docx_text(data: bytes) -> str:
document = Document(io.BytesIO(data))
parts = [p.text.strip() for p in document.paragraphs if p.text and p.text.strip()]
parts = []
blocks = document.iter_inner_content() if hasattr(document, "iter_inner_content") else document.paragraphs
for block in blocks:
if hasattr(block, "rows"):
for row in block.rows:
cells = [cell.text.strip() for cell in row.cells if cell.text and cell.text.strip()]
if cells:
parts.append(" | ".join(cells))
elif getattr(block, "text", "").strip():
text = block.text.strip()
style = (getattr(getattr(block, "style", None), "name", "") or "").lower()
if "role" in style and parts:
parts.append("")
parts.append(f"- {text}" if "bullet" in style else text)
return _normalize_text("\n".join(parts))
+28
View File
@@ -1,4 +1,5 @@
import importlib
import io
import json
import sys
from pathlib import Path
@@ -522,6 +523,33 @@ def test_extract_text_rejects_oversized_upload_before_parsing(monkeypatch):
assert "too large" in response.json()["detail"].lower()
def test_extraction_normalization_preserves_sections_and_bullets(monkeypatch):
module = load_app_module(monkeypatch)
normalized = module._normalize_text("PROFILE\n\nEngineer\n• Built APIs\n• Shipped services")
assert normalized.splitlines() == ["PROFILE", "", "Engineer", "• Built APIs", "• Shipped services"]
def test_docx_extraction_preserves_paragraph_and_table_boundaries(monkeypatch):
module = load_app_module(monkeypatch)
document = module.Document()
document.add_heading("Technical Skills", level=1)
table = document.add_table(rows=2, cols=2)
table.cell(0, 0).text = "Backend"
table.cell(0, 1).text = "C#, .NET"
table.cell(1, 0).text = "DevOps"
table.cell(1, 1).text = "Docker, Linux"
payload = io.BytesIO()
document.save(payload)
extracted = module._extract_docx_text(payload.getvalue())
assert "Technical Skills" in extracted
assert "Backend | C#, .NET" in extracted
assert "DevOps | Docker, Linux" in extracted
def test_cache_purge_requires_service_token_and_clears_content(monkeypatch):
module = load_app_module(monkeypatch, service_token="s3cret")
module.cache["synthetic-key"] = "synthetic-summary"