diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs
index fba1f49..5ff8dee 100644
--- a/JobTrackerApi.Tests/CvBuilderTests.cs
+++ b/JobTrackerApi.Tests/CvBuilderTests.cs
@@ -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
", 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\">I confirm these details.
", html);
+ }
+
}
diff --git a/JobTrackerApi.Tests/CvCorpusHarnessTests.cs b/JobTrackerApi.Tests/CvCorpusHarnessTests.cs
index bafb37a..37d0ce0 100644
--- a/JobTrackerApi.Tests/CvCorpusHarnessTests.cs
+++ b/JobTrackerApi.Tests/CvCorpusHarnessTests.cs
@@ -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");
diff --git a/JobTrackerApi.Tests/CvExtractionCoverageTests.cs b/JobTrackerApi.Tests/CvExtractionCoverageTests.cs
index 3607b69..d764c33 100644
--- a/JobTrackerApi.Tests/CvExtractionCoverageTests.cs
+++ b/JobTrackerApi.Tests/CvExtractionCoverageTests.cs
@@ -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
+ 2020–Present
+ - 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
+ 2020–Present
+ - Bygget pålitelige tjenester
+
+ Utdanning
+ Bachelor i informatikk
+ Eksempelhøgskolen
+ 2017–2020
+
+ 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("""
+
+ Technical Skills
+
+ BackendC#, .NET
+ DevOpsDocker, Linux
+
+ Example Engineer
+ Built reliable services
+
+ """);
+ }
+ 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>(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(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(method.Invoke(null, new object[] { text, text }));
+ }
+
}
diff --git a/JobTrackerApi.Tests/HumanLanguageCatalogTests.cs b/JobTrackerApi.Tests/HumanLanguageCatalogTests.cs
index 34e204d..db690f4 100644
--- a/JobTrackerApi.Tests/HumanLanguageCatalogTests.cs
+++ b/JobTrackerApi.Tests/HumanLanguageCatalogTests.cs
@@ -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")]
diff --git a/JobTrackerApi/Controllers/CvVariantController.cs b/JobTrackerApi/Controllers/CvVariantController.cs
index 5ed9116..e543f5c 100644
--- a/JobTrackerApi/Controllers/CvVariantController.cs
+++ b/JobTrackerApi/Controllers/CvVariantController.cs
@@ -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);
}
diff --git a/JobTrackerApi/Controllers/ProfileCvController.Parsing.cs b/JobTrackerApi/Controllers/ProfileCvController.Parsing.cs
index bf4b431..33c69dd 100644
--- a/JobTrackerApi/Controllers/ProfileCvController.Parsing.cs
+++ b/JobTrackerApi/Controllers/ProfileCvController.Parsing.cs
@@ -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(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(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 ParseLanguagesHeuristically(string content)
{
var languages = new List();
+ 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 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, @"(?\b\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current))(?[\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();
+ 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;
diff --git a/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs b/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs
index 45b2bf1..fa5499b 100644
--- a/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs
+++ b/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs
@@ -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();
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;
}
diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs
index e827876..9379fb6 100644
--- a/JobTrackerApi/Controllers/ProfileCvController.cs
+++ b/JobTrackerApi/Controllers/ProfileCvController.cs
@@ -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 _users;
diff --git a/JobTrackerApi/Models/CvTheme.cs b/JobTrackerApi/Models/CvTheme.cs
index b6c48bd..154b272 100644
--- a/JobTrackerApi/Models/CvTheme.cs
+++ b/JobTrackerApi/Models/CvTheme.cs
@@ -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 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 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 Themes = new List
{
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)
diff --git a/JobTrackerApi/Models/CvVariantSettings.cs b/JobTrackerApi/Models/CvVariantSettings.cs
index 98a1e93..9b30519 100644
--- a/JobTrackerApi/Models/CvVariantSettings.cs
+++ b/JobTrackerApi/Models/CvVariantSettings.cs
@@ -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? SkillGroups { get; set; }
public string? Layout { get; set; } // single | sidebar-left | sidebar-right | header-band
public double? SidebarWidthMm { get; set; }
public List? 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? 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? Items { get; set; }
}
public sealed class CvItemOverride
@@ -78,6 +82,14 @@ public sealed class CvCustomSectionSetting
public string? Title { get; set; }
public List 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 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;
}
diff --git a/JobTrackerApi/Models/HumanLanguageCatalog.cs b/JobTrackerApi/Models/HumanLanguageCatalog.cs
index ab913d3..c62673d 100644
--- a/JobTrackerApi/Models/HumanLanguageCatalog.cs
+++ b/JobTrackerApi/Models/HumanLanguageCatalog.cs
@@ -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");
diff --git a/JobTrackerApi/Services/CvRenderModel.cs b/JobTrackerApi/Services/CvRenderModel.cs
index d479ed5..4d4f716 100644
--- a/JobTrackerApi/Services/CvRenderModel.cs
+++ b/JobTrackerApi/Services/CvRenderModel.cs
@@ -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 Bullets { get; set; } = new();
public List Tags { get; set; } = new();
public List Entries { get; set; } = new();
+ public List 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 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;
}
diff --git a/JobTrackerApi/Services/ThemedCvRenderer.cs b/JobTrackerApi/Services/ThemedCvRenderer.cs
index e5e29a1..4ca6bc2 100644
--- a/JobTrackerApi/Services/ThemedCvRenderer.cs
+++ b/JobTrackerApi/Services/ThemedCvRenderer.cs
@@ -133,11 +133,21 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
var inner = section.Kind switch
{
"bullets" => $@"",
+ "paragraphs" => $@"{string.Join("", section.Bullets.Select(item => $@"
{Inline(item)}
"))}
",
+ "skill-groups" => $@"{string.Join("", section.SkillGroups.Select(RenderSkillGroup))}
",
+ "tags" when section.Key == "skills" && settings?.SkillsStyle == "bullets" => $@"",
"tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"{string.Join(" · ", section.Tags.Select(Enc))}
",
"tags" => $@"{string.Join("", section.Tags.Select(t => $@"- {Enc(t)}
"))}
",
_ => string.Join("", section.Entries.Select(RenderEntry)),
};
- return $@"{Enc(section.Title)}
{inner}";
+ var flowClass = IsFlowingSection(section) ? " section-flow" : string.Empty;
+ return $@"{Enc(section.Title)}
{inner}";
+ }
+
+ private static string RenderSkillGroup(CvRenderSkillGroup group)
+ {
+ var name = string.IsNullOrWhiteSpace(group.Name) ? string.Empty : $@"{Enc(group.Name)}
";
+ return $@"{name}
{string.Join("", group.Items.Select(item => $@"- {Enc(item)}
"))}
";
}
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;}}
diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py
index 1fb9977..62cb8a6 100644
--- a/tools/summarizer/app.py
+++ b/tools/summarizer/app.py
@@ -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))
diff --git a/tools/summarizer/tests/test_app.py b/tools/summarizer/tests/test_app.py
index ef33dec..4aace88 100644
--- a/tools/summarizer/tests/test_app.py
+++ b/tools/summarizer/tests/test_app.py
@@ -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"