From 9136bb5791a7f5937e8372c825783640c046af78 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 17 Aug 2026 17:19:42 +0100 Subject: [PATCH] feat(templates): route CV sections by what they mean, not by what they are called MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preset with a designed layout places sections into fixed slots, and it chose what went where by matching the section's heading against a list of English words each preset kept privately — then guarded the slot on the section's Java type as well. A CV headed Ausbildung, Опыт работы, or anything else in the author's own language matched nothing: the section was dropped and the slot that wanted it rendered empty. Nothing failed. The CV came out looking finished, one job short. A module already knows the answer, because its author said so. SectionRouter asks the role first and falls back to the headings for the sections that carry none — every hand-written one, and any module left as OTHER — so a document of hand-written sections routes exactly as before. A heading may not overrule a role: a module declared EXPERIENCE and headed "Projects" goes where its author put it, and the projects slot does not also claim it, which would have rendered it twice. An empty module does not shadow a section that has content. The second half is the shape. These slots are written against one section type because each draws its content its own way, so a module reaching one was discarded by the guard however well it was routed. The router hands each slot the section lowered to the type it expects, and the preset then draws it with the entry style, rules and spacing that make it that preset. What the lowering costs is stated per finder and pinned by tests: a description is joined where the target holds one string, bulleted points join with a comma and prose with a space, a linked title survives only the row style that bolds without markdown markers, and a skill with nothing under it stays a skill rather than becoming a category holding itself. SectionAllocation.claim gained the same role-first overload for the preset that allocates rather than looks up, and TimelineMinimal learned to flatten a module the way it flattens everything else — one line per item, because those lines are what its column pagination measures. SidebarPortrait's languages slot sniffs its rows for something language-shaped, because it also accepts an Additional Information section and has to pick them out. A section routed there by role is entirely languages and needs to look like nothing, so when the sniff finds none it now takes them all; otherwise role routing would have replaced a silent drop with a heading over blank space. These presets still drop a section they have no slot for. Their whole body is one atomic row that cannot break across pages, so there is nowhere to put it — that half waits on pagination, not routing. --- CHANGELOG.md | 22 ++ docs/templates/v2-layered/using-templates.md | 31 ++- .../cv/components/RoleRoutingTest.java | 185 +++++++++++++ .../cv/components/SectionAllocationTest.java | 59 ++++ .../cv/components/SectionAllocation.java | 53 ++++ .../cv/components/SectionRouter.java | 258 ++++++++++++++++++ .../templates/cv/presets/BlueBanner.java | 10 +- .../templates/cv/presets/ClassicSerif.java | 12 +- .../templates/cv/presets/CompactMono.java | 12 +- .../cv/presets/EngineeringResume.java | 18 +- .../templates/cv/presets/MintEditorial.java | 15 +- .../templates/cv/presets/MonogramSidebar.java | 17 +- .../templates/cv/presets/NordicClean.java | 12 +- .../document/templates/cv/presets/Panel.java | 12 +- .../templates/cv/presets/SidebarPortrait.java | 34 ++- .../templates/cv/presets/TimelineMinimal.java | 35 ++- .../cv/components/SectionRouterTest.java | 222 +++++++++++++++ 17 files changed, 925 insertions(+), 82 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java create mode 100644 templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b13eed6de..68c03e828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,28 @@ follow semantic versioning; release dates are ISO 8601. trusting it, so a preset that ships without being registered fails the build instead of being invisible to every caller that looks a template up by id. +- **Presets route by what a section means, not by the language it is written in.** A + preset with a designed layout places sections into fixed slots, and it chose what went + where by matching the heading against a list of English words each preset kept + privately — then guarded the slot on the section's Java type as well. A CV headed + `Ausbildung`, `Опыт работы`, or anything else in the author's own language matched + nothing: the section was dropped and the slot that wanted it rendered empty. Nothing + failed; the CV came out looking finished, one job short. + + `SectionRouter` asks the module's `SectionRole` first and falls back to the headings + for the sections that carry no role — every hand-written one, and any module left as + `OTHER` — so a document of hand-written sections routes exactly as it did. A heading + may not overrule a role: a module declared `EXPERIENCE` and headed "Projects" goes where its + author put it, and the projects slot does not also claim it, which would have rendered + it twice. The router also hands each slot the section in the shape that slot draws, so + a module reaching a slot written against `EntriesSection` is no longer discarded by + the guard — the preset draws it exactly as it draws everything else, with the entry + style, rules and spacing that make it that preset. `SectionAllocation.claim` gained + the same role-first overload for the preset that allocates rather than looks up. + + Nine presets and every slot they compose changed; a CV written in Russian and German + now renders on all sixteen, which `RoleRoutingTest` holds by rendering one. + - **A preset can draw runtime modules in its own style.** `CvRenderKit` is the three shapes a section body reduces to — a paragraph, a label/value row, a timeline entry — and a template hands back the kit it draws them with. The lowering from `CvItem` diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index c997f6c3b..44a59ba44 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -200,13 +200,21 @@ on the kind alone — which is what lets a "Volunteering" module be shaped exactly like Education without a new type. `SectionRole` says what a section *means*, separately from how it -draws. Multi-column presets decide what belongs in a sidebar by -matching headings against English keywords, which a CV headed -`Ausbildung` or `Навыки` never matches. The role is where that -decision belongs — stated by the author, who knows the answer. **The -presets do not read it yet**: today it travels with the section and is -the input the routing work will consume, so a document built now needs -no rewrite when they do. +draws — and it is the first thing a preset routes on. A preset with a designed +layout places sections into fixed slots, and it used to choose what +went where by matching the heading against a list of English words: +a CV headed `Ausbildung` or `Навыки` matched nothing, so the section +was dropped and the slot that wanted it rendered empty. Give the module +a role and it lands in the right slot whatever language the CV is +written in, and whatever kind you chose to draw it with — for the roles +that preset has a slot for. `SectionRole.OTHER` names no slot, so a +module carrying it routes by heading like any other section. + +A heading that matches a keyword still routes a section that has no +role — every hand-written section, and any module you left as +`SectionRole.OTHER`. What a heading may not do is overrule a role: a +module declared `EXPERIENCE` and headed "Projects" goes where you put +it, and the projects slot does not also claim it. Modules and the four fixed types mix freely in one document, and both render through the same components — a module drawn as `ENTRIES_DATED` @@ -241,6 +249,15 @@ The promise covers `Slot.MAIN`, which is where sections go unless you say otherwise. Every shipped preset composes a single main column, so a section placed in `Slot.SIDEBAR` is dropped — by these templates as by every other. +The presets outside that list are not broken, they are *designed*: each +composes a fixed set of slots, so it renders the roles it has a place for +and drops a section it has no slot for. Give such a preset a CV whose +sections map onto roles and it renders them all; give it an extra +"Volunteering" module and that one is lost. The reason is structural — the +whole body of those presets is one atomic block that cannot break across +pages, so there is nowhere to put an extra section — and lifting it needs +pagination work, not routing. + A template also says *how* it draws through `CvRenderKit`. The shared lowering turns a module into paragraphs, rows, and entries; the kit draws them, so a preset with its own entry style renders your runtime module in diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java new file mode 100644 index 000000000..a5a92edcb --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java @@ -0,0 +1,185 @@ +package com.demcha.compose.document.templates.cv.components; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.presets.CvTemplates; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A CV whose headings are in the author's own language reaches the page on + * every preset. + * + *

