diff --git a/CHANGELOG.md b/CHANGELOG.md
index e26d298e..b13eed6d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,40 @@ follow semantic versioning; release dates are ISO 8601.
interface, so a downstream `switch` over `CvSection` that was exhaustive without a
`default` needs one.
+- **Which presets can be handed a runtime module, declared rather than assumed.** A module
+ is only useful if the template renders it, and not every preset can promise that:
+ several compose a fixed set of modules and find each by matching headings, so a
+ section they do not recognise never reaches a renderer. The CV still comes out —
+ minus a section, looking finished — which is the kind of failure nobody reports.
+
+ `ModularCvTemplate` is the promise, and `CvTemplates.modular()` is the list a CV
+ builder should offer; `CvTemplates` also answers `byId`, `all`, `ids`, and
+ `recommendedMargin`, so picking a preset at runtime stops being a hand-kept map in
+ every consumer. Declaring the interface is not free: `ModularCvTemplateFidelityTest`
+ renders a document carrying every kind, an invented heading, a heading in a
+ script no keyword list contains, and a heading that *does* match one, through each
+ template that declares it, and asserts every item reached the page under the words
+ the author wrote — the last case because `EditorialBlue` renamed any heading
+ matching "certification" to EDUCATION, so "Certifications & Awards" arrived as a
+ word nobody had written. The promise covers `Slot.MAIN`, and says so: every shipped
+ preset composes a single main column, so a sidebar section is dropped by these
+ templates as by every other. Seven presets qualify today. `ClassicSerif` does not,
+ and finding that out is what the gate is for — it draws any shape it is given, but
+ only gives itself the sections it recognises.
+
+ `CvTemplatesCoverageTest` derives the catalogue from the presets package rather than
+ 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.
+
+- **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`
+ stays shared, because deciding what a linked title looks like or which fields a kind
+ reads belongs to the model and must not be re-decided per preset; only the drawing is
+ the preset's. `BlueBanner`, `ClassicSerif`, and `EditorialBlue` now render modules
+ with their own entry and project shapes rather than the canonical ones — the
+ limitation the entry above left open.
+
### Fixed
- **A section shape a preset did not recognise was lost three different ways.**
diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md
index 461bb11c..c997f6c3 100644
--- a/docs/templates/v2-layered/using-templates.md
+++ b/docs/templates/v2-layered/using-templates.md
@@ -212,6 +212,41 @@ Modules and the four fixed types mix freely in one document, and both
render through the same components — a module drawn as `ENTRIES_DATED`
lays out exactly like the `EntriesSection` carrying the same content.
+### Which preset can you hand a runtime module to?
+
+Not every preset. Several compose a fixed set of modules and find each by
+matching headings, so a section they do not recognise never reaches a
+renderer; the CV still comes out, minus a section, looking finished. The
+ones that render whatever they are handed say so in the type system:
+
+```java
+List safe = CvTemplates.modular(); // offer these
+
+CvTemplates.byId("modern-professional") // or look one up
+ .orElseThrow()
+ .compose(session, doc);
+```
+
+`CvTemplates` also answers `all()`, `ids()`, and `recommendedMargin(id)` —
+the margin a preset was designed at, which you need while building the
+session, before you have a template.
+
+Declaring `ModularCvTemplate` is not free: a fidelity suite renders a
+document carrying every kind, an invented heading, a heading in a script no
+keyword list contains, and a heading that *does* match one, through each
+template that declares it, and asserts every item reached the page under the
+author's own words.
+
+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.
+
+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
+that style rather than the canonical one. Presets whose bodies already use
+the shared components return `CvRenderKit.defaults()`.
+
---
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java
new file mode 100644
index 00000000..291e4036
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java
@@ -0,0 +1,265 @@
+package com.demcha.compose.document.templates.cv.api;
+
+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.data.Slot;
+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;
+
+/**
+ * Every template that declares {@link ModularCvTemplate} renders every kind
+ * of module, under whatever heading the author wrote.
+ *
+ * The interface is a promise made to a caller who cannot check it: a CV
+ * builder offers the modular templates and trusts that whatever the user
+ * assembled comes out the other side. A template that quietly dropped a
+ * section would produce a CV that still looks finished — the failure has no
+ * symptom at the point it happens, only a missing job three weeks later.
+ * This is where the promise is checked, so wearing the interface costs
+ * something.
+ *
+ * It enumerates {@link CvTemplates#modular()} rather than a list of its
+ * own, and every {@link CvKind} rather than the kinds in use, so a template
+ * or a kind added later is covered the day it lands — the coverage cannot
+ * be forgotten, only made to pass.
+ *
+ * Text is read from the composed layout, not the PDF text layer: the CV
+ * themes draw with the standard-14 Helvetica, whose encoding has no
+ * Cyrillic, and the non-Latin heading below is the case that matters most.
+ * What the model owes is that the section is placed carrying its own words;
+ * which glyphs a font can draw is the caller's font choice.
+ */
+class ModularCvTemplateFidelityTest {
+
+ private static Stream> modularTemplates() {
+ return CvTemplates.modular().stream()
+ .map(template -> Named.of(template.id(), template));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("modularTemplates")
+ void everyModularTemplateRendersEveryKind(ModularCvTemplate template) {
+ String text = composedText(template, everyKindDocument());
+
+ for (CvKind kind : CvKind.values()) {
+ // Case-insensitively: a preset that upper-cases its entry titles is
+ // styling them, not losing them.
+ assertThat(text)
+ .as("%s must render the %s module's item", template.id(), kind)
+ .containsIgnoringCase(itemTitle(kind));
+ assertThat(text)
+ .as("%s must render the %s module's description", template.id(), kind)
+ .contains(bodyLine(kind));
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("modularTemplates")
+ void everyModularTemplateRendersAnAdHocSection(ModularCvTemplate template) {
+ // No keyword list contains "Volunteering", and no preset was written
+ // with it in mind. A section the author invented is the whole point of
+ // assembling a CV at runtime, so it is the minimum this promise means.
+ CvDocument doc = document(ModuleSection.builder("Volunteering", SectionRole.OTHER,
+ CvKind.ENTRIES_DATED)
+ .item(CvItem.of("Mentor, Rails Girls").at("Rails Girls Berlin")
+ .period("2019 - 2021").bullets("Ran three weekend workshops"))
+ .build());
+
+ String text = composedText(template, doc);
+
+ assertThatHeading(text, "Volunteering", template);
+ assertThat(text)
+ .as("%s must render the invented section's entry", template.id())
+ .containsIgnoringCase("Mentor, Rails Girls");
+ assertThat(text)
+ .as("%s must render the invented section's description", template.id())
+ .contains("Ran three weekend workshops");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("modularTemplates")
+ void everyModularTemplateRendersANonLatinHeading(ModularCvTemplate template) {
+ CvDocument doc = document(ModuleSection.builder("Навыки", SectionRole.SKILLS,
+ CvKind.INLINE_LIST)
+ .item(CvItem.of("Языки").paragraphs("Java 21", "Kotlin"))
+ .build());
+
+ String text = composedText(template, doc);
+
+ assertThatHeading(text, "Навыки", template);
+ assertThat(text)
+ .as("%s must render the section's items", template.id())
+ .contains("Языки", "Java 21, Kotlin");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("modularTemplates")
+ void aHeadingThePresetHasAWordForIsStillTheAuthorsHeading(ModularCvTemplate template) {
+ // The ad-hoc cases above use headings no keyword list contains, which is
+ // the easy half. A preset with an editorial vocabulary of its own is the
+ // one that rewrites: one of these renamed any heading matching
+ // "certification" to EDUCATION, so "Certifications & Awards" reached the
+ // page as a word the author never wrote and the awards lost their title.
+ CvDocument doc = document(ModuleSection.builder("Certifications & Awards",
+ SectionRole.OTHER, CvKind.BULLETS)
+ .item(CvItem.of("AWS Solutions Architect").paragraphs("2024"))
+ .build());
+
+ String text = composedText(template, doc);
+
+ assertThatHeading(text, "Certifications & Awards", template);
+ assertThat(text)
+ .as("%s must render the section's item", template.id())
+ .containsIgnoringCase("AWS Solutions Architect");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("modularTemplates")
+ void aSidebarSectionIsNotRenderedAndTheContractSaysSo(ModularCvTemplate template) {
+ // Pinning a limitation, not a feature. Every shipped preset composes one
+ // main column and reads Slot.MAIN, so a section placed in the sidebar is
+ // dropped — which ModularCvTemplate's contract states rather than leaving
+ // "renders whatever it is handed" to be read generously. When slots go
+ // live this test goes red, which is the point: the promise and the code
+ // change together.
+ CvDocument doc = CvDocument.builder()
+ .identity(identity())
+ .section(Slot.SIDEBAR, ModuleSection.builder("Languages",
+ SectionRole.LANGUAGES, CvKind.INLINE_LIST)
+ .item(CvItem.of("Spoken").paragraphs("English", "German"))
+ .build())
+ .build();
+
+ assertThat(composedText(template, doc))
+ .as("%s reads Slot.MAIN, as its contract says", template.id())
+ .doesNotContain("Spoken");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("modularTemplates")
+ void everyModularTemplateDeclaresAKit(ModularCvTemplate template) {
+ assertThat(template.kit())
+ .as("%s must hand back a kit — the drawing half of the promise", template.id())
+ .isNotNull();
+ }
+
+ @Test
+ void theModularListIsASubsetOfTheCatalogueAndNotEmpty() {
+ List modularIds = CvTemplates.modular().stream()
+ .map(DocumentTemplate::id).toList();
+
+ assertThat(modularIds)
+ .as("a promise nobody makes is a promise nobody keeps")
+ .isNotEmpty();
+ assertThat(CvTemplates.ids()).containsAll(modularIds);
+ assertThat(modularIds).doesNotHaveDuplicates();
+ }
+
+ /**
+ * Asserts the heading reached the page, ignoring how the preset styled
+ * it: several letter-space and upper-case their headings, so
+ * "Volunteering" arrives as "V O L U N T E E R I N G". The words are the
+ * template's to keep; the typography is the template's to choose.
+ */
+ private static void assertThatHeading(String text, String heading,
+ ModularCvTemplate template) {
+ assertThat(text.replace(" ", ""))
+ .as("%s must render the section under its own heading (%s)",
+ template.id(), heading)
+ .containsIgnoringCase(heading.replace(" ", ""));
+ }
+
+ // -- fixtures --------------------------------------------------------
+
+ /**
+ * One module per kind, each carrying an item whose title and description
+ * name the kind — so a failure says which kind was dropped rather than
+ * that something was missing.
+ */
+ private static CvDocument everyKindDocument() {
+ CvDocument.Builder builder = CvDocument.builder().identity(identity());
+ for (CvKind kind : CvKind.values()) {
+ builder.section(ModuleSection.builder(sectionTitle(kind), SectionRole.OTHER, kind)
+ .item(CvItem.of(itemTitle(kind))
+ .at("Acme GmbH").in("Berlin").period("2021 - Present")
+ .paragraphs(bodyLine(kind)))
+ .build());
+ }
+ return builder.build();
+ }
+
+ private static String sectionTitle(CvKind kind) {
+ return "Section " + marker(kind);
+ }
+
+ /**
+ * The kind's name with its underscore removed. An underscore is markdown
+ * for italic, and a fixture carrying two of them would be reporting the
+ * markdown parser rather than the template.
+ */
+ private static String marker(CvKind kind) {
+ return kind.name().replace("_", " ");
+ }
+
+ private static String itemTitle(CvKind kind) {
+ // PARAGRAPH reads the body alone, so its item title never reaches the
+ // page; the body carries the marker for that kind instead.
+ return kind == CvKind.PARAGRAPH ? bodyLine(kind) : "Item " + marker(kind);
+ }
+
+ private static String bodyLine(CvKind kind) {
+ return "Body of " + marker(kind);
+ }
+
+ private static CvDocument document(ModuleSection module) {
+ return CvDocument.builder().identity(identity()).section(module).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, joined. */
+ private static String composedText(DocumentTemplate template, CvDocument doc) {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(24, 24, 24, 24)
+ .create()) {
+ template.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/ModuleSectionKindCoverageTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java
index a362537d..08defa81 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java
@@ -43,8 +43,7 @@
import static org.assertj.core.api.Assertions.assertThatCode;
/**
- * Every {@link CvKind} reaches the page, on every preset that renders whatever
- * the document hands it.
+ * Every {@link CvKind} reaches the page, and no preset fails on a module.
*
* A runtime module is only as good as the weakest kind: an author who picks
* one the renderers never learned to lower gets a section that silently draws
@@ -52,10 +51,11 @@
* cases means a kind added later fails here until it is wired, which is the
* point — a new constant cannot ship half-rendered.
*
- * The ad-hoc cases matter as much as the catalogue ones: a module with
- * {@link SectionRole#OTHER} and a heading in a script nobody's keyword list
- * contains is exactly the CV this model exists for, and it must survive to the
- * page under its own heading.
+ * The per-template promise — every kind, an invented heading, a non-Latin
+ * one — is checked in {@code ModularCvTemplateFidelityTest}, which enumerates
+ * the templates that declare the capability instead of a list kept by hand.
+ * What stays here is the kind-level coverage and the floor every preset owes
+ * whether or not it declares anything.
*/
class ModuleSectionKindCoverageTest {
@@ -138,48 +138,6 @@ void proseRendersWithoutRepeatingTheHeading() throws Exception {
.hasSize(2);
}
- @ParameterizedTest
- @MethodSource("generalPresets")
- void everyGeneralPresetRendersAModule(DocumentTemplate preset) throws Exception {
- ModuleSection module = ModuleSection.builder("Volunteering", SectionRole.OTHER,
- CvKind.ENTRIES_DATED)
- .item(CvItem.of("Mentor, Rails Girls").at("Rails Girls Berlin")
- .period("2019 - 2021").bullets("Ran three weekend workshops"))
- .build();
-
- String text = render(preset, module);
-
- // Headings are the preset's to style — several letter-space them and
- // upper-case them into "V O L U N T E E R I N G" — so the heading is
- // matched without spacing or case. The content is matched verbatim.
- assertThat(text.replace(" ", ""))
- .as("%s must render an ad-hoc module under its own heading", preset.id())
- .containsIgnoringCase("Volunteering");
- assertThat(text)
- .as("%s must render the module's items", preset.id())
- .contains("Mentor, Rails Girls", "Ran three weekend workshops");
- }
-
- @ParameterizedTest
- @MethodSource("everyPreset")
- void noPresetFailsOnAModule(DocumentTemplate preset) throws Exception {
- // Weaker than the case above, and deliberately so: eight presets guard
- // their module slots on the section's Java type, so a module routed there
- // is skipped rather than drawn, and placing it is the routing work rather
- // than this change. What no preset may do is throw — two of them did until
- // this landed, each keeping a private copy of the dispatcher whose final
- // else raised IllegalStateException, so the first CV built from a runtime
- // module would have failed to render at all.
- ModuleSection module = ModuleSection.builder("Volunteering", SectionRole.OTHER,
- CvKind.ENTRIES_DATED)
- .item(CvItem.of("Mentor, Rails Girls").period("2019 - 2021"))
- .build();
-
- assertThatCode(() -> render(preset, module))
- .as("%s must render a document containing a runtime module", preset.id())
- .doesNotThrowAnyException();
- }
-
@ParameterizedTest
@MethodSource("presetsThatRenderAnyShape")
void aModuleUnderAHeadingThePresetKnowsIsRendered(DocumentTemplate preset)
@@ -198,26 +156,9 @@ void aModuleUnderAHeadingThePresetKnowsIsRendered(DocumentTemplate p
assertThat(render(preset, module))
.as("%s must render a module it routed by heading", preset.id())
- .contains("Senior Backend Engineer");
- }
-
- @Test
- void aNonLatinHeadingReachesTheLayoutUnderItsOwnWords() throws Exception {
- // Preset routing that matches English keywords against a heading has
- // nothing to match here; the role carries the meaning instead and the
- // heading stays the author's. Asserted against the composed layout rather
- // than the PDF text layer on purpose: the CV themes draw with the
- // standard-14 Helvetica, which has no Cyrillic glyphs, so the *rendered*
- // page shows substitutes until the caller supplies a font that covers the
- // script. What this pins is the half that is the model's to get right —
- // the section is placed and carries its own text.
- ModuleSection module = ModuleSection.builder("Навыки", SectionRole.SKILLS,
- CvKind.INLINE_LIST)
- .item(CvItem.of("Языки").paragraphs("Java 21", "Kotlin"))
- .build();
-
- assertThat(composedText(ModernProfessional.create(), module))
- .contains("Навыки", "Языки", "Java 21, Kotlin");
+ // Case-insensitively: a preset that upper-cases entry titles — and
+ // one now does, through its own kit — is styling, not dropping.
+ .containsIgnoringCase("Senior Backend Engineer");
}
@Test
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java
new file mode 100644
index 00000000..8d25a9f6
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java
@@ -0,0 +1,59 @@
+package com.demcha.compose.document.templates.cv.api;
+
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.components.CvRenderKit;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+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.Slot;
+
+/**
+ * A CV template that renders every section placed in {@link Slot#MAIN} —
+ * every {@link CvKind}, under whatever heading the author wrote.
+ *
+ * The promise is exactly that, and the slot is part of it.
+ * Every shipped preset composes a single main column and reads
+ * {@code sectionsIn(Slot.MAIN)}; a section placed in {@link Slot#SIDEBAR} or
+ * {@link Slot#FOOTER} is dropped, by these templates as by every other, which
+ * is the behaviour {@link com.demcha.compose.document.templates.cv.data.CvDocument}
+ * has always documented. Saying so here rather than leaving "whatever the
+ * document hands it" to be read generously is the difference between a
+ * contract and a slogan — a caller assembling a CV at runtime needs to know
+ * that placing a module in a sidebar loses it today.
+ *
+ * This is the promise a CV assembled at runtime needs, and it is not one
+ * every preset can make. Several place their sections into fixed slots and
+ * guard each slot on the section's Java type, so a module routed to one is
+ * skipped rather than drawn; the CV still renders, minus a section, and
+ * looks finished. That failure is invisible from the outside, which is why
+ * the capability is declared in the type system rather than assumed: a
+ * constructor asks {@link com.demcha.compose.document.templates.cv.presets.CvTemplates#modular()}
+ * for the templates it may offer, and the rest stay available to callers
+ * who build the canonical sections by hand.
+ *
+ * Declaring it is not enough to have it. {@code ModularCvTemplateFidelityTest}
+ * enumerates the implementations and renders a document carrying every kind,
+ * a section this catalogue has no name for, a heading in a script no keyword
+ * list contains, and a heading that does match a keyword list — the
+ * last because a preset with an editorial vocabulary of its own is the one
+ * likely to rename what the author wrote. Each item must reach the page, so
+ * the interface cannot be worn by a template that would drop or retitle
+ * one.
+ *
+ * {@link #kit()} is how the promise stays compatible with a preset's own
+ * look: the shared lowering turns a {@link ModuleSection} into paragraphs,
+ * rows, and entries, and the kit draws them the way this template draws
+ * everything else.
+ *
+ * @since 2.3.0
+ */
+public interface ModularCvTemplate extends DocumentTemplate {
+
+ /**
+ * How this template draws the shapes a module lowers to.
+ *
+ * @return this template's kit; {@link CvRenderKit#defaults()} for a
+ * template whose modules look like the canonical components
+ */
+ CvRenderKit kit();
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java
new file mode 100644
index 00000000..5391265e
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java
@@ -0,0 +1,94 @@
+package com.demcha.compose.document.templates.cv.components;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.templates.core.theme.BrandTheme;
+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.RowStyle;
+
+/**
+ * How one template draws the three shapes a CV section body reduces to:
+ * a paragraph of prose, a label/value row, a timeline entry.
+ *
+ * A preset that wants runtime {@code ModuleSection}s to look like the
+ * rest of its own document implements this and hands it back through
+ * {@link com.demcha.compose.document.templates.cv.api.ModularCvTemplate};
+ * {@link #defaults()} draws them the canonical way, and every method has a
+ * default, so a preset overrides only the shapes it actually styles
+ * differently.
+ *
+ * Why the primitives and not the kinds. The obvious
+ * alternative is a function per {@link CvKind}. It puts the wrong work on
+ * the preset: turning a {@link CvItem} into an entry or a row means
+ * deciding what a linked title looks like, how a subtitle and a location
+ * join, which fields the kind ignores, what an empty description does to a
+ * trailing colon — rules that belong to the model and must not be
+ * re-decided sixteen times. {@link ModuleRenderer} keeps that lowering and
+ * asks the kit only to draw what came out of it, which is exactly the part
+ * a preset has an opinion about. It is also the shape the presets already
+ * have: their private renderers take a {@code CvEntry} or a {@code CvRow}
+ * today.
+ *
+ * Implementations draw into the host and return; they do not set the
+ * host's spacing or padding, which the caller has already settled, and
+ * they do not insert separators between items — {@code ModuleRenderer}
+ * owns the gaps so that spacing stays uniform whoever is drawing.
+ *
+ * @since 2.3.0
+ */
+public interface CvRenderKit {
+
+ /**
+ * The canonical kit: every shape drawn by the shared components, which
+ * is what a section rendered through
+ * {@link SectionDispatcher#renderBody(SectionBuilder, com.demcha.compose.document.templates.cv.data.CvSection, BrandTheme)}
+ * has always produced.
+ *
+ * @return a kit that draws every shape the canonical way
+ */
+ static CvRenderKit defaults() {
+ return DEFAULTS;
+ }
+
+ /** The canonical kit. Stateless, so one instance serves every caller. */
+ CvRenderKit DEFAULTS = new CvRenderKit() {
+ };
+
+ /**
+ * Draws one paragraph of prose. Blank text draws nothing.
+ *
+ * @param host host section receiving the paragraph
+ * @param text the prose; may carry inline markdown
+ * @param theme the active theme
+ */
+ default void paragraph(SectionBuilder host, String text, BrandTheme theme) {
+ ParagraphRenderer.render(host, text, theme);
+ }
+
+ /**
+ * Draws one label/value row with the given decoration.
+ *
+ * @param host host section receiving the row
+ * @param row label and body
+ * @param style plain, bulleted, or bulleted with the body stacked under
+ * the label
+ * @param theme the active theme
+ */
+ default void row(SectionBuilder host, CvRow row, RowStyle style, BrandTheme theme) {
+ RowRenderer.render(host, row, style, theme);
+ }
+
+ /**
+ * Draws one timeline entry. A blank {@code date} collapses the date
+ * column rather than reserving an empty one.
+ *
+ * @param host host section receiving the entry
+ * @param entry title, subtitle, date, and body
+ * @param theme the active theme
+ */
+ default void entry(SectionBuilder host, CvEntry entry, BrandTheme theme) {
+ EntryRenderer.render(host, entry, theme);
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java
index 9df3f1ca..9d2a5272 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java
@@ -37,23 +37,43 @@ private ModuleRenderer() {
}
/**
- * Renders every item of {@code module} into {@code host}.
+ * Renders every item of {@code module} into {@code host}, drawing the
+ * canonical way.
*
* @param host host section receiving the body
* @param module the module supplying items, kind, and role
* @param theme the active theme supplying palette, typography, and spacing
*/
public static void render(SectionBuilder host, ModuleSection module, BrandTheme theme) {
+ render(host, module, theme, CvRenderKit.defaults());
+ }
+
+ /**
+ * Renders every item of {@code module} into {@code host}, drawing
+ * through {@code kit}.
+ *
+ * The lowering below is the same whoever draws: which fields a kind
+ * reads, how a linked title is spelled, what an empty description does
+ * to a trailing colon. Only the three drawing calls go to the kit, so a
+ * preset can restyle its modules without re-deciding any of that.
+ *
+ * @param host host section receiving the body
+ * @param module the module supplying items, kind, and role
+ * @param theme the active theme supplying palette, typography, and spacing
+ * @param kit how this template draws paragraphs, rows, and entries
+ */
+ public static void render(SectionBuilder host, ModuleSection module, BrandTheme theme,
+ CvRenderKit kit) {
List items = module.items();
for (int i = 0; i < items.size(); i++) {
CvItem item = items.get(i);
switch (module.kind()) {
- case PARAGRAPH -> paragraph(host, item, theme);
- case BULLETS -> bullet(host, item, theme);
- case BULLETS_STACKED -> stackedBullet(host, item, theme, i > 0);
- case INLINE_LIST -> inlineList(host, item, theme);
- case ENTRIES -> entry(host, item, "", theme, i > 0);
- case ENTRIES_DATED -> entry(host, item, item.period(), theme, i > 0);
+ case PARAGRAPH -> paragraph(host, item, theme, kit);
+ case BULLETS -> bullet(host, item, theme, kit);
+ case BULLETS_STACKED -> stackedBullet(host, item, theme, kit, i > 0);
+ case INLINE_LIST -> inlineList(host, item, theme, kit);
+ case ENTRIES -> entry(host, item, "", theme, kit, i > 0);
+ case ENTRIES_DATED -> entry(host, item, item.period(), theme, kit, i > 0);
}
}
}
@@ -63,12 +83,13 @@ public static void render(SectionBuilder host, ModuleSection module, BrandTheme
* {@code CvKind.PARAGRAPH}). A bulleted body still bullets — the
* body style is the author's second choice, independent of kind.
*/
- private static void paragraph(SectionBuilder host, CvItem item, BrandTheme theme) {
+ private static void paragraph(SectionBuilder host, CvItem item, BrandTheme theme,
+ CvRenderKit kit) {
for (String line : item.body()) {
if (item.bodyStyle() == BodyStyle.BULLETS) {
bulletedLine(host, line, theme.bodyStyle(), theme);
} else {
- ParagraphRenderer.render(host, line, theme);
+ kit.paragraph(host, line, theme);
}
}
}
@@ -79,7 +100,8 @@ private static void paragraph(SectionBuilder host, CvItem item, BrandTheme theme
* as its label alone: {@link RowStyle#PLAIN} would leave a colon
* pointing at nothing.
*/
- private static void inlineList(SectionBuilder host, CvItem item, BrandTheme theme) {
+ private static void inlineList(SectionBuilder host, CvItem item, BrandTheme theme,
+ CvRenderKit kit) {
// The title, not linkedTitle: this kind documents that it ignores the
// link, and RowRenderer bolds a label by wrapping it in markdown
// markers — which would nest around a link and print as literal
@@ -88,7 +110,7 @@ private static void inlineList(SectionBuilder host, CvItem item, BrandTheme them
ParagraphPrimitive.writeBody(host, item.title(), theme.bodyBoldStyle(), theme);
return;
}
- RowRenderer.render(host, new CvRow(item.title(), String.join(", ", item.body())),
+ kit.row(host, new CvRow(item.title(), String.join(", ", item.body())),
RowStyle.PLAIN, theme);
}
@@ -102,7 +124,8 @@ private static void inlineList(SectionBuilder host, CvItem item, BrandTheme them
* links wants {@link CvKind#BULLETS_STACKED}, which bolds through the
* text style and leaves the link intact.
*/
- private static void bullet(SectionBuilder host, CvItem item, BrandTheme theme) {
+ private static void bullet(SectionBuilder host, CvItem item, BrandTheme theme,
+ CvRenderKit kit) {
if (item.body().isEmpty()) {
// PLAIN/BULLETED end the label with a colon, which would point at
// nothing. A title-only entry is a plain bullet.
@@ -111,7 +134,7 @@ private static void bullet(SectionBuilder host, CvItem item, BrandTheme theme) {
DocumentInsets.top((float) theme.spacing().paragraphMarginTop()), theme);
return;
}
- RowRenderer.render(host, new CvRow(item.title(), String.join(" ", item.body())),
+ kit.row(host, new CvRow(item.title(), String.join(" ", item.body())),
RowStyle.BULLETED, theme);
}
@@ -120,15 +143,14 @@ private static void bullet(SectionBuilder host, CvItem item, BrandTheme theme) {
* the title ({@link RowStyle#BULLETED_STACKED}).
*/
private static void stackedBullet(SectionBuilder host, CvItem item, BrandTheme theme,
- boolean separate) {
+ CvRenderKit kit, boolean separate) {
// Stacked items are multi-line blocks, so they get the same gap the
// dispatcher puts between stacked rows — without it consecutive items
// read as one.
if (separate) {
host.spacer(0, theme.spacing().entrySeparation());
}
- RowRenderer.render(host, new CvRow(linkedTitle(item), ""),
- RowStyle.BULLETED_STACKED, theme);
+ kit.row(host, new CvRow(linkedTitle(item), ""), RowStyle.BULLETED_STACKED, theme);
// A bulleted body nests a bullet under the item's own; prose is indented
// to the title instead of carrying a second glyph.
String glyph = item.bodyStyle() == BodyStyle.BULLETS
@@ -147,11 +169,11 @@ private static void stackedBullet(SectionBuilder host, CvItem item, BrandTheme t
* in the style the item asked for.
*/
private static void entry(SectionBuilder host, CvItem item, String date,
- BrandTheme theme, boolean separate) {
+ BrandTheme theme, CvRenderKit kit, boolean separate) {
if (separate) {
host.spacer(0, theme.spacing().entrySeparation());
}
- EntryRenderer.render(host,
+ kit.entry(host,
new CvEntry(linkedTitle(item), subtitleWithLocation(item), date, ""), theme);
for (String line : item.body()) {
if (item.bodyStyle() == BodyStyle.BULLETS) {
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java
index 10be3b78..b450b8e6 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java
@@ -34,11 +34,31 @@ private SectionDispatcher() {
* @throws IllegalStateException if the section subtype is unhandled
*/
public static void renderBody(SectionBuilder host, CvSection section, BrandTheme theme) {
+ renderBody(host, section, theme, CvRenderKit.defaults());
+ }
+
+ /**
+ * Renders the section body, drawing through {@code kit}.
+ *
+ * The routing is identical to the three-argument form; only who draws
+ * differs. A preset with its own entry or row style passes its kit here
+ * so a runtime module looks like the rest of its document instead of
+ * like the canonical components.
+ *
+ * @param host host section receiving the body
+ * @param section the section whose subtype selects the renderer
+ * @param theme the active theme supplying palette, typography, and spacing
+ * @param kit how this template draws paragraphs, rows, and entries
+ * @throws IllegalStateException if the section subtype is unhandled
+ * @since 2.3.0
+ */
+ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme theme,
+ CvRenderKit kit) {
host.spacing(theme.spacing().sectionBodySpacing())
.padding(theme.spacing().sectionBodyPadding());
if (section instanceof ParagraphSection p) {
- ParagraphRenderer.render(host, p.body(), theme);
+ kit.paragraph(host, p.body(), theme);
} else if (section instanceof SkillsSection s) {
SkillsRenderer.render(host, s, theme);
} else if (section instanceof RowsSection r) {
@@ -52,13 +72,13 @@ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme
if (i > 0 && stackedNeedsSeparator) {
host.spacer(0, theme.spacing().entrySeparation());
}
- RowRenderer.render(host, r.rows().get(i), r.style(), theme);
+ kit.row(host, r.rows().get(i), r.style(), theme);
}
} else if (section instanceof ModuleSection m) {
// Runtime-assembled module. The kind decides which of the
// renderers above each item lands on, so this branch draws
// nothing of its own — see ModuleRenderer.
- ModuleRenderer.render(host, m, theme);
+ ModuleRenderer.render(host, m, theme, kit);
} else if (section instanceof EntriesSection e) {
// Timeline entries (Education, Experience) get a spacer
// between items — each entry is a multi-line block
@@ -68,7 +88,7 @@ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme
if (i > 0) {
host.spacer(0, theme.spacing().entrySeparation());
}
- EntryRenderer.render(host, e.entries().get(i), theme);
+ kit.entry(host, e.entries().get(i), theme);
}
} else {
throw new IllegalStateException(
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 1857b539..37287a37 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
@@ -10,6 +10,7 @@
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.api.ModularCvTemplate;
import com.demcha.compose.document.templates.cv.components.*;
import com.demcha.compose.document.templates.cv.data.*;
import com.demcha.compose.document.templates.core.theme.BrandTheme;
@@ -93,7 +94,7 @@ public static DocumentTemplate create(BrandTheme theme) {
return new Template(theme);
}
- private record Template(BrandTheme theme) implements DocumentTemplate {
+ private record Template(BrandTheme theme) implements ModularCvTemplate {
@Override
public String id() {
@@ -105,6 +106,11 @@ public String displayName() {
return DISPLAY_NAME;
}
+ @Override
+ public CvRenderKit kit() {
+ return KIT;
+ }
+
@Override
public void compose(DocumentSession document, CvDocument doc) {
Objects.requireNonNull(document, "document");
@@ -167,7 +173,7 @@ private static void renderBody(SectionBuilder host,
// page, which matters more than matching this preset's flavour of
// entry. A preset that wants its own module styling overrides this
// branch, it does not lose the content by omission.
- SectionDispatcher.renderBody(host, section, theme);
+ SectionDispatcher.renderBody(host, section, theme, KIT);
}
}
@@ -185,6 +191,31 @@ private static void renderRows(SectionBuilder host,
}
}
+ /**
+ * This preset's own drawing, so a runtime module gets the upper-cased
+ * two-column entry and the dash-joined project row the rest of the
+ * document uses rather than the canonical ones.
+ *
+ * Stateless: every method takes its theme, so one instance serves
+ * every {@code create(theme)}.
+ */
+ private static final CvRenderKit KIT = new CvRenderKit() {
+
+ @Override
+ public void entry(SectionBuilder host, CvEntry entry, BrandTheme theme) {
+ renderEntry(host, entry, theme);
+ }
+
+ @Override
+ public void row(SectionBuilder host, CvRow row, RowStyle style, BrandTheme theme) {
+ if (style == RowStyle.BULLETED_STACKED) {
+ renderPlainProjectRow(host, row, theme);
+ return;
+ }
+ RowRenderer.render(host, row, style, theme);
+ }
+ };
+
private static void renderPlainProjectRow(SectionBuilder host,
CvRow row,
BrandTheme theme) {
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java
index 9cbdd108..269ac35c 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java
@@ -3,6 +3,8 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.PageFlowBuilder;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.api.ModularCvTemplate;
+import com.demcha.compose.document.templates.cv.components.CvRenderKit;
import com.demcha.compose.document.templates.cv.components.SectionDispatcher;
import com.demcha.compose.document.templates.cv.data.CvDocument;
import com.demcha.compose.document.templates.cv.data.CvSection;
@@ -83,7 +85,7 @@ public static DocumentTemplate create(BrandTheme theme) {
return new Template(theme);
}
- private record Template(BrandTheme theme) implements DocumentTemplate {
+ private record Template(BrandTheme theme) implements ModularCvTemplate {
@Override
public String id() {
@@ -95,6 +97,13 @@ public String displayName() {
return DISPLAY_NAME;
}
+ @Override
+ public CvRenderKit kit() {
+ // This preset renders bodies through the shared dispatcher, so a
+ // runtime module already looks like the rest of its document.
+ return CvRenderKit.defaults();
+ }
+
@Override
public void compose(DocumentSession document, CvDocument doc) {
Objects.requireNonNull(document, "document");
@@ -128,7 +137,7 @@ public void compose(DocumentSession document, CvDocument doc) {
SectionHeader.banner(host, sec.title(), theme);
});
pageFlow.addSection("CvV2Body_" + idx,
- host -> SectionDispatcher.renderBody(host, sec, theme));
+ host -> SectionDispatcher.renderBody(host, sec, theme, kit()));
}
pageFlow.build();
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java
index 0cf25e9b..bb501bd6 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java
@@ -8,7 +8,9 @@
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.api.ModularCvTemplate;
import com.demcha.compose.document.templates.cv.components.ProjectRenderer;
+import com.demcha.compose.document.templates.cv.components.CvRenderKit;
import com.demcha.compose.document.templates.cv.components.SectionDispatcher;
import com.demcha.compose.document.templates.cv.data.*;
import com.demcha.compose.document.templates.core.theme.BrandTheme;
@@ -109,7 +111,7 @@ public static DocumentTemplate create(BrandTheme theme) {
return new Template(theme);
}
- private record Template(BrandTheme theme) implements DocumentTemplate {
+ private record Template(BrandTheme theme) implements ModularCvTemplate {
@Override
public String id() {
@@ -121,6 +123,13 @@ public String displayName() {
return DISPLAY_NAME;
}
+ @Override
+ public CvRenderKit kit() {
+ // This preset renders bodies through the shared dispatcher, so a
+ // runtime module already looks like the rest of its document.
+ return CvRenderKit.defaults();
+ }
+
@Override
public void compose(DocumentSession document, CvDocument doc) {
Objects.requireNonNull(document, "document");
@@ -184,7 +193,7 @@ private void renderBody(SectionBuilder host, CvSection sec) {
}
return;
}
- SectionDispatcher.renderBody(host, sec, theme);
+ SectionDispatcher.renderBody(host, sec, theme, kit());
}
private void renderStackedProject(SectionBuilder host, CvRow row) {
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 f2671c2d..ff0e4c52 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
@@ -246,10 +246,45 @@ private void renderDetailBody(SectionBuilder host, CvSection section) {
// runtime ModuleSection. The canonical dispatcher renders it
// rather than the render failing on a section the author
// legitimately added.
- SectionDispatcher.renderBody(host, section, theme);
+ SectionDispatcher.renderBody(host, section, theme, kit());
}
}
+ /**
+ * This preset's own drawing, so a runtime module routed into one
+ * of its modules gets the serif entry and the project row the
+ * rest of the document uses.
+ *
+ * Not a {@code ModularCvTemplate}: this preset composes six
+ * fixed modules and finds each by matching headings, so a section
+ * it does not recognise never reaches a renderer at all. Drawing
+ * modules well and rendering every module are different promises,
+ * and it can only make the first.
+ *
+ * Built per call rather than cached: a record's methods are
+ * only reachable from an instance, and the kit closes over this
+ * template's theme.
+ */
+ private CvRenderKit kit() {
+ return new CvRenderKit() {
+
+ @Override
+ public void entry(SectionBuilder host, CvEntry entry, BrandTheme unused) {
+ renderEntry(host, entry);
+ }
+
+ @Override
+ public void row(SectionBuilder host, CvRow row, RowStyle style,
+ BrandTheme unused) {
+ if (style == RowStyle.BULLETED_STACKED) {
+ renderProject(host, row);
+ return;
+ }
+ renderKeyValue(host, row);
+ }
+ };
+ }
+
private void renderEntries(SectionBuilder host, EntriesSection entries) {
for (int i = 0; i < entries.entries().size(); i++) {
if (i > 0) {
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java
new file mode 100644
index 00000000..a6d6417f
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java
@@ -0,0 +1,167 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.api.ModularCvTemplate;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+/**
+ * Every shipped CV preset, by the id it publishes.
+ *
+ * A preset is a class, and picking one at compile time is a constructor
+ * call. Picking one at runtime — from a dropdown, a config file, a
+ * request field — is a lookup, and until now every caller wrote its own:
+ * a switch, a map, a list that has to be remembered when a preset ships.
+ * The consumer this model exists for keeps exactly such a map in another
+ * repository, where nothing tells it a preset was added.
+ *
+ * {@code
+ * CvTemplates.byId("modern-professional")
+ * .orElseThrow()
+ * .compose(session, doc);
+ * }
+ *
+ * {@link #modular()} is the list to offer when the document is assembled
+ * at runtime: the presets that promise to render whatever they are handed
+ * (see {@link ModularCvTemplate}). The rest stay in {@link #all()} for
+ * callers who build the canonical sections by hand — they are not lesser
+ * templates, they are templates with a fixed idea of what a CV contains.
+ *
+ * Every lookup builds a fresh template with the preset's own default
+ * theme; a caller wanting a variant calls that preset's
+ * {@code create(BrandTheme)} directly. {@code CvTemplatesCoverageTest} holds
+ * this catalogue to the presets package, so a preset added and not
+ * registered fails the build rather than staying invisible to every runtime
+ * caller.
+ *
+ * @since 2.3.0
+ */
+public final class CvTemplates {
+
+ /**
+ * One catalogue entry. Keeping the id, the margin, and the factory
+ * together is what makes the catalogue impossible to half-update: there
+ * is one list, and a preset is in it or it is not.
+ */
+ private record Preset(String id, double recommendedMargin,
+ Supplier> factory) {
+ }
+
+ /** The catalogue, in the order a gallery shows it. */
+ private static final List PRESETS = List.of(
+ new Preset(ModernProfessional.ID, ModernProfessional.RECOMMENDED_MARGIN,
+ ModernProfessional::create),
+ new Preset(BoxedSections.ID, BoxedSections.RECOMMENDED_MARGIN,
+ BoxedSections::create),
+ new Preset(MinimalUnderlined.ID, MinimalUnderlined.RECOMMENDED_MARGIN,
+ MinimalUnderlined::create),
+ new Preset(Executive.ID, Executive.RECOMMENDED_MARGIN, Executive::create),
+ new Preset(CenteredHeadline.ID, CenteredHeadline.RECOMMENDED_MARGIN,
+ CenteredHeadline::create),
+ new Preset(BlueBanner.ID, BlueBanner.RECOMMENDED_MARGIN, BlueBanner::create),
+ new Preset(ClassicSerif.ID, ClassicSerif.RECOMMENDED_MARGIN, ClassicSerif::create),
+ new Preset(EditorialBlue.ID, EditorialBlue.RECOMMENDED_MARGIN,
+ EditorialBlue::create),
+ new Preset(CompactMono.ID, CompactMono.RECOMMENDED_MARGIN, CompactMono::create),
+ new Preset(EngineeringResume.ID, EngineeringResume.RECOMMENDED_MARGIN,
+ EngineeringResume::create),
+ new Preset(NordicClean.ID, NordicClean.RECOMMENDED_MARGIN, NordicClean::create),
+ new Preset(Panel.ID, Panel.RECOMMENDED_MARGIN, Panel::create),
+ new Preset(TimelineMinimal.ID, TimelineMinimal.RECOMMENDED_MARGIN,
+ TimelineMinimal::create),
+ new Preset(MonogramSidebar.ID, MonogramSidebar.RECOMMENDED_MARGIN,
+ MonogramSidebar::create),
+ new Preset(SidebarPortrait.ID, SidebarPortrait.RECOMMENDED_MARGIN,
+ SidebarPortrait::create),
+ new Preset(MintEditorial.ID, MintEditorial.RECOMMENDED_MARGIN,
+ MintEditorial::create));
+
+ private CvTemplates() {
+ }
+
+ /**
+ * The template published under {@code id}, built with its own default
+ * theme.
+ *
+ * @param id a preset id such as {@code "modern-professional"}; leading
+ * and trailing whitespace is ignored. An unknown or null id
+ * yields an empty result rather than an exception — an id
+ * arriving from a config file or a request is input to
+ * validate, not a programming error
+ * @return the template, or empty when no preset publishes that id
+ */
+ public static Optional> byId(String id) {
+ return find(id).map(preset -> preset.factory().get());
+ }
+
+ /**
+ * Every shipped preset, freshly built, in gallery order.
+ *
+ * @return one template per preset
+ */
+ public static List> all() {
+ List> templates = new ArrayList<>(PRESETS.size());
+ for (Preset preset : PRESETS) {
+ templates.add(preset.factory().get());
+ }
+ return List.copyOf(templates);
+ }
+
+ /**
+ * The presets that render whatever the document hands them — the ones
+ * to offer for a CV assembled at runtime.
+ *
+ * @return one template per preset implementing {@link ModularCvTemplate},
+ * in gallery order
+ */
+ public static List modular() {
+ List templates = new ArrayList<>();
+ for (DocumentTemplate template : all()) {
+ if (template instanceof ModularCvTemplate modular) {
+ templates.add(modular);
+ }
+ }
+ return List.copyOf(templates);
+ }
+
+ /**
+ * Every registered preset id, in gallery order.
+ *
+ * @return the ids {@link #byId(String)} answers to
+ */
+ public static List ids() {
+ List ids = new ArrayList<>(PRESETS.size());
+ for (Preset preset : PRESETS) {
+ ids.add(preset.id());
+ }
+ return List.copyOf(ids);
+ }
+
+ /**
+ * The page margin the preset was designed at, in points — which a caller
+ * needs while building the session, before it has a template.
+ *
+ * @param id a preset id
+ * @return the margin, or empty when no preset publishes that id
+ */
+ public static Optional recommendedMargin(String id) {
+ return find(id).map(Preset::recommendedMargin);
+ }
+
+ private static Optional find(String id) {
+ if (id == null) {
+ return Optional.empty();
+ }
+ String wanted = id.trim();
+ for (Preset preset : PRESETS) {
+ if (preset.id().equals(wanted)) {
+ return Optional.of(preset);
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java
index 5fc39b20..c8cb9efe 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java
@@ -12,6 +12,7 @@
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.api.ModularCvTemplate;
import com.demcha.compose.document.templates.cv.components.*;
import com.demcha.compose.document.templates.cv.data.*;
import com.demcha.compose.document.templates.core.theme.BrandTheme;
@@ -87,7 +88,7 @@ public static DocumentTemplate create(BrandTheme theme) {
return new Template(theme);
}
- private record Template(BrandTheme theme) implements DocumentTemplate {
+ private record Template(BrandTheme theme) implements ModularCvTemplate {
@Override
public String id() {
@@ -121,7 +122,7 @@ public void compose(DocumentSession document, CvDocument doc) {
CvSection section = sections.get(i);
String name = "CvV2EditorialBlue_" + i;
FlowSectionHeader.label(pageFlow, name + "_Title",
- displayTitle(section.title()), width, theme,
+ headingFor(section), width, theme,
sectionTitleStyle(), new DocumentInsets(8, 0, 0, 0),
new DocumentInsets(7, 0, 5, 0),
DocumentInsets.zero(), true);
@@ -152,7 +153,7 @@ private void renderSectionBody(SectionBuilder section, CvSection cvSection,
// would render as nothing at all: an empty heading over blank
// space, which reads as a finished CV that quietly lost a
// section.
- SectionDispatcher.renderBody(section, cvSection, theme);
+ SectionDispatcher.renderBody(section, cvSection, theme, kit());
}
}
@@ -172,6 +173,43 @@ private void renderEntries(SectionBuilder section, EntriesSection entries) {
}
}
+ /**
+ * This preset's own drawing, so a runtime module gets the
+ * editorial entry, project, and key/value shapes.
+ *
+ * Entries take the experience styling. The preset picks
+ * between its experience and education variants by sniffing a
+ * section's heading, which is exactly what a module carries a
+ * role to avoid; until the kit is handed that role, one of the
+ * two has to be the answer, and experience is the shape most
+ * modules take.
+ */
+ @Override
+ public CvRenderKit kit() {
+ return new CvRenderKit() {
+
+ @Override
+ public void entry(SectionBuilder host, CvEntry entry, BrandTheme unused) {
+ renderExperienceEntry(host, entry);
+ }
+
+ @Override
+ public void row(SectionBuilder host, CvRow row, RowStyle style,
+ BrandTheme unused) {
+ if (style == RowStyle.BULLETED_STACKED) {
+ renderProject(host, row);
+ return;
+ }
+ renderKeyValue(host, row);
+ }
+
+ @Override
+ public void paragraph(SectionBuilder host, String text, BrandTheme unused) {
+ renderParagraph(host, text, 1.6);
+ }
+ };
+ }
+
private void renderExperienceEntry(SectionBuilder section, CvEntry entry) {
DocumentTextStyle titleStyle = TextStyles.of(FontName.HELVETICA,
11.0, DocumentTextDecoration.BOLD, NAME_COLOR);
@@ -281,6 +319,21 @@ private void addFooter(PageFlowBuilder pageFlow, double width) {
.margin(DocumentInsets.top(2))));
}
+ /**
+ * The heading to print. A module carries a heading the author
+ * chose and a role that already says what the section is, so it
+ * prints as written; the keyword rename below exists to give the
+ * canonical sections this preset's editorial vocabulary, and
+ * applying it to a module would retitle "Certifications & Awards"
+ * as "EDUCATION" — which is exactly the promise
+ * {@code ModularCvTemplate} makes it must not do.
+ */
+ private String headingFor(CvSection section) {
+ return section instanceof ModuleSection
+ ? section.title().toUpperCase(Locale.ROOT)
+ : displayTitle(section.title());
+ }
+
private String displayTitle(String title) {
String normalized = SectionLookup.normalize(title);
if (normalized.contains("summary") || normalized.contains("profile")) {
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java
index 95ae92d3..7842cdab 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java
@@ -12,7 +12,9 @@
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.api.ModularCvTemplate;
import com.demcha.compose.document.templates.core.text.TextStyles;
+import com.demcha.compose.document.templates.cv.components.CvRenderKit;
import com.demcha.compose.document.templates.cv.components.SectionDispatcher;
import com.demcha.compose.document.templates.cv.data.*;
import com.demcha.compose.document.templates.core.theme.BrandTheme;
@@ -96,7 +98,7 @@ public static DocumentTemplate create(BrandTheme theme) {
return new Template(theme);
}
- private record Template(BrandTheme theme) implements DocumentTemplate {
+ private record Template(BrandTheme theme) implements ModularCvTemplate {
@Override
public String id() {
@@ -108,6 +110,13 @@ public String displayName() {
return DISPLAY_NAME;
}
+ @Override
+ public CvRenderKit kit() {
+ // This preset renders bodies through the shared dispatcher, so a
+ // runtime module already looks like the rest of its document.
+ return CvRenderKit.defaults();
+ }
+
@Override
public void compose(DocumentSession document, CvDocument doc) {
Objects.requireNonNull(document, "document");
@@ -132,7 +141,7 @@ public void compose(DocumentSession document, CvDocument doc) {
ACCENT, theme);
});
flow.addSection("CvV2ExecutiveBody_" + idx, host ->
- SectionDispatcher.renderBody(host, sec, theme));
+ SectionDispatcher.renderBody(host, sec, theme, kit()));
}
flow.build();
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java
index a9320676..720fa082 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java
@@ -3,6 +3,8 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.PageFlowBuilder;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.api.ModularCvTemplate;
+import com.demcha.compose.document.templates.cv.components.CvRenderKit;
import com.demcha.compose.document.templates.cv.components.SectionDispatcher;
import com.demcha.compose.document.templates.cv.data.CvDocument;
import com.demcha.compose.document.templates.cv.data.CvSection;
@@ -83,7 +85,7 @@ public static DocumentTemplate create(BrandTheme theme) {
return new Template(theme);
}
- private record Template(BrandTheme theme) implements DocumentTemplate {
+ private record Template(BrandTheme theme) implements ModularCvTemplate {
@Override
public String id() {
@@ -95,6 +97,13 @@ public String displayName() {
return DISPLAY_NAME;
}
+ @Override
+ public CvRenderKit kit() {
+ // This preset renders bodies through the shared dispatcher, so a
+ // runtime module already looks like the rest of its document.
+ return CvRenderKit.defaults();
+ }
+
@Override
public void compose(DocumentSession document, CvDocument doc) {
Objects.requireNonNull(document, "document");
@@ -123,7 +132,7 @@ public void compose(DocumentSession document, CvDocument doc) {
SectionHeader.underlined(host, sec.title(), theme);
});
pageFlow.addSection("Body_" + idx, host ->
- SectionDispatcher.renderBody(host, sec, theme));
+ SectionDispatcher.renderBody(host, sec, theme, kit()));
}
pageFlow.build();
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java
index b1ab62a9..4c2a8119 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java
@@ -6,6 +6,8 @@
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.api.ModularCvTemplate;
+import com.demcha.compose.document.templates.cv.components.CvRenderKit;
import com.demcha.compose.document.templates.cv.components.SectionDispatcher;
import com.demcha.compose.document.templates.cv.data.CvDocument;
import com.demcha.compose.document.templates.cv.data.CvSection;
@@ -109,7 +111,7 @@ public static DocumentTemplate create(BrandTheme theme) {
return new Template(theme);
}
- private record Template(BrandTheme theme) implements DocumentTemplate {
+ private record Template(BrandTheme theme) implements ModularCvTemplate {
@Override
public String id() {
@@ -121,6 +123,13 @@ public String displayName() {
return DISPLAY_NAME;
}
+ @Override
+ public CvRenderKit kit() {
+ // This preset renders bodies through the shared dispatcher, so a
+ // runtime module already looks like the rest of its document.
+ return CvRenderKit.defaults();
+ }
+
@Override
public void compose(DocumentSession document, CvDocument doc) {
Objects.requireNonNull(document, "document");
@@ -178,7 +187,7 @@ public void compose(DocumentSession document, CvDocument doc) {
SectionHeader.flat(host, sec.title(), SECTION_TITLE_COLOR, theme);
});
pageFlow.addSection("Body_" + idx, host ->
- SectionDispatcher.renderBody(host, sec, theme));
+ SectionDispatcher.renderBody(host, sec, theme, kit()));
}
pageFlow.build();
diff --git a/templates/src/test/java/com/demcha/compose/document/templates/cv/presets/CvTemplatesCoverageTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/presets/CvTemplatesCoverageTest.java
new file mode 100644
index 00000000..3d96b786
--- /dev/null
+++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/presets/CvTemplatesCoverageTest.java
@@ -0,0 +1,149 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The catalogue lists every preset in the package.
+ *
+ * A registry is only useful while it is complete, and the way it stops
+ * being complete is that someone ships a preset and forgets the one line.
+ * Nothing about that fails: the preset works, its tests pass, its example
+ * renders — it is merely invisible to every caller that picks a template by
+ * id, which is the whole audience the catalogue exists for. So the list is
+ * derived from the package rather than trusted, by reading the directory
+ * the presets live in.
+ *
+ * Reading source files rather than scanning the classpath is deliberate:
+ * it needs no reflection dependency, and the failure message can name the
+ * file to add.
+ */
+class CvTemplatesCoverageTest {
+
+ /** The presets package, relative to this module's directory. */
+ private static final Path PRESETS = Path.of(
+ "src/main/java/com/demcha/compose/document/templates/cv/presets");
+
+ @Test
+ void everyPresetInThePackageIsInTheCatalogue() throws IOException {
+ List missing = new ArrayList<>();
+ for (String preset : presetClassNames()) {
+ if (CvTemplates.all().stream().noneMatch(t -> declaredBy(t, preset))) {
+ missing.add(preset);
+ }
+ }
+
+ assertThat(missing)
+ .as("every preset in %s must be registered in CvTemplates — a preset "
+ + "missing from the catalogue is invisible to every caller that "
+ + "picks a template by id", PRESETS)
+ .isEmpty();
+ }
+
+ @Test
+ void theCatalogueListsNothingTwice() {
+ assertThat(CvTemplates.ids()).doesNotHaveDuplicates();
+ assertThat(CvTemplates.all()).hasSameSizeAs(CvTemplates.ids());
+ }
+
+ @Test
+ void everyRegisteredIdResolvesToTheTemplateThatPublishesIt() {
+ for (String id : CvTemplates.ids()) {
+ assertThat(CvTemplates.byId(id))
+ .as("byId(%s) must resolve", id)
+ .isPresent()
+ .get()
+ .extracting(DocumentTemplate::id)
+ .as("byId(%s) must return the template publishing that id", id)
+ .isEqualTo(id);
+ assertThat(CvTemplates.recommendedMargin(id))
+ .as("recommendedMargin(%s) must be known", id)
+ .isPresent();
+ }
+ }
+
+ @Test
+ void anUnknownOrNullIdIsAnEmptyResultNotAnException() {
+ // The id arrives from a config file or a request; a caller validating
+ // input should not have to catch anything.
+ assertThat(CvTemplates.byId("no-such-preset")).isEmpty();
+ assertThat(CvTemplates.byId(null)).isEmpty();
+ assertThat(CvTemplates.byId("")).isEmpty();
+ assertThat(CvTemplates.recommendedMargin("no-such-preset")).isEmpty();
+ }
+
+ @Test
+ void surroundingWhitespaceInAnIdIsIgnored() {
+ assertThat(CvTemplates.byId(" modern-professional "))
+ .get()
+ .extracting(DocumentTemplate::id)
+ .isEqualTo(ModernProfessional.ID);
+ }
+
+ @Test
+ void everyTemplateBuildsAFreshInstance() {
+ // all() hands each caller its own template rather than a shared one,
+ // so a caller cannot be surprised by another's theme.
+ List> first = CvTemplates.all();
+ List> second = CvTemplates.all();
+
+ for (int i = 0; i < first.size(); i++) {
+ assertThat(first.get(i)).isNotSameAs(second.get(i));
+ assertThat(first.get(i).id()).isEqualTo(second.get(i).id());
+ }
+ }
+
+ /**
+ * The class names in the presets package that are actually presets:
+ * public types with a factory. {@code package-info} carries no class, and
+ * {@code ColumnPagination} is a package-private helper rather than a
+ * template, so neither belongs in a catalogue of templates.
+ */
+ private static List presetClassNames() throws IOException {
+ assertThat(PRESETS).as("presets package must exist at %s", PRESETS).exists();
+ try (Stream files = Files.list(PRESETS)) {
+ List names = new ArrayList<>();
+ for (Path file : files.toList()) {
+ String name = file.getFileName().toString();
+ if (!name.endsWith(".java") || name.equals("package-info.java")) {
+ continue;
+ }
+ String simpleName = name.substring(0, name.length() - ".java".length());
+ String source = Files.readString(file);
+ if (source.contains("public final class " + simpleName)
+ && source.contains("public static final String ID")) {
+ names.add(simpleName);
+ }
+ }
+ assertThat(names)
+ .as("the scan must find the presets it is meant to guard")
+ .hasSizeGreaterThan(10);
+ return names;
+ }
+ }
+
+ /**
+ * Whether {@code template} is the one {@code presetClass} publishes,
+ * decided by the id constant that class declares.
+ */
+ private static boolean declaredBy(DocumentTemplate template, String presetClass) {
+ try {
+ Class> type = Class.forName(
+ "com.demcha.compose.document.templates.cv.presets." + presetClass);
+ Object id = type.getField("ID").get(null);
+ return template.id().equals(id);
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError("preset " + presetClass + " must publish a public ID", e);
+ }
+ }
+}