Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
31 changes: 24 additions & 7 deletions docs/templates/v2-layered/using-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*/
class RoleRoutingTest {

private static Stream<Named<DocumentTemplate<CvDocument>>> everyPreset() {
return CvTemplates.all().stream().map(t -> Named.of(t.id(), t));
}

@ParameterizedTest(name = "{0}")
@MethodSource("everyPreset")
void aCvWrittenInAnotherLanguageRendersOnEveryPreset(DocumentTemplate<CvDocument> preset) {
String text = composedText(preset, foreignLanguageCv());

assertRendered(text, "Ведущий инженер", preset, "experience");
assertRendered(text, "Информатика", preset, "education");
}

@ParameterizedTest(name = "{0}")
@MethodSource("everyPreset")
void aRoleRoutedModuleRendersWhateverItsKind(DocumentTemplate<CvDocument> 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<com.demcha.compose.document.templates.cv.data.CvSection> 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<com.demcha.compose.document.templates.cv.data.CvSection> 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<CvDocument> 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<CvDocument> 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<DocumentNode> nodes, StringBuilder out) {
for (DocumentNode node : nodes) {
if (node instanceof ParagraphNode paragraph) {
out.append(paragraph.text()).append(' ');
}
collectText(node.children(), out);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Loading
Loading