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();
+ }
+}