diff --git a/CHANGELOG.md b/CHANGELOG.md index b13eed6d..68c03e82 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 c997f6c3..44a59ba4 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 00000000..a5a92edc --- /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 fb0a999d..7f7ab0f3 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 560c5b94..c79045b5 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 00000000..b87f3ad1 --- /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 37287a37..69e7e90c 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 ff0e4c52..22f93098 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 506a112c..00557f1d 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 40a190c8..f3ff748e 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 261bf077..072e4ae7 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 09acff8d..51bf0a07 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 416b5763..e4e380b7 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 3f5e4453..5bacb920 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 e57acc44..d47d6b7a 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 8f03cc54..4a9da2a0 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 00000000..8ee264a5 --- /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(); + } +}