Presets with a designed layout place sections into fixed slots, and they + * chose what goes where by matching the heading against a list of English + * words each kept privately. A CV headed {@code Berufserfahrung} or + * {@code Опыт работы} matched nothing: the section was dropped and the slot + * that wanted it rendered empty. Nothing failed — the CV came out looking + * finished, one job short.

+ * + *

A module states its {@link SectionRole}, so the routing has an answer + * that does not depend on the language the CV is written in. Every heading + * here is deliberately in Russian and German: if any preset still routes by + * keyword, its slot stays empty and this goes red.

+ */ +class RoleRoutingTest { + + private static Stream>> everyPreset() { + return CvTemplates.all().stream().map(t -> Named.of(t.id(), t)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("everyPreset") + void aCvWrittenInAnotherLanguageRendersOnEveryPreset(DocumentTemplate preset) { + String text = composedText(preset, foreignLanguageCv()); + + assertRendered(text, "Ведущий инженер", preset, "experience"); + assertRendered(text, "Информатика", preset, "education"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("everyPreset") + void aRoleRoutedModuleRendersWhateverItsKind(DocumentTemplate preset) { + // The slots were guarded on the section's Java type as well as its + // heading, so a module routed correctly was dropped anyway. Kinds here + // are deliberately the "wrong" shape for the slot each role names — + // experience as bullets, education as an inline list — because the + // author picks the kind and the preset does not get a veto. + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(ModuleSection.builder("Berufserfahrung", SectionRole.EXPERIENCE, + CvKind.BULLETS) + .item(CvItem.of("Senior Engineer").paragraphs("Acme GmbH, 2021-2025")) + .build()) + .section(ModuleSection.builder("Kenntnisse", SectionRole.SKILLS, + CvKind.INLINE_LIST) + .item(CvItem.of("Sprachen").paragraphs("Java 21", "Kotlin")) + .build()) + .build(); + + String text = composedText(preset, doc); + + assertRendered(text, "Senior Engineer", preset, "EXPERIENCE"); + assertRendered(text, "Java 21", preset, "SKILLS"); + } + + @Test + void theRoleWinsOverAHeadingThatMatchesADifferentSlot() { + // A module titled "Projects" but declared EXPERIENCE belongs where its + // author said, not where its heading reads. + List sections = List.of( + ModuleSection.builder("Projects", SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) + .item(CvItem.of("Senior Engineer").period("2021")).build()); + + assertThat(SectionRouter.find(sections, SectionRole.EXPERIENCE, List.of("experience"))) + .as("the role names the slot") + .isNotNull(); + assertThat(SectionRouter.find(sections, SectionRole.PROJECTS, List.of("projects"))) + .as("...and the heading no longer claims a slot the role did not name") + .isNull(); + } + + @Test + void aSectionWithoutARoleStillRoutesByItsHeading() { + // The four hand-written section types carry no role, and neither does a + // module the catalogue has no name for. Keywords remain the answer for + // them, so nothing that worked before stops working. + List sections = List.of( + new com.demcha.compose.document.templates.cv.data.ParagraphSection( + "Professional Summary", "Backend engineer."), + ModuleSection.builder("Awards", SectionRole.OTHER, CvKind.BULLETS) + .item("Employee of the year").build()); + + assertThat(SectionRouter.find(sections, SectionRole.SUMMARY, List.of("summary"))) + .as("a hand-written section still matches by heading") + .isNotNull(); + assertThat(SectionRouter.find(sections, SectionRole.OTHER, List.of("awards"))) + .as("SectionRole.OTHER claims no slot and falls through to the heading") + .isNotNull(); + } + + /** + * Asserts the words reached the page, ignoring how the preset set them: + * several upper-case entry titles and several letter-space them, so + * "Senior Engineer" arrives as "S E N I O R E N G I N E E R". Typography + * is the preset's to choose; the words are the author's to keep. + */ + private static void assertRendered(String text, String words, + DocumentTemplate preset, String slot) { + assertThat(text.replace(" ", "")) + .as("%s must render the %s module routed by role", preset.id(), slot) + .containsIgnoringCase(words.replace(" ", "")); + } + + // -- fixtures -------------------------------------------------------- + + private static CvDocument foreignLanguageCv() { + return CvDocument.builder() + .identity(identity()) + .section(ModuleSection.builder("О себе", SectionRole.SUMMARY, CvKind.PARAGRAPH) + .item(CvItem.of("summary").paragraphs("Backend engineer.")) + .build()) + .section(ModuleSection.builder("Опыт работы", SectionRole.EXPERIENCE, + CvKind.ENTRIES_DATED) + .item(CvItem.of("Ведущий инженер").at("Acme GmbH") + .period("2021 - 2025").paragraphs("Payments.")) + .build()) + .section(ModuleSection.builder("Образование", SectionRole.EDUCATION, + CvKind.ENTRIES_DATED) + .item(CvItem.of("Информатика").at("МГУ").period("2014 - 2018")) + .build()) + .build(); + } + + private static CvIdentity identity() { + return CvIdentity.builder() + .name("Jordan", "Rivera") + .jobTitle("Backend Engineer") + .contact("+1 555 0100", "jordan@example.com", "Berlin, DE") + .build(); + } + + /** + * Every string the composed layout carries. Read from the layout rather + * than the PDF text layer: the CV themes draw with the standard-14 + * Helvetica, which has no Cyrillic glyphs, and where the section was + * placed is what routing owes — the font is the caller's choice. + */ + private static String composedText(DocumentTemplate preset, CvDocument doc) { + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(24, 24, 24, 24) + .create()) { + preset.compose(session, doc); + StringBuilder text = new StringBuilder(); + collectText(session.roots(), text); + return text.toString(); + } + } + + private static void collectText(List nodes, StringBuilder out) { + for (DocumentNode node : nodes) { + if (node instanceof ParagraphNode paragraph) { + out.append(paragraph.text()).append(' '); + } + collectText(node.children(), out); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java index fb0a999d8..7f7ab0f36 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java @@ -2,6 +2,10 @@ import com.demcha.compose.document.templates.cv.data.CvSection; import com.demcha.compose.document.templates.cv.data.EntriesSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; import com.demcha.compose.document.templates.cv.data.ParagraphSection; import com.demcha.compose.document.templates.cv.data.RowStyle; import com.demcha.compose.document.templates.cv.data.RowsSection; @@ -130,4 +134,59 @@ void aMissingFallbackLabelIsARejectedArgument() { .isInstanceOf(NullPointerException.class) .hasMessageContaining("fallback"); } + + @Test + void aRoleClaimTakesTheModuleThatNamedTheRole() { + ModuleSection experience = ModuleSection.builder("Опыт работы", + SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) + .item(CvItem.of("Ведущий инженер").period("2021")) + .build(); + SectionAllocation allocation = SectionAllocation.of(List.of(SUMMARY, experience)); + + assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))) + .as("the heading matches no English keyword; the role is the answer") + .isSameAs(experience); + assertThat(allocation.remaining()) + .as("a role-claimed section is claimed, so it is not also a leftover") + .doesNotContain(experience); + } + + @Test + void aRoleClaimFallsBackToTheHeadingForSectionsWithoutARole() { + SectionAllocation allocation = SectionAllocation.of(List.of(SUMMARY)); + + assertThat(allocation.claim(SectionRole.SUMMARY, List.of("summary"))) + .as("hand-written sections carry no role and still route by heading") + .isSameAs(SUMMARY); + } + + @Test + void aDeclaredRoleIsNotClaimableByAnotherSlotsKeywords() { + // Otherwise the experience slot takes it by role and the projects slot + // takes it by heading, and the same module renders twice. + ModuleSection module = ModuleSection.builder("Projects", SectionRole.EXPERIENCE, + CvKind.ENTRIES_DATED) + .item(CvItem.of("Senior Engineer").period("2021")) + .build(); + SectionAllocation allocation = SectionAllocation.of(List.of(module)); + + assertThat(allocation.claim(SectionRole.PROJECTS, List.of("projects"))).isNull(); + assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))) + .isSameAs(module); + } + + @Test + void aRoleClaimsAtMostOneSectionSoASecondSlotSeesTheNextOne() { + ModuleSection first = ModuleSection.builder("Erfahrung", SectionRole.EXPERIENCE, + CvKind.ENTRIES_DATED).item(CvItem.of("First").period("2021")).build(); + ModuleSection second = ModuleSection.builder("Weitere Erfahrung", + SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) + .item(CvItem.of("Second").period("2019")).build(); + SectionAllocation allocation = SectionAllocation.of(List.of(first, second)); + + assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))).isSameAs(first); + assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))) + .as("claiming hands each section out once") + .isSameAs(second); + } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java index 560c5b94d..c79045b57 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java @@ -1,6 +1,8 @@ package com.demcha.compose.document.templates.cv.components; import com.demcha.compose.document.templates.cv.data.CvSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; import java.util.ArrayList; import java.util.IdentityHashMap; @@ -58,6 +60,53 @@ public static SectionAllocation of(List sections) { return new SectionAllocation(List.copyOf(copy)); } + /** + * Claims the section this slot means, preferring a module that named the + * role over one whose heading happens to match. + * + *

Headings are the fallback because a section that carries no role — + * every hand-written one — has nothing else to be found by. A module that + * did name a role is never claimed by a different slot's + * keywords: it would then render in two places, which is a worse failure + * than the one role routing exists to fix.

+ * + * @param role the role this slot holds; {@code null} or + * {@link SectionRole#OTHER} means "keywords only" + * @param keys candidate heading fragments + * @return the claimed section, or {@code null} when nothing matches + * @since 2.3.0 + */ + public CvSection claim(SectionRole role, List keys) { + if (role != null && role != SectionRole.OTHER) { + for (CvSection section : sections) { + if (claimed.containsKey(section)) { + continue; + } + if (section instanceof ModuleSection module && module.role() == role) { + claimed.put(section, Boolean.TRUE); + return section; + } + } + } + for (CvSection section : sections) { + if (claimed.containsKey(section)) { + continue; + } + if (section instanceof ModuleSection module + && module.role() != SectionRole.OTHER) { + continue; + } + String title = SectionLookup.normalize(section.title()); + for (String key : keys == null ? List.of() : keys) { + if (title.contains(SectionLookup.normalize(key))) { + claimed.put(section, Boolean.TRUE); + return section; + } + } + } + return null; + } + /** * Claims the first not-yet-claimed section whose normalised title contains * any of the keys. @@ -66,6 +115,10 @@ public static SectionAllocation of(List sections) { * different section instead of the same one twice, and what moves * the section out of {@link #remaining()}.

* + *

Heading-only. A slot that knows which {@link SectionRole} it holds + * should call {@link #claim(SectionRole, List)}, so a CV written in another + * language routes on what its sections mean.

+ * * @param keys candidate title fragments; {@code null} claims nothing * @return the claimed section, or {@code null} when nothing matches */ diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java new file mode 100644 index 000000000..b87f3ad16 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java @@ -0,0 +1,258 @@ +package com.demcha.compose.document.templates.cv.components; + +import com.demcha.compose.document.templates.core.identity.Link; +import com.demcha.compose.document.templates.cv.data.BodyStyle; +import com.demcha.compose.document.templates.cv.data.CvEntry; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.CvRow; +import com.demcha.compose.document.templates.cv.data.CvSection; +import com.demcha.compose.document.templates.cv.data.EntriesSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.data.RowStyle; +import com.demcha.compose.document.templates.cv.data.RowsSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.data.SkillGroup; +import com.demcha.compose.document.templates.cv.data.SkillsSection; + +import java.util.ArrayList; +import java.util.List; + +/** + * Finds the section a preset's slot should hold, by what it means rather + * than by what it is called — and hands it back in the shape that slot + * knows how to draw. + * + *

Presets with a designed layout place their sections into fixed slots, + * and they have been choosing what goes where by matching the section's + * heading against a list of English words each preset keeps privately. That + * works for a CV written in English by someone who used the expected + * headings. A CV headed {@code Ausbildung} or {@code Навыки} matches + * nothing and loses the section; so does {@code "Volunteering"}, and so does + * a second section whose heading matched a word the first one took.

+ * + *

A {@link ModuleSection} carries a {@link SectionRole} because the + * author already knew the answer, so the role is asked first and the + * keywords are the fallback for the sections that have no role to give.

+ * + *

The second half is the shape. These slots are written against a + * particular section type — {@code if (!(section instanceof EntriesSection + * entries)) return;} — because each draws its content its own way, and a + * module reaching one would be dropped by that guard however well it was + * routed. Each finder below therefore lowers a matched module to the type + * its slot expects, so the preset draws it exactly as it draws everything + * else. What that costs is stated per method: a module's description lines + * are joined where the target type holds one string, and a bulleted + * description reads as prose.

+ * + *

These presets still drop a section that matches no slot at all — their + * whole body is one atomic row that cannot paginate, so there is nowhere to + * put it. Routing by role fixes the sections that were lost while a slot + * for them sat empty; the rest waits on pagination.

+ * + * @since 2.3.0 + */ +public final class SectionRouter { + + private SectionRouter() { + } + + /** + * The section for a timeline slot — education, experience, anything the + * preset draws as dated entries. + * + *

A matched module becomes an {@link EntriesSection}: each item's + * title, its subtitle and location joined, its period (blank when the + * module's kind does not read one), and its description lines joined + * into the single body string a {@link CvEntry} holds. A description the + * author asked to bullet reads as prose here — the slot draws one + * paragraph.

+ * + * @param sections the document's sections for this slot's column + * @param role the role this slot holds + * @param keys heading fragments to fall back on + * @return an {@code EntriesSection}, or {@code null} when nothing matches + */ + public static CvSection entries(List sections, SectionRole role, + List keys) { + CvSection found = find(sections, role, keys); + if (!(found instanceof ModuleSection module)) { + return found; + } + List entries = new ArrayList<>(module.items().size()); + boolean dated = module.kind() == CvKind.ENTRIES_DATED; + for (CvItem item : module.items()) { + entries.add(new CvEntry(title(item), subtitleWithLocation(item), + dated ? item.period() : "", String.join(" ", item.body()))); + } + return new EntriesSection(module.title(), entries); + } + + /** + * The section for a label/value slot — projects, languages, additional + * information. + * + *

A matched module becomes a {@link RowsSection} in the caller's + * {@link RowStyle}: one row per item, its title the label and its + * description lines joined into the body.

+ * + * @param sections the document's sections for this slot's column + * @param role the role this slot holds + * @param keys heading fragments to fall back on + * @param style the decoration this slot draws rows with + * @return a {@code RowsSection}, or {@code null} when nothing matches + */ + public static CvSection rows(List sections, SectionRole role, + List keys, RowStyle style) { + CvSection found = find(sections, role, keys); + if (!(found instanceof ModuleSection module)) { + return found; + } + List rows = new ArrayList<>(module.items().size()); + for (CvItem item : module.items()) { + // Discrete points are joined with a comma and prose with a space: + // a row holds one string, and "Rebuilt the ledger Cut p99 40%" reads + // as one garbled sentence while "Shipped it., Measured it." puts a + // comma after a full stop. + String separator = item.bodyStyle() == BodyStyle.BULLETS ? ", " : " "; + // Only the stacked style keeps a linked title: the inline ones bold + // their label by wrapping it in markdown markers, which would nest + // around the link and reach the page as literal asterisks. + String label = style == RowStyle.BULLETED_STACKED ? title(item) : item.title(); + rows.add(new CvRow(label, String.join(separator, item.body()))); + } + return new RowsSection(module.title(), rows, style); + } + + /** + * The section for a prose slot — a profile, a summary, an objective. + * + *

A matched module becomes a {@link ParagraphSection} whose body is + * every item's description, joined. The slot holds one block of prose, + * so a module with several items reads as one.

+ * + * @param sections the document's sections for this slot's column + * @param role the role this slot holds + * @param keys heading fragments to fall back on + * @return a {@code ParagraphSection}, or {@code null} when nothing matches + */ + public static CvSection paragraph(List sections, SectionRole role, + List keys) { + CvSection found = find(sections, role, keys); + if (!(found instanceof ModuleSection module)) { + return found; + } + List lines = new ArrayList<>(); + for (CvItem item : module.items()) { + lines.addAll(item.body()); + } + return new ParagraphSection(module.title(), String.join(" ", lines)); + } + + /** + * The section for a skills slot — the one preset slot that wants + * categories rather than lines, because it may draw a table, chips, or + * proficiency bars. + * + *

A matched module becomes a {@link SkillsSection}: an item with a + * description is a category whose skills are its lines, and the items + * with none are collected into one group under the module's own heading — + * a plain list of skills is a list of skills, not a set of categories + * each holding itself.

+ * + * @param sections the document's sections for this slot's column + * @param role the role this slot holds + * @param keys heading fragments to fall back on + * @return a {@code SkillsSection}, or {@code null} when nothing matches + */ + public static CvSection skills(List sections, SectionRole role, + List keys) { + CvSection found = find(sections, role, keys); + if (!(found instanceof ModuleSection module)) { + return found; + } + List groups = new ArrayList<>(module.items().size()); + List loose = new ArrayList<>(); + for (CvItem item : module.items()) { + if (item.body().isEmpty()) { + // A skill with nothing under it is a skill, not a category of + // one: making it its own group prints "Java 21: Java 21". + loose.add(item.title()); + continue; + } + groups.add(SkillGroup.ofNames(item.title(), item.body())); + } + if (!loose.isEmpty()) { + groups.add(SkillGroup.ofNames(module.title(), loose)); + } + return new SkillsSection(module.title(), groups); + } + + /** + * The section this slot should hold, or {@code null} when the document + * has none: the first module whose role is the slot's, else the first + * section whose heading matches one of the keys. + * + *

Role first, and only a role the author actually chose — + * {@link SectionRole#OTHER} is what a module carries when the catalogue + * has no name for it, so it never claims a slot and falls through to the + * headings like any other section.

+ * + * @param sections the document's sections for this slot's column + * @param role the role this slot holds + * @param keys heading fragments to fall back on + * @return the section, or {@code null} when nothing matches + */ + public static CvSection find(List sections, SectionRole role, + List keys) { + if (sections == null) { + return null; + } + if (role != null && role != SectionRole.OTHER) { + for (CvSection section : sections) { + if (section instanceof ModuleSection module && module.role() == role + && SectionLookup.hasContent(section)) { + return section; + } + } + } + // The heading is the fallback, and it may not overrule a role. A module + // declared EXPERIENCE and headed "Projects" belongs where its author put + // it; letting the projects slot claim it by heading would render it in + // both places, which is worse than the drop this routing exists to fix. + return SectionLookup.firstMatching(spokenFor(sections), keys); + } + + /** The sections a keyword slot may still claim: everything but a module that named its own role. */ + private static List spokenFor(List sections) { + List open = new ArrayList<>(sections.size()); + for (CvSection section : sections) { + if (section instanceof ModuleSection module && module.role() != SectionRole.OTHER) { + continue; + } + open.add(section); + } + return open; + } + + /** The title, as markdown link syntax when the item carries a link. */ + private static String title(CvItem item) { + Link link = item.link(); + if (link == null || item.title().indexOf('[') >= 0 || item.title().indexOf(']') >= 0) { + return item.title(); + } + return "[" + item.title() + "](" + link.url() + ")"; + } + + /** Subtitle and location joined, or whichever exists, or blank. */ + private static String subtitleWithLocation(CvItem item) { + if (item.subtitle().isBlank()) { + return item.location(); + } + if (item.location().isBlank()) { + return item.subtitle(); + } + return item.subtitle() + " · " + item.location(); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java index 37287a37c..69e7e90c5 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java @@ -250,11 +250,11 @@ private static void renderEntry(SectionBuilder section, private static List orderedSections(CvDocument doc) { List sections = doc.sectionsIn(Slot.MAIN); List ordered = new ArrayList<>(); - addIfPresent(ordered, SectionLookup.firstMatching(sections, SUMMARY_KEYS)); - addIfPresent(ordered, SectionLookup.firstMatching(sections, EXPERIENCE_KEYS)); - addIfPresent(ordered, SectionLookup.firstMatching(sections, EDUCATION_KEYS)); - addIfPresent(ordered, SectionLookup.firstMatching(sections, SKILL_KEYS)); - addIfPresent(ordered, SectionLookup.firstMatching(sections, ADDITIONAL_KEYS)); + addIfPresent(ordered, SectionRouter.find(sections, SectionRole.SUMMARY, SUMMARY_KEYS)); + addIfPresent(ordered, SectionRouter.find(sections, SectionRole.EXPERIENCE, EXPERIENCE_KEYS)); + addIfPresent(ordered, SectionRouter.find(sections, SectionRole.EDUCATION, EDUCATION_KEYS)); + addIfPresent(ordered, SectionRouter.find(sections, SectionRole.SKILLS, SKILL_KEYS)); + addIfPresent(ordered, SectionRouter.find(sections, SectionRole.OTHER, ADDITIONAL_KEYS)); for (CvSection section : sections) { addIfPresent(ordered, section); } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java index ff0e4c52b..22f93098c 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java @@ -118,16 +118,16 @@ public void compose(DocumentSession document, CvDocument doc) { .spacing(theme.spacing().pageFlowSpacing()); addHeader(flow, doc, width); - addSummary(flow, SectionLookup.firstMatching(sections, SUMMARY_KEYS)); - addCoverSkillsModule(flow, SectionLookup.firstMatching(sections, SKILL_KEYS)); + addSummary(flow, SectionRouter.paragraph(sections, SectionRole.SUMMARY, SUMMARY_KEYS)); + addCoverSkillsModule(flow, SectionRouter.skills(sections, SectionRole.SKILLS, SKILL_KEYS)); addLinearModule(flow, "Experience", - SectionLookup.firstMatching(sections, EXPERIENCE_KEYS)); + SectionRouter.find(sections, SectionRole.EXPERIENCE, EXPERIENCE_KEYS)); addLinearModule(flow, "Projects", - SectionLookup.firstMatching(sections, PROJECT_KEYS)); + SectionRouter.find(sections, SectionRole.PROJECTS, PROJECT_KEYS)); addLinearModule(flow, "Education", - SectionLookup.firstMatching(sections, EDUCATION_KEYS)); + SectionRouter.find(sections, SectionRole.EDUCATION, EDUCATION_KEYS)); addLinearModule(flow, "Additional", - SectionLookup.firstMatching(sections, ADDITIONAL_KEYS)); + SectionRouter.find(sections, SectionRole.OTHER, ADDITIONAL_KEYS)); flow.build(); } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CompactMono.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CompactMono.java index 506a112cc..00557f1dd 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CompactMono.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CompactMono.java @@ -168,16 +168,16 @@ private void addRail(SectionBuilder rail, List sections) { .padding(new DocumentInsets(11, 11, 13, 11)) .fillColor(theme.palette().banner()) .accentLeft(ACCENT, 3.0); - addRailSkills(rail, SectionLookup.firstMatching(sections, SKILL_KEYS)); - addRailEducation(rail, SectionLookup.firstMatching(sections, EDUCATION_KEYS)); - addRailAdditional(rail, SectionLookup.firstMatching(sections, ADDITIONAL_KEYS)); + addRailSkills(rail, SectionRouter.skills(sections, SectionRole.SKILLS, SKILL_KEYS)); + addRailEducation(rail, SectionRouter.entries(sections, SectionRole.EDUCATION, EDUCATION_KEYS)); + addRailAdditional(rail, SectionRouter.rows(sections, SectionRole.OTHER, ADDITIONAL_KEYS, RowStyle.PLAIN)); } private void addMain(SectionBuilder main, List sections) { main.spacing(8); - addProfile(main, SectionLookup.firstMatching(sections, SUMMARY_KEYS)); - addExperience(main, SectionLookup.firstMatching(sections, EXPERIENCE_KEYS)); - addProjects(main, SectionLookup.firstMatching(sections, PROJECT_KEYS)); + addProfile(main, SectionRouter.paragraph(sections, SectionRole.SUMMARY, SUMMARY_KEYS)); + addExperience(main, SectionRouter.entries(sections, SectionRole.EXPERIENCE, EXPERIENCE_KEYS)); + addProjects(main, SectionRouter.rows(sections, SectionRole.PROJECTS, PROJECT_KEYS, RowStyle.BULLETED_STACKED)); } private void addRailSkills(SectionBuilder parent, CvSection section) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java index 40a190c87..f3ff748ee 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java @@ -12,6 +12,7 @@ import com.demcha.compose.document.templates.core.text.TextStyles; import com.demcha.compose.document.templates.core.text.MarkdownInline; import com.demcha.compose.document.templates.cv.components.ProjectLabel; +import com.demcha.compose.document.templates.cv.components.SectionRouter; import com.demcha.compose.document.templates.cv.components.SectionLookup; import com.demcha.compose.document.templates.cv.data.*; import com.demcha.compose.document.templates.core.theme.BrandTheme; @@ -272,17 +273,12 @@ private static String headerSubtitleText(CvIdentity identity) { // -- Body 2-column ------------------------------------------------- private void addBody(PageFlowBuilder flow, List sections) { - CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS); - CvSection education = SectionLookup.firstMatching(sections, - EDUCATION_KEYS); - CvSection additional = SectionLookup.firstMatching(sections, - ADDITIONAL_KEYS); - CvSection summary = SectionLookup.firstMatching(sections, - SUMMARY_KEYS); - CvSection experience = SectionLookup.firstMatching(sections, - EXPERIENCE_KEYS); - CvSection projects = SectionLookup.firstMatching(sections, - PROJECT_KEYS); + CvSection skills = SectionRouter.skills(sections, SectionRole.SKILLS, SKILL_KEYS); + CvSection education = SectionRouter.entries(sections, SectionRole.EDUCATION, EDUCATION_KEYS); + CvSection additional = SectionRouter.rows(sections, SectionRole.OTHER, ADDITIONAL_KEYS, RowStyle.PLAIN); + CvSection summary = SectionRouter.paragraph(sections, SectionRole.SUMMARY, SUMMARY_KEYS); + CvSection experience = SectionRouter.entries(sections, SectionRole.EXPERIENCE, EXPERIENCE_KEYS); + CvSection projects = SectionRouter.rows(sections, SectionRole.PROJECTS, PROJECT_KEYS, RowStyle.BULLETED_STACKED); flow.addRow("CvV2EngineeringResumeBody", row -> row .spacing(14) diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java index 261bf077b..072e4ae77 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java @@ -18,6 +18,7 @@ import com.demcha.compose.document.templates.api.DocumentTemplate; import com.demcha.compose.document.templates.core.text.TextStyles; import com.demcha.compose.document.templates.core.text.MarkdownInline; +import com.demcha.compose.document.templates.cv.components.SectionRouter; import com.demcha.compose.document.templates.cv.components.SectionLookup; import com.demcha.compose.document.templates.core.text.TextOrnaments; import com.demcha.compose.document.templates.cv.data.*; @@ -487,13 +488,13 @@ public void compose(DocumentSession document, CvDocument doc) { List sections = doc.sectionsIn(Slot.MAIN); CvIdentity identity = doc.identity(); - CvSection interests = SectionLookup.firstMatching(sections, INTERESTS_KEYS); - CvSection education = SectionLookup.firstMatching(sections, EDUCATION_KEYS); - CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS); - CvSection profile = SectionLookup.firstMatching(sections, SUMMARY_KEYS); - CvSection experience = SectionLookup.firstMatching(sections, EXPERIENCE_KEYS); - CvSection awards = SectionLookup.firstMatching(sections, AWARDS_KEYS); - CvSection references = SectionLookup.firstMatching(sections, REFERENCES_KEYS); + CvSection interests = SectionRouter.rows(sections, SectionRole.OTHER, INTERESTS_KEYS, RowStyle.PLAIN); + CvSection education = SectionRouter.entries(sections, SectionRole.EDUCATION, EDUCATION_KEYS); + CvSection skills = SectionRouter.skills(sections, SectionRole.SKILLS, SKILL_KEYS); + CvSection profile = SectionRouter.paragraph(sections, SectionRole.SUMMARY, SUMMARY_KEYS); + CvSection experience = SectionRouter.entries(sections, SectionRole.EXPERIENCE, EXPERIENCE_KEYS); + CvSection awards = SectionRouter.rows(sections, SectionRole.OTHER, AWARDS_KEYS, RowStyle.PLAIN); + CvSection references = SectionRouter.rows(sections, SectionRole.OTHER, REFERENCES_KEYS, RowStyle.PLAIN); List experienceEntries = entriesOf(experience); List experiencePage1 = experienceEntries.stream() diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java index 09acff8d1..51bf0a071 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java @@ -376,14 +376,13 @@ private void addSidebar(SectionBuilder section, CvDocument doc, addSidebarHeader(section, "CONTACT", innerWidth); addContactBlock(section, doc.identity()); - CvSection education = SectionLookup.firstMatching(sections, - EDUCATION_KEYS); + CvSection education = SectionRouter.entries(sections, SectionRole.EDUCATION, EDUCATION_KEYS); if (hasContent(education)) { addSidebarHeader(section, education.title(), innerWidth); addEducationEntries(section, education); } - CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS); + CvSection skills = SectionRouter.skills(sections, SectionRole.SKILLS, SKILL_KEYS); if (hasContent(skills)) { addSidebarHeader(section, "EXPERTISE", innerWidth); addSkillsList(section, skills); @@ -540,8 +539,7 @@ private void addMain(SectionBuilder section, CvIdentity identity, addNameBlock(section, identity); - CvSection profile = SectionLookup.firstMatching(sections, - SUMMARY_KEYS); + CvSection profile = SectionRouter.paragraph(sections, SectionRole.SUMMARY, SUMMARY_KEYS); if (hasContent(profile)) { addMainSectionHeader(section, profile.title().isBlank() @@ -550,8 +548,7 @@ private void addMain(SectionBuilder section, CvIdentity identity, addProfileBody(section, profile); } - CvSection experience = SectionLookup.firstMatching(sections, - EXPERIENCE_KEYS); + CvSection experience = SectionRouter.entries(sections, SectionRole.EXPERIENCE, EXPERIENCE_KEYS); if (hasContent(experience)) { addMainSectionHeader(section, experience.title().isBlank() @@ -560,8 +557,7 @@ private void addMain(SectionBuilder section, CvIdentity identity, addExperienceEntries(section, experience); } - CvSection projects = SectionLookup.firstMatching(sections, - PROJECT_KEYS); + CvSection projects = SectionRouter.rows(sections, SectionRole.PROJECTS, PROJECT_KEYS, RowStyle.BULLETED_STACKED); if (hasContent(projects)) { addMainSectionHeader(section, projects.title().isBlank() @@ -570,8 +566,7 @@ private void addMain(SectionBuilder section, CvIdentity identity, addProjectsList(section, projects); } - CvSection additional = SectionLookup.firstMatching(sections, - ADDITIONAL_KEYS); + CvSection additional = SectionRouter.rows(sections, SectionRole.OTHER, ADDITIONAL_KEYS, RowStyle.PLAIN); if (hasContent(additional)) { addMainSectionHeader(section, additional.title().isBlank() diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NordicClean.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NordicClean.java index 416b5763a..e4e380b77 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NordicClean.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NordicClean.java @@ -284,7 +284,7 @@ public void compose(DocumentSession document, CvDocument doc) { .spacing(theme.spacing().pageFlowSpacing()); addHeader(flow, doc); - addProfile(flow, SectionLookup.firstMatching(sections, SUMMARY_KEYS)); + addProfile(flow, SectionRouter.paragraph(sections, SectionRole.SUMMARY, SUMMARY_KEYS)); addBody(flow, sections); flow.build(); } @@ -367,15 +367,15 @@ private void addRail(SectionBuilder rail, List sections) { .fillColor(options.railFillColor()) .stroke(DocumentStroke.of(theme.palette().rule(), 0.35)) .cornerRadius(4); - addSkills(rail, SectionLookup.firstMatching(sections, SKILL_KEYS)); - addEducation(rail, SectionLookup.firstMatching(sections, EDUCATION_KEYS)); - addAdditional(rail, SectionLookup.firstMatching(sections, ADDITIONAL_KEYS)); + addSkills(rail, SectionRouter.skills(sections, SectionRole.SKILLS, SKILL_KEYS)); + addEducation(rail, SectionRouter.entries(sections, SectionRole.EDUCATION, EDUCATION_KEYS)); + addAdditional(rail, SectionRouter.rows(sections, SectionRole.OTHER, ADDITIONAL_KEYS, RowStyle.PLAIN)); } private void addMain(SectionBuilder main, List sections) { main.spacing(9); - addExperience(main, SectionLookup.firstMatching(sections, EXPERIENCE_KEYS)); - addProjects(main, SectionLookup.firstMatching(sections, PROJECT_KEYS)); + addExperience(main, SectionRouter.entries(sections, SectionRole.EXPERIENCE, EXPERIENCE_KEYS)); + addProjects(main, SectionRouter.rows(sections, SectionRole.PROJECTS, PROJECT_KEYS, RowStyle.BULLETED_STACKED)); } private void addSkills(SectionBuilder parent, CvSection section) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Panel.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Panel.java index 3f5e44530..5bacb9207 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Panel.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Panel.java @@ -176,12 +176,12 @@ public void compose(DocumentSession document, CvDocument doc) { .name("CvV2PanelRoot") .spacing(gap); - CvSection summary = SectionLookup.firstMatching(sections, SUMMARY_KEYS); - CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS); - CvSection education = SectionLookup.firstMatching(sections, EDUCATION_KEYS); - CvSection experience = SectionLookup.firstMatching(sections, EXPERIENCE_KEYS); - CvSection projects = SectionLookup.firstMatching(sections, PROJECT_KEYS); - CvSection additional = SectionLookup.firstMatching(sections, ADDITIONAL_KEYS); + CvSection summary = SectionRouter.paragraph(sections, SectionRole.SUMMARY, SUMMARY_KEYS); + CvSection skills = SectionRouter.skills(sections, SectionRole.SKILLS, SKILL_KEYS); + CvSection education = SectionRouter.entries(sections, SectionRole.EDUCATION, EDUCATION_KEYS); + CvSection experience = SectionRouter.entries(sections, SectionRole.EXPERIENCE, EXPERIENCE_KEYS); + CvSection projects = SectionRouter.rows(sections, SectionRole.PROJECTS, PROJECT_KEYS, RowStyle.BULLETED_STACKED); + CvSection additional = SectionRouter.rows(sections, SectionRole.OTHER, ADDITIONAL_KEYS, RowStyle.PLAIN); addHeader(flow, doc.identity(), fullCardContentWidth); addFullWidthPanel(flow, "Profile", "Profile", summary, diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java index e57acc446..d47d6b7a7 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java @@ -17,6 +17,7 @@ import com.demcha.compose.document.templates.core.text.TextStyles; import com.demcha.compose.document.templates.core.text.MarkdownInline; import com.demcha.compose.document.templates.cv.components.ProjectLabel; +import com.demcha.compose.document.templates.cv.components.SectionRouter; import com.demcha.compose.document.templates.cv.components.SectionLookup; import com.demcha.compose.document.templates.cv.data.*; import com.demcha.compose.document.templates.core.theme.BrandTheme; @@ -392,21 +393,19 @@ private void addSidebar(SectionBuilder section, CvDocument doc, addPhotoBlock(section); addContactBlock(section, doc.identity()); - CvSection education = SectionLookup.firstMatching(sections, - EDUCATION_KEYS); + CvSection education = SectionRouter.entries(sections, SectionRole.EDUCATION, EDUCATION_KEYS); if (hasContent(education)) { addSidebarHeader(section, "Education"); addEducationEntries(section, education); } - CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS); + CvSection skills = SectionRouter.skills(sections, SectionRole.SKILLS, SKILL_KEYS); if (hasContent(skills)) { addSidebarHeader(section, "Key Skills"); addSkillsList(section, skills); } - CvSection languages = SectionLookup.firstMatching(sections, - LANGUAGE_KEYS); + CvSection languages = SectionRouter.rows(sections, SectionRole.LANGUAGES, LANGUAGE_KEYS, RowStyle.PLAIN); if (hasContent(languages)) { addSidebarHeader(section, "Languages"); addLanguageList(section, languages); @@ -610,22 +609,19 @@ private void addMain(SectionBuilder section, List sections) { content.spacing(10) .padding(new DocumentInsets(24, 34, 24, 34)); - CvSection profile = SectionLookup.firstMatching(sections, - SUMMARY_KEYS); + CvSection profile = SectionRouter.paragraph(sections, SectionRole.SUMMARY, SUMMARY_KEYS); if (hasContent(profile)) { addMainSectionHeader(content, "Professional Profile"); addProfileBody(content, profile); } - CvSection experience = SectionLookup.firstMatching(sections, - EXPERIENCE_KEYS); + CvSection experience = SectionRouter.entries(sections, SectionRole.EXPERIENCE, EXPERIENCE_KEYS); if (hasContent(experience)) { addMainSectionHeader(content, "Experience"); addExperienceEntries(content, experience); } - CvSection projects = SectionLookup.firstMatching(sections, - PROJECT_KEYS); + CvSection projects = SectionRouter.rows(sections, SectionRole.PROJECTS, PROJECT_KEYS, RowStyle.BULLETED_STACKED); if (hasContent(projects)) { addMainSectionHeader(content, "Projects"); addProjectsList(content, projects); @@ -1004,6 +1000,22 @@ private static List languageItems(CvSection section) { result.add(label + " " + body); } } + if (result.isEmpty()) { + // The sniffing above exists because this slot also accepts an + // "Additional Information" section and has to pick the language + // rows out of it. A section routed here by its role is entirely + // languages, and nothing in it needs to look like one — without + // this the block draws its heading over nothing, which is worse + // than the drop it replaced. + for (CvRow row : rows.rows()) { + String label = MarkdownInline.plainText(row.label()).trim(); + String body = MarkdownInline.plainText(row.body()).trim(); + if (label.isBlank() && body.isBlank()) { + continue; + } + result.add(body.isBlank() ? label : label + " " + body); + } + } } else if (section instanceof SkillsSection skills) { for (SkillGroup group : skills.groups()) { String inline = MarkdownInline.plainText(group.skillsInline()); diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java index 8f03cc54f..4a9da2a0b 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java @@ -25,6 +25,8 @@ import com.demcha.compose.document.templates.core.widgets.TimelineAxisWidget; import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * v2 port of the legacy "Timeline Minimal" CV preset. @@ -220,12 +222,12 @@ public void compose(DocumentSession document, CvDocument doc) { // Claim order decides which module wins when two lists could // match the same title, and every claim removes the section // from what falls through to the main column below. - CvSection education = allocation.claim(EDUCATION_KEYS); - CvSection skills = allocation.claim(SKILL_KEYS); - CvSection projects = allocation.claim(PROJECT_KEYS); - CvSection additional = allocation.claim(ADDITIONAL_KEYS); - CvSection summary = allocation.claim(SUMMARY_KEYS); - CvSection experience = allocation.claim(EXPERIENCE_KEYS); + CvSection education = allocation.claim(SectionRole.EDUCATION, EDUCATION_KEYS); + CvSection skills = allocation.claim(SectionRole.SKILLS, SKILL_KEYS); + CvSection projects = allocation.claim(SectionRole.PROJECTS, PROJECT_KEYS); + CvSection additional = allocation.claim(SectionRole.OTHER, ADDITIONAL_KEYS); + CvSection summary = allocation.claim(SectionRole.SUMMARY, SUMMARY_KEYS); + CvSection experience = allocation.claim(SectionRole.EXPERIENCE, EXPERIENCE_KEYS); List sidebar = modules( module(education, "Education"), @@ -654,6 +656,27 @@ private static List sectionLines(CvSection section) { lines.add(label + ": " + body); } } + } else if (section instanceof ModuleSection module) { + // A runtime module flattens the same way everything else does — one + // line per item — because these lines are what the column + // pagination measures. Rendering it through the shared dispatcher + // instead would draw rich multi-paragraph output the estimator + // never counted, and the axis row it lands in cannot page-break. + for (CvItem item : module.items()) { + StringBuilder line = new StringBuilder(MarkdownInline.plainText(item.title())); + String meta = Stream.of(item.subtitle(), item.location(), + module.kind() == CvKind.ENTRIES_DATED ? item.period() : "") + .filter(value -> !value.isBlank()) + .collect(Collectors.joining(" · ")); + if (!meta.isBlank()) { + line.append(" — ").append(meta); + } + if (!item.body().isEmpty()) { + line.append(": ").append(MarkdownInline.plainText( + String.join(" ", item.body()))); + } + lines.add(line.toString()); + } } else if (section instanceof EntriesSection entries) { for (CvEntry entry : entries.entries()) { // Flattened single-line excerpt: strip inline markdown so a diff --git a/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java new file mode 100644 index 000000000..8ee264a57 --- /dev/null +++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java @@ -0,0 +1,222 @@ +package com.demcha.compose.document.templates.cv.components; + +import com.demcha.compose.document.templates.cv.data.CvEntry; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.CvSection; +import com.demcha.compose.document.templates.cv.data.EntriesSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.data.RowStyle; +import com.demcha.compose.document.templates.cv.data.RowsSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.data.SkillGroup; +import com.demcha.compose.document.templates.cv.data.SkillsSection; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.type; + +/** + * What each slot receives, and what the lowering costs. + * + *

The routing half is checked end to end by {@code RoleRoutingTest}, which + * renders a foreign-language CV through every preset. What that cannot see is + * the text itself: a preset draws whatever it is handed, so a module lowered + * with the wrong separator or a doubled label renders perfectly and reads + * wrong. These cases pin the strings.

+ */ +class SectionRouterTest { + + private static List only(CvSection section) { + return List.of(section); + } + + // -- role beats heading, heading still works ------------------------ + + @Test + void aModuleIsFoundByItsRoleWhateverItsHeadingSays() { + CvSection module = ModuleSection.builder("Berufserfahrung", SectionRole.EXPERIENCE, + CvKind.ENTRIES_DATED) + .item(CvItem.of("Senior Engineer").period("2021")) + .build(); + + assertThat(SectionRouter.find(only(module), SectionRole.EXPERIENCE, List.of("experience"))) + .isSameAs(module); + } + + @Test + void aHeadingNeverOverrulesADeclaredRole() { + // Both slots would otherwise claim it — the experience slot by role and + // the projects slot by heading — and the module would render twice. + CvSection module = ModuleSection.builder("Projects", SectionRole.EXPERIENCE, + CvKind.ENTRIES_DATED) + .item(CvItem.of("Senior Engineer").period("2021")) + .build(); + + assertThat(SectionRouter.find(only(module), SectionRole.PROJECTS, List.of("projects"))) + .isNull(); + } + + @Test + void aSectionWithNoRoleIsStillFoundByItsHeading() { + CvSection legacy = new ParagraphSection("Professional Summary", "Backend engineer."); + + assertThat(SectionRouter.find(only(legacy), SectionRole.SUMMARY, List.of("summary"))) + .isSameAs(legacy); + } + + @Test + void anEmptyModuleDoesNotShadowASectionThatHasContent() { + CvSection empty = ModuleSection.of("Experience", SectionRole.EXPERIENCE, + CvKind.ENTRIES_DATED); + CvSection populated = EntriesSection.builder("Experience") + .entry("Senior Engineer", "Acme", "2021", "") + .build(); + + assertThat(SectionRouter.find(List.of(empty, populated), SectionRole.EXPERIENCE, + List.of("experience"))).isSameAs(populated); + } + + // -- what each lowering produces ----------------------------------- + + @Test + void datedEntriesKeepThePeriodAndUndatedOnesDropIt() { + CvItem item = CvItem.of("Senior Engineer").at("Acme GmbH").in("Berlin") + .period("2021 - Present").paragraphs("Owned payments."); + + assertThat(entriesOf(CvKind.ENTRIES_DATED, item)) + .singleElement() + .satisfies(entry -> { + assertThat(entry.title()).isEqualTo("Senior Engineer"); + assertThat(entry.subtitle()).isEqualTo("Acme GmbH · Berlin"); + assertThat(entry.date()).isEqualTo("2021 - Present"); + assertThat(entry.body()).isEqualTo("Owned payments."); + }); + assertThat(entriesOf(CvKind.ENTRIES, item)) + .singleElement() + .extracting(CvEntry::date) + .as("ENTRIES ignores the period, so the slot draws no date column") + .isEqualTo(""); + } + + @Test + void aRowJoinsProseWithSpacesAndDiscretePointsWithCommas() { + CvSection prose = rowsOf(RowStyle.PLAIN, + CvItem.of("Languages").paragraphs("English (Fluent).", "German (B2).")); + CvSection points = rowsOf(RowStyle.PLAIN, + CvItem.of("Highlights").bullets("Doubled throughput", "Cut onboarding")); + + assertThat(rowBody(prose)).isEqualTo("English (Fluent). German (B2)."); + assertThat(rowBody(points)) + .as("bulleted points are discrete: joined with a space they read as one sentence") + .isEqualTo("Doubled throughput, Cut onboarding"); + } + + @Test + void onlyTheStackedRowStyleCarriesALinkedTitle() { + CvItem linked = CvItem.of("GraphCompose").linkedTo("https://example.dev/gc") + .paragraphs("A layout engine."); + + assertThat(rowLabel(rowsOf(RowStyle.BULLETED_STACKED, linked))) + .as("the stacked row bolds through the text style, so the link survives") + .isEqualTo("[GraphCompose](https://example.dev/gc)"); + assertThat(rowLabel(rowsOf(RowStyle.PLAIN, linked))) + .as("an inline row bolds by wrapping in markdown, which would nest " + + "around the link and print literal asterisks") + .isEqualTo("GraphCompose"); + } + + @Test + void aPlainListOfSkillsArrivesAsSkillsNotAsCategoriesHoldingThemselves() { + CvSection lowered = SectionRouter.skills(only(ModuleSection + .builder("Kenntnisse", SectionRole.SKILLS, CvKind.BULLETS) + .item("Java 21") + .item("Kotlin") + .build()), SectionRole.SKILLS, List.of("skills")); + + assertThat(lowered).asInstanceOf(type(SkillsSection.class)) + .extracting(SkillsSection::groups, org.assertj.core.api.InstanceOfAssertFactories.LIST) + .singleElement() + .satisfies(group -> { + SkillGroup skillGroup = (SkillGroup) group; + assertThat(skillGroup.category()).isEqualTo("Kenntnisse"); + assertThat(skillGroup.skills()).containsExactly("Java 21", "Kotlin"); + }); + } + + @Test + void anItemWithADescriptionBecomesItsOwnSkillCategory() { + CvSection lowered = SectionRouter.skills(only(ModuleSection + .builder("Technical Skills", SectionRole.SKILLS, CvKind.INLINE_LIST) + .item(CvItem.of("Languages").paragraphs("Java 21", "Kotlin")) + .item("Docker") + .build()), SectionRole.SKILLS, List.of("skills")); + + SkillsSection skills = (SkillsSection) lowered; + assertThat(skills.groups()).extracting(SkillGroup::category) + .containsExactly("Languages", "Technical Skills"); + assertThat(skills.groups().get(0).skills()).containsExactly("Java 21", "Kotlin"); + assertThat(skills.groups().get(1).skills()).containsExactly("Docker"); + } + + @Test + void proseJoinsEveryItemsDescriptionIntoOneBlock() { + CvSection lowered = SectionRouter.paragraph(only(ModuleSection + .builder("Profile", SectionRole.SUMMARY, CvKind.PARAGRAPH) + .item(CvItem.of("first").paragraphs("Backend engineer.", "Ten years of it.")) + .build()), SectionRole.SUMMARY, List.of("summary")); + + assertThat(lowered).asInstanceOf(type(ParagraphSection.class)) + .extracting(ParagraphSection::body) + .isEqualTo("Backend engineer. Ten years of it."); + } + + @Test + void aSectionThatIsAlreadyTheRightShapePassesThroughUntouched() { + CvSection legacy = EntriesSection.builder("Experience") + .entry("Senior Engineer", "Acme", "2021", "").build(); + + assertThat(SectionRouter.entries(only(legacy), SectionRole.EXPERIENCE, + List.of("experience"))) + .as("lowering must not rebuild what a preset already handles") + .isSameAs(legacy); + } + + @Test + void nothingMatchingYieldsNullFromEveryFinder() { + List none = List.of(new ParagraphSection("Awards", "Employee of the year")); + + assertThat(SectionRouter.entries(none, SectionRole.EXPERIENCE, List.of("experience"))).isNull(); + assertThat(SectionRouter.rows(none, SectionRole.PROJECTS, List.of("projects"), + RowStyle.PLAIN)).isNull(); + assertThat(SectionRouter.paragraph(none, SectionRole.SUMMARY, List.of("summary"))).isNull(); + assertThat(SectionRouter.skills(none, SectionRole.SKILLS, List.of("skills"))).isNull(); + assertThat(SectionRouter.find(null, SectionRole.SKILLS, List.of("skills"))).isNull(); + } + + // -- helpers --------------------------------------------------------- + + private static List entriesOf(CvKind kind, CvItem item) { + CvSection lowered = SectionRouter.entries(only(ModuleSection + .of("Experience", SectionRole.EXPERIENCE, kind, item)), + SectionRole.EXPERIENCE, List.of("experience")); + return ((EntriesSection) lowered).entries(); + } + + private static CvSection rowsOf(RowStyle style, CvItem item) { + return SectionRouter.rows(only(ModuleSection + .of("Section", SectionRole.OTHER, CvKind.BULLETS, item)), + SectionRole.OTHER, List.of("section"), style); + } + + private static String rowBody(CvSection section) { + return ((RowsSection) section).rows().get(0).body(); + } + + private static String rowLabel(CvSection section) { + return ((RowsSection) section).rows().get(0).label(); + } +}