From 13296454787366d46af9e3c9ff0ed76394fcdbab Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 17 Aug 2026 16:07:53 +0100
Subject: [PATCH] feat(templates): declare which presets can be handed a
runtime module
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A module is only worth building 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 comes out, minus a section, looking finished.
Nothing about that failure is visible at the point it happens.
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 map kept
by hand in every consumer. Declaring the interface costs something:
ModularCvTemplateFidelityTest renders 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 one, through each template that
declares it. Seven presets qualify. ClassicSerif does not — it draws any
shape it is given but only gives itself the sections it recognises, and
finding that out is what the gate is for.
The promise covers Slot.MAIN and says so, rather than leaving "renders
whatever it is handed" to be read generously: every shipped preset
composes one main column, so a sidebar section is dropped by these
templates as by every other. The gate pins that too, so the contract and
the code have to change together.
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: what a linked
title looks like, which fields a kind reads, what an empty description
does to a trailing colon are the model's decisions and must not be
re-made sixteen times. BlueBanner, ClassicSerif and EditorialBlue now
draw modules with their own entry and project shapes.
EditorialBlue also stops renaming a module's heading. Its keyword
vocabulary turned "Certifications & Awards" into EDUCATION, which is the
one thing the promise says cannot happen; the canonical sections keep the
rename that gives the preset its voice.
CvTemplatesCoverageTest derives the catalogue from the presets package
rather than trusting it, so a preset that ships unregistered fails the
build instead of being invisible to every caller that looks one up by id.
---
CHANGELOG.md | 34 +++
docs/templates/v2-layered/using-templates.md | 35 +++
.../cv/api/ModularCvTemplateFidelityTest.java | 265 ++++++++++++++++++
.../ModuleSectionKindCoverageTest.java | 77 +----
.../templates/cv/api/ModularCvTemplate.java | 59 ++++
.../templates/cv/components/CvRenderKit.java | 94 +++++++
.../cv/components/ModuleRenderer.java | 58 ++--
.../cv/components/SectionDispatcher.java | 28 +-
.../templates/cv/presets/BlueBanner.java | 35 ++-
.../templates/cv/presets/BoxedSections.java | 13 +-
.../cv/presets/CenteredHeadline.java | 13 +-
.../templates/cv/presets/ClassicSerif.java | 37 ++-
.../templates/cv/presets/CvTemplates.java | 167 +++++++++++
.../templates/cv/presets/EditorialBlue.java | 59 +++-
.../templates/cv/presets/Executive.java | 13 +-
.../cv/presets/MinimalUnderlined.java | 13 +-
.../cv/presets/ModernProfessional.java | 13 +-
.../cv/presets/CvTemplatesCoverageTest.java | 149 ++++++++++
18 files changed, 1056 insertions(+), 106 deletions(-)
create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java
create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java
create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java
create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java
create mode 100644 templates/src/test/java/com/demcha/compose/document/templates/cv/presets/CvTemplatesCoverageTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e26d298e3..b13eed6de 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 461bb11ca..c997f6c3b 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 000000000..291e40360
--- /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 a362537da..08defa811 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 000000000..8d25a9f62
--- /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 000000000..5391265ef
--- /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 9df3f1cae..9d2a52726 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 10be3b78b..b450b8e67 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 1857b539d..37287a37c 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 9cbdd108e..269ac35c7 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 0cf25e9b3..bb501bd66 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 f2671c2d0..ff0e4c52b 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 000000000..a6d6417ff
--- /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 5fc39b203..c8cb9efe9 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 95ae92d35..7842cdabf 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 a93206767..720fa0820 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 b1ab62a94..4c2a81191 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 000000000..3d96b7866
--- /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);
+ }
+ }
+}