diff --git a/CHANGELOG.md b/CHANGELOG.md
index eaeda76dd..b13eed6de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,88 @@
All notable changes to GraphCompose are documented here. Versions
follow semantic versioning; release dates are ISO 8601.
-## v2.2.1 — Planned
+## v2.3.0 — Planned
+
+### Public API
+
+- **A CV section whose shape is a value, for CVs assembled at runtime.** The four
+ section records each fix one shape at compile time, which is right when a CV is
+ written in Java — you pick the record, the compiler checks it. It is the wrong model
+ when the CV arrives as data: a user who has just chosen "Volunteering, shaped like
+ Education, with dates" cannot instantiate a different record per choice, so every
+ shape somebody thought of would have to become a type.
+
+ `ModuleSection` carries the choice instead. One `CvItem` record holds every optional
+ field — title, link, subtitle, period, location, description lines — and a `CvKind`
+ (`PARAGRAPH`, `BULLETS`, `BULLETS_STACKED`, `INLINE_LIST`, `ENTRIES`,
+ `ENTRIES_DATED`) decides which of them are read: the same item renders with or without its dates depending on the kind
+ alone. `BodyStyle` decides whether a description reads as prose or as bullets, and
+ `SectionRole` states what a section *means* — the decision multi-column presets make
+ by matching headings against English keywords, which a CV headed `Ausbildung` or
+ `Навыки` never matches. The presets do not read the role yet; it travels with the
+ section now so a document built today needs no rewrite when the routing work lands.
+
+ The existing four records are untouched and mix with modules in the same document.
+ A module renders through the existing components rather than beside them, so one
+ drawn as `ENTRIES_DATED` lays out exactly like the `EntriesSection` carrying the same
+ content — held node-for-node by a parity suite, for every kind, alongside the
+ extracted text so structure and content are both pinned. The addition is binary-
+ compatible (the japicmp gate covers this module); it is a fifth permit on a sealed
+ 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.**
+ `BlueBanner` and `ClassicSerif` each kept a private copy of the section dispatcher
+ whose final `else` threw `IllegalStateException`; `EditorialBlue`'s had no `else` at
+ all; and `SectionLookup.hasContent` — which presets consult *before* routing, and
+ which `SectionAllocation.remaining()` uses to decide what still needs a home —
+ answered `false` for any subtype it had not been taught, dropping the heading along
+ with the body. So a section type added to the model would have crashed two presets
+ and vanished from several more, including through the very fallback that exists to
+ catch unplaced sections. All three dispatchers now delegate unfamiliar shapes to the
+ canonical one, and `hasContent` answers for every permit.
+
+- **An entry with no date no longer reserves a column for it.** `EntryRenderer` always
+ emitted the two-column title/date header, so an undated entry — a certification, a
+ project — had its title wrapped early to leave room for nothing. Its Javadoc had
+ described the collapsing behaviour since the entry renderer was written. No shipped
+ fixture has a blank date, so no existing render moves.
### Build
diff --git a/README.md b/README.md
index fb97aa3c0..e63ee3bc8 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
> **Release status** —
> 🟢 **Latest stable**: [v2.2.0](https://github.com/DemchaAV/GraphCompose/releases/tag/v2.2.0) — the **right-to-left** release: Hebrew and Arabic lay out, shape, join and mirror through PDF, PowerPoint and Word — in paragraphs and in table cells — with the fonts to render them. See [CHANGELOG.md](./CHANGELOG.md).
-> · 🟡 **In development**: v2.2.1 on `develop` — see [CHANGELOG.md](./CHANGELOG.md).
+> · 🟡 **In development**: v2.3.0 on `develop` — see [CHANGELOG.md](./CHANGELOG.md).
Live Showcase
diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml
index 1ad6ce226..fb743b64f 100644
--- a/benchmarks/pom.xml
+++ b/benchmarks/pom.xml
@@ -7,7 +7,7 @@
io.github.demchaav
graph-compose-build
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
../pom.xml
diff --git a/bundle/pom.xml b/bundle/pom.xml
index b4f4b508b..4a6e658d4 100644
--- a/bundle/pom.xml
+++ b/bundle/pom.xml
@@ -32,7 +32,7 @@
graph-compose and graph-compose-templates dependencies below use
${project.version}, so they follow automatically.
-->
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
jar
GraphCompose Bundle
diff --git a/core/pom.xml b/core/pom.xml
index 2d53ee4cf..25cdb4bd4 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -6,7 +6,7 @@
io.github.demchaav
graph-compose-core
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
GraphCompose Core
A declarative layout engine for programmatic document generation, implemented primarily in Java. This is the lean engine coordinate; depend on the `graph-compose` artifact for the drop-in, PDF-capable install.
diff --git a/coverage/pom.xml b/coverage/pom.xml
index c8ffe968f..7386afe42 100644
--- a/coverage/pom.xml
+++ b/coverage/pom.xml
@@ -29,7 +29,7 @@
io.github.demchaav
graph-compose-build
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
../pom.xml
diff --git a/docs/roadmaps/post-2.0-engineering.md b/docs/roadmaps/post-2.0-engineering.md
index e04ba0994..6e5da7a1c 100644
--- a/docs/roadmaps/post-2.0-engineering.md
+++ b/docs/roadmaps/post-2.0-engineering.md
@@ -111,7 +111,7 @@ exercise it. Report-only; thresholds can follow after a baseline read.
`japicmp` ran report-only through the 2.0 major — the major intentionally broke
binary compatibility. With the 2.0.0 GA artifacts on Central the gate switched to
per-module baselines pinned at the major's floor (2.0.0) in break-on-incompatible
-mode: `graph-compose-core` first, `graph-compose-templates` since 2.2.1 — each
+mode: `graph-compose-core` first, `graph-compose-templates` since 2.3.0 — each
module's `japicmp` profile lives in its own pom and runs in the PR-time
`Binary Compatibility` job, in `cut-release.ps1` step 5b, and in the publish
workflow. The remaining published modules (`render-pdf` / `render-docx` /
diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md
index bcfaf31fd..c997f6c3b 100644
--- a/docs/templates/v2-layered/using-templates.md
+++ b/docs/templates/v2-layered/using-templates.md
@@ -19,11 +19,12 @@ it sets up the conceptual model in 5 minutes.
1. [The pieces you assemble](#the-pieces-you-assemble)
2. [Identity — name, contact, optional links](#identity)
3. [Section types](#section-types)
-4. [Slots — main vs sidebar](#slots)
-5. [Picking a preset](#picking-a-preset)
-6. [Customising a theme](#customising-a-theme)
-7. [Rendering — pageSize, margins, output](#rendering)
-8. [Common patterns](#common-patterns)
+4. [Building sections at runtime — `ModuleSection`](#runtime-modules)
+5. [Slots — main vs sidebar](#slots)
+6. [Picking a preset](#picking-a-preset)
+7. [Customising a theme](#customising-a-theme)
+8. [Rendering — pageSize, margins, output](#rendering)
+9. [Common patterns](#common-patterns)
---
@@ -161,6 +162,93 @@ paragraph beneath.
---
+
+## Building sections at runtime — `ModuleSection`
+
+The four types above are the right choice when you write a CV in Java:
+you pick the record and the compiler checks it. They are the wrong one
+when the CV is assembled from data — a form, a JSON payload, an LLM —
+because the shape is not known until it arrives, and a user who picks
+"dated entries" from a menu cannot instantiate a different record per
+choice.
+
+`ModuleSection` moves that choice into a value. One item record carries
+every optional field, and a `CvKind` decides which of them are read:
+
+```java
+ModuleSection.builder("Volunteering", SectionRole.OTHER, CvKind.ENTRIES_DATED)
+ .item(CvItem.of("Mentor, Rails Girls")
+ .at("Rails Girls Berlin") // subtitle
+ .in("Berlin, DE") // location
+ .period("2019 - 2021") // read by dated kinds only
+ .bullets("Ran three weekend workshops"))
+ .build();
+```
+
+| `CvKind` | Shape | Reads |
+|---|---|---|
+| `PARAGRAPH` | prose under the section heading | `body` |
+| `BULLETS` | a bullet per item, description inline | `title`, `body` (not `link`) |
+| `BULLETS_STACKED` | a bullet per item, description underneath | `title`, `link`, `body` |
+| `INLINE_LIST` | `Languages: Java 21, Kotlin` | `title`, `body` (not `link`) |
+| `ENTRIES` | timeline, no date column | everything but `period` |
+| `ENTRIES_DATED` | timeline with dates | everything |
+
+Only `title` is required on an item. Whatever a kind does not read is
+ignored, so the same item renders with or without its dates depending
+on the kind alone — which is what lets a "Volunteering" module be
+shaped exactly like Education without a new type.
+
+`SectionRole` says what a section *means*, separately from how it
+draws. Multi-column presets decide what belongs in a sidebar by
+matching headings against English keywords, which a CV headed
+`Ausbildung` or `Навыки` never matches. The role is where that
+decision belongs — stated by the author, who knows the answer. **The
+presets do not read it yet**: today it travels with the section and is
+the input the routing work will consume, so a document built now needs
+no rewrite when they do.
+
+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()`.
+
+---
+
## Slots — main vs sidebar
diff --git a/examples/pom.xml b/examples/pom.xml
index 18f7234e6..ec908264c 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -7,7 +7,7 @@
io.github.demchaav
graph-compose-build
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
../pom.xml
diff --git a/pom.xml b/pom.xml
index 72b797b8b..09849aeff 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.demchaav
graph-compose-build
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
pom
GraphCompose Build Aggregator
diff --git a/qa/pom.xml b/qa/pom.xml
index 59fd176ce..9d7a002fe 100644
--- a/qa/pom.xml
+++ b/qa/pom.xml
@@ -22,7 +22,7 @@
io.github.demchaav
graph-compose-build
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
../pom.xml
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
new file mode 100644
index 000000000..08defa811
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java
@@ -0,0 +1,243 @@
+package com.demcha.compose.document.templates.cv.components;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.ParagraphNode;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.CvItem;
+import com.demcha.compose.document.templates.cv.data.CvKind;
+import com.demcha.compose.document.templates.cv.data.ModuleSection;
+import com.demcha.compose.document.templates.cv.data.SectionRole;
+import com.demcha.compose.document.templates.cv.presets.BlueBanner;
+import com.demcha.compose.document.templates.cv.presets.BoxedSections;
+import com.demcha.compose.document.templates.cv.presets.CenteredHeadline;
+import com.demcha.compose.document.templates.cv.presets.ClassicSerif;
+import com.demcha.compose.document.templates.cv.presets.CompactMono;
+import com.demcha.compose.document.templates.cv.presets.EditorialBlue;
+import com.demcha.compose.document.templates.cv.presets.EngineeringResume;
+import com.demcha.compose.document.templates.cv.presets.Executive;
+import com.demcha.compose.document.templates.cv.presets.MinimalUnderlined;
+import com.demcha.compose.document.templates.cv.presets.MintEditorial;
+import com.demcha.compose.document.templates.cv.presets.ModernProfessional;
+import com.demcha.compose.document.templates.cv.presets.MonogramSidebar;
+import com.demcha.compose.document.templates.cv.presets.NordicClean;
+import com.demcha.compose.document.templates.cv.presets.Panel;
+import com.demcha.compose.document.templates.cv.presets.SidebarPortrait;
+import com.demcha.compose.document.templates.cv.presets.TimelineMinimal;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.List;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * 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
+ * nothing, and the CV looks finished. Enumerating the enum rather than listing
+ * cases means a kind added later fails here until it is wired, which is the
+ * point — a new constant cannot ship half-rendered.
+ *
+ * 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 {
+
+ /** Presets that render every section the document carries, in order. */
+ private static Stream> generalPresets() {
+ return Stream.of(ModernProfessional.create(), BoxedSections.create(),
+ MinimalUnderlined.create(), Executive.create(),
+ CenteredHeadline.create(), BlueBanner.create());
+ }
+
+ /**
+ * Presets that render whatever section they are handed — the six general
+ * loops plus the two that route by heading and then draw any shape.
+ */
+ private static Stream> presetsThatRenderAnyShape() {
+ return Stream.concat(generalPresets(),
+ Stream.of(ClassicSerif.create(), EditorialBlue.create()));
+ }
+
+ /**
+ * Presets whose module slots are guarded on the section's Java type —
+ * {@code if (!(section instanceof EntriesSection entries)) return;} and
+ * friends — so a module routed to one of those slots is skipped whatever
+ * its kind. Placing them is the routing work, not this change; what is
+ * pinned here is that they do not fail.
+ */
+ private static Stream> presetsThatGuardOnSectionType() {
+ return Stream.of(CompactMono.create(), EngineeringResume.create(),
+ MintEditorial.create(), MonogramSidebar.create(), NordicClean.create(),
+ Panel.create(), SidebarPortrait.create(), TimelineMinimal.create());
+ }
+
+ /** Every shipped CV preset. */
+ private static Stream> everyPreset() {
+ return Stream.concat(presetsThatRenderAnyShape(), presetsThatGuardOnSectionType());
+ }
+
+ @ParameterizedTest
+ @EnumSource(CvKind.class)
+ void everyKindPutsItsDescriptionsOnThePage(CvKind kind) throws Exception {
+ ModuleSection module = ModuleSection.builder("Selected Work", SectionRole.OTHER, kind)
+ .item(CvItem.of("First entry").at("Acme GmbH").in("Berlin")
+ .period("2021 - Present").paragraphs("Did the work."))
+ .item(CvItem.of("Second entry").at("Northwind").period("2018 - 2021")
+ .bullets("Shipped it", "Measured it"))
+ .build();
+
+ String text = render(ModernProfessional.create(), module);
+
+ assertThat(text)
+ .as("%s must render every description line — no kind may drop the body", kind)
+ .contains("Did the work.", "Shipped it", "Measured it");
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = CvKind.class, names = "PARAGRAPH", mode = EnumSource.Mode.EXCLUDE)
+ void everyTitledKindPutsItsTitlesOnThePage(CvKind kind) throws Exception {
+ // PARAGRAPH is excluded on purpose, not overlooked: it renders prose under
+ // the section's own heading and documents that it reads the body alone —
+ // the case below pins that, so the two together cover the whole enum.
+ ModuleSection module = ModuleSection.builder("Selected Work", SectionRole.OTHER, kind)
+ .item(CvItem.of("First entry").paragraphs("Did the work."))
+ .item(CvItem.of("Second entry").period("2018").paragraphs("Did more."))
+ .build();
+
+ assertThat(render(ModernProfessional.create(), module))
+ .as("%s reads the item title, so it must reach the page", kind)
+ .contains("First entry", "Second entry");
+ }
+
+ @Test
+ void proseRendersWithoutRepeatingTheHeading() throws Exception {
+ ModuleSection module = ModuleSection.summary("Profile", "Backend engineer.");
+
+ String text = render(ModernProfessional.create(), module);
+
+ assertThat(text).contains("Backend engineer.");
+ assertThat(text.split("Profile", -1))
+ .as("the heading is the section's; PARAGRAPH must not print it a second time")
+ .hasSize(2);
+ }
+
+ @ParameterizedTest
+ @MethodSource("presetsThatRenderAnyShape")
+ void aModuleUnderAHeadingThePresetKnowsIsRendered(DocumentTemplate preset)
+ throws Exception {
+ // Stronger than "does not throw", and the case that catches the failure
+ // no-throw cannot see: presets consult SectionLookup.hasContent before they
+ // route or render, and its default for an unrecognised subtype is false —
+ // which discarded the module's heading and body together, on presets whose
+ // rendering path for it was perfectly good. The heading here is one every
+ // preset's keyword list contains, so nothing but that gate can lose it.
+ ModuleSection module = ModuleSection.builder("Professional Experience",
+ SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED)
+ .item(CvItem.of("Senior Backend Engineer").at("Acme GmbH")
+ .period("2021 - Present").paragraphs("Owned the settlement service."))
+ .build();
+
+ assertThat(render(preset, module))
+ .as("%s must render a module it routed by heading", preset.id())
+ // 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
+ void aModuleAndTheOlderSectionTypesCoexistInOneDocument() throws Exception {
+ // The new permit is additive: a document may mix a runtime module with
+ // the hand-written records, which is what a migration looks like.
+ CvDocument doc = CvDocument.builder()
+ .identity(identity())
+ .section(ModuleSection.summary("Profile", "Backend engineer."))
+ .section(com.demcha.compose.document.templates.cv.data.EntriesSection
+ .builder("Education")
+ .entry("BSc Computer Science", "TU Berlin", "2014 - 2018", "")
+ .build())
+ .section(ModuleSection.builder("Volunteering", SectionRole.OTHER, CvKind.BULLETS)
+ .item("Rails Girls mentor")
+ .build())
+ .build();
+
+ assertThat(renderDocument(ModernProfessional.create(), doc))
+ .contains("Backend engineer.", "BSc Computer Science", "Rails Girls mentor");
+ }
+
+ // -- helpers ---------------------------------------------------------
+
+ private static String render(DocumentTemplate preset, ModuleSection module)
+ throws Exception {
+ return renderDocument(preset, CvDocument.builder()
+ .identity(identity())
+ .sections(List.of(module))
+ .build());
+ }
+
+ private static String renderDocument(DocumentTemplate preset, CvDocument doc)
+ throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(24, 24, 24, 24)
+ .create()) {
+ preset.compose(session, doc);
+ try (PDDocument pdf = Loader.loadPDF(session.toPdfBytes())) {
+ return new PDFTextStripper().getText(pdf).replaceAll("\\s+", " ").trim();
+ }
+ }
+ }
+
+ /**
+ * Every string the composed layout carries, joined — the text before a font
+ * gets a say in whether it can draw it.
+ */
+ private static String composedText(DocumentTemplate preset, ModuleSection module) {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(24, 24, 24, 24)
+ .create()) {
+ preset.compose(session, CvDocument.builder()
+ .identity(identity()).sections(List.of(module)).build());
+ 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) {
+ // text() carries the plain string AND the concatenation of any
+ // rich runs, so it sees both a header written with .text(...) and
+ // a body assembled from markdown runs.
+ out.append(paragraph.text()).append(' ');
+ }
+ collectText(node.children(), out);
+ }
+ }
+
+ private static CvIdentity identity() {
+ return CvIdentity.builder()
+ .name("Jordan", "Rivera")
+ .jobTitle("Backend Engineer")
+ .contact("+1 555 0100", "jordan@example.com", "Berlin, DE")
+ .build();
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionParityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionParityTest.java
new file mode 100644
index 000000000..eb82d249a
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionParityTest.java
@@ -0,0 +1,349 @@
+package com.demcha.compose.document.templates.cv.components;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.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.CvSection;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.data.ModuleSection;
+import com.demcha.compose.document.templates.cv.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.data.RowStyle;
+import com.demcha.compose.document.templates.cv.data.RowsSection;
+import com.demcha.compose.document.templates.cv.data.SectionRole;
+import com.demcha.compose.document.templates.cv.presets.ModernProfessional;
+import com.demcha.compose.testing.layout.LayoutSnapshotJson;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A {@code ModuleSection} chosen at runtime must lay out exactly like the
+ * section a Java author would have written by hand for the same content.
+ *
+ * That equivalence is the whole basis of the runtime module: it renders
+ * through the existing components rather than beside them, so the two
+ * authoring routes are two spellings of one document. Left unchecked it is a
+ * claim in a Javadoc, and the failure it hides is silent — a module that
+ * merely looks close, on a preset nobody re-renders, in a CV nobody compares
+ * side by side.
+ *
+ * Each case pins both halves of "the same": the layout snapshot, which
+ * carries node structure and bounds but not text, and the extracted PDF text,
+ * which carries the words but not their positions. Either alone passes
+ * documents the other would catch.
+ */
+class ModuleSectionParityTest {
+
+ @Test
+ void datedEntriesMatchAHandWrittenEntriesSection() throws Exception {
+ CvSection handWritten = EntriesSection.builder("Professional Experience")
+ .entry("Senior Backend Engineer", "Acme GmbH", "2021 - Present",
+ "Cut p99 latency by 40%.")
+ .entry("Backend Engineer", "Northwind Systems", "2018 - 2021",
+ "Owned the billing service.")
+ .build();
+
+ CvSection module = ModuleSection.builder("Professional Experience",
+ SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED)
+ .item(CvItem.of("Senior Backend Engineer").at("Acme GmbH")
+ .period("2021 - Present").paragraphs("Cut p99 latency by 40%."))
+ .item(CvItem.of("Backend Engineer").at("Northwind Systems")
+ .period("2018 - 2021").paragraphs("Owned the billing service."))
+ .build();
+
+ assertSameRender(handWritten, module);
+ }
+
+ @Test
+ void anInlineListMatchesAHandWrittenPlainRowsSection() throws Exception {
+ CvSection handWritten = RowsSection.builder("Additional Information", RowStyle.PLAIN)
+ .row("Languages", "English (Fluent), German (B2)")
+ .row("Interests", "Chess, long-distance cycling")
+ .build();
+
+ CvSection module = ModuleSection.builder("Additional Information",
+ SectionRole.OTHER, CvKind.INLINE_LIST)
+ .item(CvItem.of("Languages").paragraphs("English (Fluent)", "German (B2)"))
+ .item(CvItem.of("Interests").paragraphs("Chess", "long-distance cycling"))
+ .build();
+
+ assertSameRender(handWritten, module);
+ }
+
+ @Test
+ void oneLineBulletsMatchAHandWrittenBulletedRowsSection() throws Exception {
+ CvSection handWritten = RowsSection.builder("Highlights", RowStyle.BULLETED)
+ .row("Throughput", "Doubled it")
+ .row("Onboarding", "Cut to two days")
+ .build();
+
+ CvSection module = ModuleSection.builder("Highlights", SectionRole.OTHER, CvKind.BULLETS)
+ .item(CvItem.of("Throughput").paragraphs("Doubled it"))
+ .item(CvItem.of("Onboarding").paragraphs("Cut to two days"))
+ .build();
+
+ assertSameRender(handWritten, module);
+ }
+
+ @Test
+ void stackedBulletsMatchAHandWrittenStackedRowsSection() throws Exception {
+ CvSection handWritten = RowsSection.builder("Projects", RowStyle.BULLETED_STACKED)
+ .row("GraphCompose (Java 21, PDFBox)",
+ "A declarative layout engine for programmatic documents.")
+ .row("Ledger (Kotlin)", "Double-entry bookkeeping for small studios.")
+ .build();
+
+ // paragraphs(), not bullets(): a stacked row indents its description under
+ // the title, which is what prose does. BodyStyle.BULLETS asks for a bullet
+ // on each description line instead — a different shape, pinned by the case
+ // below rather than smuggled into this comparison.
+ CvSection module = ModuleSection.builder("Projects", SectionRole.PROJECTS,
+ CvKind.BULLETS_STACKED)
+ .item(CvItem.of("GraphCompose (Java 21, PDFBox)")
+ .paragraphs("A declarative layout engine for programmatic documents."))
+ .item(CvItem.of("Ledger (Kotlin)")
+ .paragraphs("Double-entry bookkeeping for small studios."))
+ .build();
+
+ assertSameRender(handWritten, module);
+ }
+
+ @Test
+ void aBulletedBodyNestsABulletUnderTheItemsOwn() throws Exception {
+ CvSection prose = ModuleSection.builder("Projects", SectionRole.PROJECTS,
+ CvKind.BULLETS_STACKED)
+ .item(CvItem.of("GraphCompose").paragraphs("Shipped it", "Measured it"))
+ .build();
+ CvSection bulleted = ModuleSection.builder("Projects", SectionRole.PROJECTS,
+ CvKind.BULLETS_STACKED)
+ .item(CvItem.of("GraphCompose").bullets("Shipped it", "Measured it"))
+ .build();
+
+ assertThat(text(bulleted))
+ .as("BodyStyle.BULLETS must reach the page as bullets, not as indented prose")
+ .isNotEqualTo(text(prose))
+ .contains("• Shipped it", "• Measured it");
+ }
+
+ @Test
+ void proseMatchesAHandWrittenParagraphSection() throws Exception {
+ CvSection handWritten = new ParagraphSection("Professional Summary",
+ "Backend engineer with ten years on payment systems.");
+
+ CvSection module = ModuleSection.summary("Professional Summary",
+ "Backend engineer with ten years on payment systems.");
+
+ assertSameRender(handWritten, module);
+ }
+
+ @Test
+ void undatedEntriesMatchAHandWrittenEntriesSectionWithBlankDates() throws Exception {
+ // The blank-date path is a change to EntryRenderer itself, so pin it the
+ // same way: an undated module and the hand-written section that has always
+ // been able to express one must produce the same layout.
+ CvSection handWritten = EntriesSection.builder("Certifications")
+ .entry("AWS Solutions Architect", "Amazon", "", "")
+ .entry("CKA", "Linux Foundation", "", "")
+ .build();
+
+ CvSection module = ModuleSection.builder("Certifications", SectionRole.OTHER,
+ CvKind.ENTRIES)
+ .item(CvItem.of("AWS Solutions Architect").at("Amazon"))
+ .item(CvItem.of("CKA").at("Linux Foundation"))
+ .build();
+
+ assertSameRender(handWritten, module);
+ }
+
+ @Test
+ void anUndatedEntryDropsTheDateColumnRatherThanReservingIt() throws Exception {
+ // The kind's whole contract is that it ignores the period. Rendering an
+ // empty date column instead would still "ignore" it while narrowing every
+ // title on the page, so pin the shape, not just the absent text.
+ CvSection dated = ModuleSection.builder("Certifications", SectionRole.OTHER,
+ CvKind.ENTRIES_DATED)
+ .item(CvItem.of("AWS Solutions Architect").at("Amazon").period("2024"))
+ .build();
+ CvSection undated = ModuleSection.builder("Certifications", SectionRole.OTHER,
+ CvKind.ENTRIES)
+ .item(CvItem.of("AWS Solutions Architect").at("Amazon").period("2024"))
+ .build();
+
+ assertThat(layoutJson(undated))
+ .as("an undated entry must not lay out like a dated one")
+ .isNotEqualTo(layoutJson(dated));
+ assertThat(text(undated)).contains("AWS Solutions Architect", "Amazon");
+ assertThat(text(undated))
+ .as("the period must not reach the page under CvKind.ENTRIES")
+ .doesNotContain("2024");
+ assertThat(text(dated)).contains("2024");
+ }
+
+ @Test
+ void anItemLinkRendersAsAClickableTitle() throws Exception {
+ CvSection module = ModuleSection.builder("Projects", SectionRole.PROJECTS,
+ CvKind.BULLETS_STACKED)
+ .item(CvItem.of("GraphCompose").linkedTo("https://example.dev/gc")
+ .paragraphs("A layout engine."))
+ .build();
+
+ assertThat(text(module))
+ .as("the link URL is the target, not the visible text")
+ .contains("GraphCompose")
+ .doesNotContain("https://example.dev/gc");
+ assertThat(text(module))
+ .as("markdown markers are instructions, not content — none may reach the page")
+ .doesNotContain("*", "[", "]");
+ assertThat(externalLinkTargets(module)).contains("https://example.dev/gc");
+ }
+
+ @Test
+ void aBracketedTitleNeverLeaksItsUrlAsVisibleText() throws Exception {
+ // The markdown link label admits no brackets, so wrapping this title would
+ // match nothing and print the whole construction. Losing the click target
+ // is the acceptable outcome here; printing the URL is not.
+ CvSection module = ModuleSection.builder("Projects", SectionRole.PROJECTS,
+ CvKind.BULLETS_STACKED)
+ .item(CvItem.of("Ledger [v2]").linkedTo("https://example.dev/ledger")
+ .paragraphs("Double-entry bookkeeping."))
+ .build();
+
+ assertThat(text(module))
+ .contains("Ledger [v2]", "Double-entry bookkeeping.")
+ .doesNotContain("https://example.dev/ledger");
+ }
+
+ @Test
+ void aTitleOnlyBulletHasNoColonPointingAtNothing() throws Exception {
+ CvSection module = ModuleSection.builder("Interests", SectionRole.OTHER, CvKind.BULLETS)
+ .item("Chess")
+ .item("Long-distance cycling")
+ .build();
+
+ assertThat(text(module))
+ .contains("Chess", "Long-distance cycling")
+ .doesNotContain("Chess:", "cycling:");
+ }
+
+ @Test
+ void anInlineListWithNothingToListRendersItsLabelAlone() throws Exception {
+ CvSection module = ModuleSection.builder("Languages", SectionRole.LANGUAGES,
+ CvKind.INLINE_LIST)
+ .item("English")
+ .build();
+
+ assertThat(text(module)).contains("English").doesNotContain("English:");
+ }
+
+ @Test
+ void everyKindIgnoresExactlyTheFieldsItSaysItIgnores() throws Exception {
+ // The contract that makes one item record serve every module is that the
+ // kind decides what is read. Stated in CvKind's Javadoc and the docs table;
+ // pinned here, per kind, by rendering one item that carries everything.
+ CvItem everything = CvItem.of("Item title")
+ .at("SubtitleValue").in("LocationValue").period("PeriodValue")
+ .paragraphs("Body line.");
+
+ assertThat(render(CvKind.PARAGRAPH, everything))
+ .as("PARAGRAPH reads the body alone")
+ .contains("Body line.")
+ .doesNotContain("Item title", "SubtitleValue", "PeriodValue", "LocationValue");
+ assertThat(render(CvKind.BULLETS, everything))
+ .as("BULLETS reads title and body")
+ .contains("Item title", "Body line.")
+ .doesNotContain("SubtitleValue", "PeriodValue", "LocationValue");
+ assertThat(render(CvKind.BULLETS_STACKED, everything))
+ .as("BULLETS_STACKED reads title and body")
+ .contains("Item title", "Body line.")
+ .doesNotContain("SubtitleValue", "PeriodValue", "LocationValue");
+ assertThat(render(CvKind.INLINE_LIST, everything))
+ .as("INLINE_LIST reads title and body")
+ .contains("Item title", "Body line.")
+ .doesNotContain("SubtitleValue", "PeriodValue", "LocationValue");
+ assertThat(render(CvKind.ENTRIES, everything))
+ .as("ENTRIES reads everything but the period")
+ .contains("Item title", "SubtitleValue", "LocationValue", "Body line.")
+ .doesNotContain("PeriodValue");
+ assertThat(render(CvKind.ENTRIES_DATED, everything))
+ .as("ENTRIES_DATED reads every field")
+ .contains("Item title", "SubtitleValue", "LocationValue", "PeriodValue",
+ "Body line.");
+ }
+
+ private static String render(CvKind kind, CvItem item) throws Exception {
+ return text(ModuleSection.of("Section", SectionRole.OTHER, kind, item));
+ }
+
+ // -- helpers ---------------------------------------------------------
+
+ private static void assertSameRender(CvSection handWritten, CvSection module) throws Exception {
+ assertThat(layoutJson(module))
+ .as("a runtime module must lay out node-for-node like the hand-written section")
+ .isEqualTo(layoutJson(handWritten));
+ assertThat(text(module))
+ .as("...and carry the same words: the snapshot above compares structure, not content")
+ .isEqualTo(text(handWritten));
+ }
+
+ private static String layoutJson(CvSection section) throws Exception {
+ try (DocumentSession session = newSession()) {
+ ModernProfessional.create().compose(session, docWith(section));
+ return LayoutSnapshotJson.toJson(session.layoutSnapshot());
+ }
+ }
+
+ private static String text(CvSection section) throws Exception {
+ try (DocumentSession session = newSession()) {
+ ModernProfessional.create().compose(session, docWith(section));
+ try (PDDocument pdf = Loader.loadPDF(session.toPdfBytes())) {
+ return new PDFTextStripper().getText(pdf).replaceAll("\\s+", " ").trim();
+ }
+ }
+ }
+
+ private static java.util.List externalLinkTargets(CvSection section) throws Exception {
+ try (DocumentSession session = newSession()) {
+ ModernProfessional.create().compose(session, docWith(section));
+ try (PDDocument pdf = Loader.loadPDF(session.toPdfBytes())) {
+ java.util.List targets = new java.util.ArrayList<>();
+ for (org.apache.pdfbox.pdmodel.PDPage page : pdf.getPages()) {
+ for (org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation annotation
+ : page.getAnnotations()) {
+ if (annotation instanceof org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink link
+ && link.getAction()
+ instanceof org.apache.pdfbox.pdmodel.interactive.action.PDActionURI uri) {
+ targets.add(uri.getURI());
+ }
+ }
+ }
+ return targets;
+ }
+ }
+ }
+
+ private static CvDocument docWith(CvSection section) {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Jordan", "Rivera")
+ .jobTitle("Backend Engineer")
+ .contact("+1 555 0100", "jordan@example.com", "Berlin, DE")
+ .build())
+ .section(section)
+ .build();
+ }
+
+ private static DocumentSession newSession() {
+ float margin = (float) ModernProfessional.RECOMMENDED_MARGIN;
+ return GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(margin, margin, margin, margin)
+ .create();
+ }
+}
diff --git a/render-docx/pom.xml b/render-docx/pom.xml
index 06bf51427..535f40aac 100644
--- a/render-docx/pom.xml
+++ b/render-docx/pom.xml
@@ -19,7 +19,7 @@
-->
io.github.demchaav
graph-compose-render-docx
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
GraphCompose Render — DOCX
Semantic DOCX export backend for GraphCompose, backed by Apache POI.
diff --git a/render-pdf/pom.xml b/render-pdf/pom.xml
index 97a4eb8e5..ebe0860b6 100644
--- a/render-pdf/pom.xml
+++ b/render-pdf/pom.xml
@@ -25,7 +25,7 @@
-->
io.github.demchaav
graph-compose-render-pdf
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
GraphCompose Render — PDF
The PDFBox-backed PDF render backend for GraphCompose.
diff --git a/render-pptx/pom.xml b/render-pptx/pom.xml
index f231910b0..e856b0a4a 100644
--- a/render-pptx/pom.xml
+++ b/render-pptx/pom.xml
@@ -19,7 +19,7 @@
-->
io.github.demchaav
graph-compose-render-pptx
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
GraphCompose Render — PPTX
PPTX render backends for GraphCompose: the coordinate-exact fixed-layout backend (POI XSLF) and the slide-safe semantic export skeleton.
diff --git a/templates/pom.xml b/templates/pom.xml
index bb95bf1ea..5c8822d6e 100644
--- a/templates/pom.xml
+++ b/templates/pom.xml
@@ -17,7 +17,7 @@
-->
io.github.demchaav
graph-compose-templates
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
GraphCompose Templates
Built-in CV, cover-letter, invoice, and proposal document templates for GraphCompose.
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/EntryRenderer.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java
index 160302a03..f703bef09 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java
@@ -44,28 +44,39 @@ public static void render(SectionBuilder section, CvEntry entry, BrandTheme them
DocumentTextStyle subtitleStyle = theme.entrySubtitleStyle();
DocumentTextStyle bodyStyle = theme.bodyStyle();
- // -- title + date row -------------------------------------------
- // The two-column header is a row layout, not a paragraph, so it
- // does not go through ParagraphPrimitive — its DSL shape is
- // genuinely different.
- section.addRow("CvV2EntryHeader", row -> row
- .spacing(theme.spacing().entryHeaderRowSpacing())
- .weights(theme.spacing().entryTitleWeight(),
- theme.spacing().entryDateWeight())
- .addSection("Title", titleColumn -> titleColumn
- .padding(DocumentInsets.zero())
- .addParagraph(p -> p
- .textStyle(titleStyle)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.zero())
- .rich(rich -> MarkdownInline.append(rich, entry.title(), titleStyle))))
- .addSection("Date", dateColumn -> dateColumn
- .padding(DocumentInsets.zero())
- .addParagraph(p -> p
- .text(entry.date())
- .textStyle(dateStyle)
- .align(TextAlign.RIGHT)
- .margin(DocumentInsets.zero()))));
+ // -- title (+ date) header --------------------------------------
+ // With a date this is a two-column row, not a paragraph, so it does
+ // not go through ParagraphPrimitive — its DSL shape is genuinely
+ // different. Without one the row is dropped entirely rather than
+ // reserving an empty column: an undated entry — a certification, a
+ // project, anything a runtime module renders without dates — would
+ // otherwise have its title wrapped early to leave room for nothing.
+ if (entry.date().isBlank()) {
+ section.addParagraph(p -> p
+ .textStyle(titleStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero())
+ .rich(rich -> MarkdownInline.append(rich, entry.title(), titleStyle)));
+ } else {
+ section.addRow("CvV2EntryHeader", row -> row
+ .spacing(theme.spacing().entryHeaderRowSpacing())
+ .weights(theme.spacing().entryTitleWeight(),
+ theme.spacing().entryDateWeight())
+ .addSection("Title", titleColumn -> titleColumn
+ .padding(DocumentInsets.zero())
+ .addParagraph(p -> p
+ .textStyle(titleStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero())
+ .rich(rich -> MarkdownInline.append(rich, entry.title(), titleStyle))))
+ .addSection("Date", dateColumn -> dateColumn
+ .padding(DocumentInsets.zero())
+ .addParagraph(p -> p
+ .text(entry.date())
+ .textStyle(dateStyle)
+ .align(TextAlign.RIGHT)
+ .margin(DocumentInsets.zero()))));
+ }
// -- italic subtitle --------------------------------------------
if (!entry.subtitle().isBlank()) {
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
new file mode 100644
index 000000000..9d2a52726
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java
@@ -0,0 +1,230 @@
+package com.demcha.compose.document.templates.cv.components;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.core.theme.BrandTheme;
+import com.demcha.compose.document.templates.cv.data.BodyStyle;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvItem;
+import com.demcha.compose.document.templates.cv.data.CvRow;
+import com.demcha.compose.document.templates.cv.data.ModuleSection;
+import com.demcha.compose.document.templates.cv.data.RowStyle;
+
+import java.util.List;
+
+/**
+ * Renders a {@link ModuleSection} by lowering it onto the renderers
+ * this package already ships.
+ *
+ * Nothing here draws. Each {@code CvKind} is a rule for turning
+ * {@link CvItem}s into the inputs {@link ParagraphRenderer},
+ * {@link RowRenderer} and {@link EntryRenderer} already take, which is
+ * what makes a runtime-assembled module and a hand-written
+ * {@code EntriesSection} carrying the same content lay out the same
+ * way — a property the parity suite checks node for node rather than
+ * by eye.
+ *
+ * The lowering is also where a kind's documented indifference
+ * happens: {@code ENTRIES} builds its {@link CvEntry} with a blank
+ * date, so an item's {@code period} reaches no renderer at all. Every
+ * field a kind ignores is dropped here, in one place, rather than by
+ * each renderer deciding what to skip.
+ */
+public final class ModuleRenderer {
+
+ private ModuleRenderer() {
+ }
+
+ /**
+ * 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, 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);
+ }
+ }
+ }
+
+ /**
+ * Prose: one paragraph per body line, the title left out (see
+ * {@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,
+ CvRenderKit kit) {
+ for (String line : item.body()) {
+ if (item.bodyStyle() == BodyStyle.BULLETS) {
+ bulletedLine(host, line, theme.bodyStyle(), theme);
+ } else {
+ kit.paragraph(host, line, theme);
+ }
+ }
+ }
+
+ /**
+ * One line per item — bold label, the description collapsed into a
+ * comma-separated run after it. An item with nothing to list renders
+ * as its label alone: {@link RowStyle#PLAIN} would leave a colon
+ * pointing at nothing.
+ */
+ 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
+ // asterisks.
+ if (item.body().isEmpty()) {
+ ParagraphPrimitive.writeBody(host, item.title(), theme.bodyBoldStyle(), theme);
+ return;
+ }
+ kit.row(host, new CvRow(item.title(), String.join(", ", item.body())),
+ RowStyle.PLAIN, theme);
+ }
+
+ /**
+ * A bullet whose description shares its line
+ * ({@link RowStyle#BULLETED}).
+ *
+ * The title goes in unlinked. This row bolds its label by wrapping
+ * it in markdown markers, which would nest around link markup and
+ * reach the page as literal asterisks; a module whose titles are
+ * 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,
+ 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.
+ ParagraphPrimitive.writeBulleted(host, item.title(), theme.bodyBoldStyle(),
+ theme.decoration().bulletGlyph(),
+ DocumentInsets.top((float) theme.spacing().paragraphMarginTop()), theme);
+ return;
+ }
+ kit.row(host, new CvRow(item.title(), String.join(" ", item.body())),
+ RowStyle.BULLETED, theme);
+ }
+
+ /**
+ * A bullet whose description is stacked underneath and indented to
+ * the title ({@link RowStyle#BULLETED_STACKED}).
+ */
+ private static void stackedBullet(SectionBuilder host, CvItem item, BrandTheme theme,
+ 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());
+ }
+ 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
+ ? theme.decoration().stackedIndent() + theme.decoration().bulletGlyph()
+ : theme.decoration().stackedIndent();
+ for (String line : item.body()) {
+ ParagraphPrimitive.writeBulleted(host, line, theme.bodyStyle(),
+ glyph, DocumentInsets.zero(), theme);
+ }
+ }
+
+ /**
+ * A timeline entry. The header goes through {@link EntryRenderer}
+ * with an empty body so the title / date / subtitle zones are the
+ * ones every other entry uses; the description follows underneath
+ * in the style the item asked for.
+ */
+ private static void entry(SectionBuilder host, CvItem item, String date,
+ BrandTheme theme, CvRenderKit kit, boolean separate) {
+ if (separate) {
+ host.spacer(0, theme.spacing().entrySeparation());
+ }
+ kit.entry(host,
+ new CvEntry(linkedTitle(item), subtitleWithLocation(item), date, ""), theme);
+ for (String line : item.body()) {
+ if (item.bodyStyle() == BodyStyle.BULLETS) {
+ bulletedLine(host, line, theme.bodyStyle(), theme);
+ } else {
+ ParagraphPrimitive.writeBody(host, line, theme.bodyStyle(), theme);
+ }
+ }
+ }
+
+ private static void bulletedLine(SectionBuilder host, String line,
+ DocumentTextStyle style, BrandTheme theme) {
+ ParagraphPrimitive.writeBulleted(host, line, style,
+ theme.decoration().bulletGlyph(),
+ DocumentInsets.top((float) theme.spacing().paragraphMarginTop()), theme);
+ }
+
+ /**
+ * The title, wrapped in markdown link syntax when the item carries a
+ * link. Every renderer here already routes titles through the shared
+ * markdown helper, so this needs no separate link path.
+ *
+ * A title containing a bracket is left alone. The markdown link
+ * pattern's label admits no brackets, so wrapping
+ * {@code "Ledger [v2]"} would match nothing and print the whole
+ * construction — URL included — as visible text. Either the title
+ * already carries its own {@code [text](url)}, which renders as the
+ * link it is, or it is prose with a bracket in it and reaches the
+ * page as written.
+ */
+ private static String linkedTitle(CvItem item) {
+ if (item.link() == null
+ || item.title().indexOf('[') >= 0
+ || item.title().indexOf(']') >= 0) {
+ return item.title();
+ }
+ return "[" + item.title() + "](" + item.link().url() + ")";
+ }
+
+ /**
+ * The italic line under an entry title: subtitle and location joined
+ * when both are present, whichever exists when only one is, blank
+ * when neither — no separator left dangling.
+ */
+ private static String subtitleWithLocation(CvItem item) {
+ if (item.subtitle().isBlank()) {
+ return item.location();
+ }
+ if (item.location().isBlank()) {
+ return item.subtitle();
+ }
+ return item.subtitle() + " · " + item.location();
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java
index c2280c71a..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,8 +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, kit);
} else if (section instanceof EntriesSection e) {
// Timeline entries (Education, Experience) get a spacer
// between items — each entry is a multi-line block
@@ -63,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/components/SectionLookup.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java
index c116eb34f..e5f3a96ce 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java
@@ -50,9 +50,18 @@ public static CvSection firstMatching(List sections,
* sealed {@code CvSection} hierarchy — {@code SectionLookupTest} pins the
* contract.
*
+ * The default is {@code false}, which makes an unlisted subtype
+ * invisible rather than merely unstyled: presets filter on this
+ * before they route or render, so a section this method does not
+ * recognise never reaches a dispatcher at all. Every {@code CvSection}
+ * permit therefore needs a case here — the branch below for
+ * {@code ModuleSection} exists because the fallback dropped the section
+ * heading and body together, on presets that had a perfectly good
+ * rendering path for it.
+ *
* @param section the section to inspect; may be {@code null}
* @return {@code true} if the section has non-empty body, entries,
- * rows, or skill groups
+ * rows, skill groups, or module items
*/
public static boolean hasContent(CvSection section) {
if (section instanceof ParagraphSection paragraph) {
@@ -67,6 +76,9 @@ public static boolean hasContent(CvSection section) {
if (section instanceof SkillsSection skills) {
return skills.groups() != null && !skills.groups().isEmpty();
}
+ if (section instanceof ModuleSection module) {
+ return !module.items().isEmpty();
+ }
return false;
}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/BodyStyle.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/BodyStyle.java
new file mode 100644
index 000000000..8d927a479
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/BodyStyle.java
@@ -0,0 +1,28 @@
+package com.demcha.compose.document.templates.cv.data;
+
+/**
+ * How one {@link CvItem}'s description lines render — the second,
+ * smaller axis next to {@link CvKind}.
+ *
+ * The kind decides the item's shape (a bullet, a dated entry, a
+ * line in a list); this decides what happens to
+ * {@link CvItem#body()} inside it. The same experience entry can list
+ * its achievements as bullets or read as a paragraph without changing
+ * the module's kind, which is the distinction authors actually make
+ * when they say "this section is bulleted".
+ *
+ * @since 2.3.0
+ */
+public enum BodyStyle {
+
+ /**
+ * Each body line is a paragraph of prose. The default: an item
+ * built without a stated style reads as text.
+ */
+ PARAGRAPH,
+
+ /**
+ * Each body line carries a bullet glyph and a hanging indent.
+ */
+ BULLETS
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java
new file mode 100644
index 000000000..db0f1716a
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java
@@ -0,0 +1,189 @@
+package com.demcha.compose.document.templates.cv.data;
+
+import com.demcha.compose.document.templates.core.identity.Link;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * One entry inside a {@link ModuleSection} — the universal record every
+ * runtime-assembled module is built from.
+ *
+ * A job, a degree, a project, a skill category, a paragraph of a
+ * summary: all of them are a title plus some optional context plus a
+ * description. Rather than a record per shape, this carries every
+ * optional field and lets the section's {@link CvKind} decide which
+ * ones it reads — a {@code period} is drawn by
+ * {@link CvKind#ENTRIES_DATED} and ignored by {@link CvKind#ENTRIES},
+ * with the same item on both sides. Each kind documents exactly what
+ * it reads.
+ *
+ * Only {@code title} is required, and only because a module entry
+ * with nothing to name it has nothing to render. Everything else is
+ * blank, {@code null}, or empty when the author has nothing to say —
+ * no placeholder text, no {@code "—"} stand-ins.
+ *
+ * Build one through {@link #of(String)} and the {@code with}-style
+ * methods, which read in the order the fields render:
+ *
+ * {@code
+ * CvItem.of("Senior Backend Engineer")
+ * .at("Acme GmbH")
+ * .in("Berlin, DE")
+ * .period("2021 - Present")
+ * .bullets("Cut p99 latency 40%", "Led the payments migration");
+ * }
+ *
+ * @param title what the entry is called; required, non-blank. May
+ * carry inline markdown, including {@code [text](url)}
+ * @param link optional click target for the title; {@code null}
+ * when the title is not a link. A {@code link} and a
+ * markdown link inside {@code title} do the same job —
+ * prefer this one, which needs no escaping
+ * @param subtitle employer, institution, client; blank when absent
+ * @param period date or range as the author wants it written
+ * ({@code "2021 - Present"}, {@code "2019"}); blank
+ * when absent, and read only by dated kinds
+ * @param location city, country, or "Remote"; blank when absent
+ * @param body description lines; empty when the entry is a
+ * heading only. One line renders as one paragraph or
+ * one bullet, per {@code bodyStyle}
+ * @param bodyStyle whether {@code body} reads as prose or as bullets
+ * @since 2.3.0
+ */
+public record CvItem(String title, Link link, String subtitle, String period,
+ String location, List body, BodyStyle bodyStyle) {
+
+ /**
+ * Validates the required {@code title}, normalises every optional
+ * text field from {@code null} to blank, drops null or blank body
+ * lines, and defensively copies the body list.
+ */
+ public CvItem {
+ Objects.requireNonNull(title, "title");
+ if (title.isBlank()) {
+ throw new IllegalArgumentException("title must not be blank");
+ }
+ title = title.trim();
+ subtitle = subtitle == null ? "" : subtitle.trim();
+ period = period == null ? "" : period.trim();
+ location = location == null ? "" : location.trim();
+ bodyStyle = bodyStyle == null ? BodyStyle.PARAGRAPH : bodyStyle;
+
+ List cleaned = new ArrayList<>(body == null ? 0 : body.size());
+ if (body != null) {
+ for (String line : body) {
+ if (line != null && !line.isBlank()) {
+ cleaned.add(line.trim());
+ }
+ }
+ }
+ body = List.copyOf(cleaned);
+ }
+
+ /**
+ * An item with nothing but its title. Chain the {@code with}-style
+ * methods below to add what the entry actually has.
+ *
+ * @param title what the entry is called; required, non-blank
+ * @return a new item carrying only {@code title}
+ */
+ public static CvItem of(String title) {
+ return new CvItem(title, null, "", "", "", List.of(), BodyStyle.PARAGRAPH);
+ }
+
+ /**
+ * Returns a copy whose title links to {@code url}.
+ *
+ * @param url click target; blank or null clears the link
+ * @return a copy carrying the link
+ */
+ public CvItem linkedTo(String url) {
+ Link target = url == null || url.isBlank() ? null : Link.of(title, url);
+ return new CvItem(title, target, subtitle, period, location, body, bodyStyle);
+ }
+
+ /**
+ * Returns a copy with the employer / institution / client line.
+ *
+ * @param value subtitle text; null or blank leaves the line out
+ * @return a copy carrying the subtitle
+ */
+ public CvItem at(String value) {
+ return new CvItem(title, link, value, period, location, body, bodyStyle);
+ }
+
+ /**
+ * Returns a copy with the location line.
+ *
+ * @param value city, country, or "Remote"; null or blank leaves it out
+ * @return a copy carrying the location
+ */
+ public CvItem in(String value) {
+ return new CvItem(title, link, subtitle, period, value, body, bodyStyle);
+ }
+
+ /**
+ * Returns a copy with the date or range, written as the author
+ * wants it. Read only by {@link CvKind#ENTRIES_DATED}.
+ *
+ * @param value date or range; null or blank leaves it out
+ * @return a copy carrying the period
+ */
+ public CvItem period(String value) {
+ return new CvItem(title, link, subtitle, value, location, body, bodyStyle);
+ }
+
+ /**
+ * Returns a copy whose description reads as prose, one paragraph
+ * per line.
+ *
+ * @param lines description lines; null or blank lines are dropped
+ * @return a copy carrying the description
+ */
+ public CvItem paragraphs(String... lines) {
+ return withBody(lines, BodyStyle.PARAGRAPH);
+ }
+
+ /**
+ * Returns a copy whose description reads as a bulleted list, one
+ * bullet per line.
+ *
+ * @param lines description lines; null or blank lines are dropped
+ * @return a copy carrying the description
+ */
+ public CvItem bullets(String... lines) {
+ return withBody(lines, BodyStyle.BULLETS);
+ }
+
+ /**
+ * Returns a copy with an explicit body list and style — the
+ * variant for callers holding a {@code List} they did not build
+ * literally (an import layer, a JSON mapper).
+ *
+ * @param lines description lines; null or blank lines are dropped
+ * @param style whether the lines read as prose or as bullets
+ * @return a copy carrying the description
+ */
+ public CvItem body(List lines, BodyStyle style) {
+ return new CvItem(title, link, subtitle, period, location, lines, style);
+ }
+
+ /**
+ * The click target for this item's title, if it has one.
+ *
+ * @return the link URL, or blank when the title is not a link
+ */
+ public String url() {
+ return link == null ? "" : link.url();
+ }
+
+ private CvItem withBody(String[] lines, BodyStyle style) {
+ // Arrays.asList, not List.of: a null line is dropped by the canonical
+ // constructor, and List.of would throw before it ever got there.
+ List values = lines == null ? List.of() : Arrays.asList(lines);
+ return new CvItem(title, link, subtitle, period, location, values, style);
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvKind.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvKind.java
new file mode 100644
index 000000000..8cecabda0
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvKind.java
@@ -0,0 +1,103 @@
+package com.demcha.compose.document.templates.cv.data;
+
+/**
+ * How a {@link ModuleSection}'s items are laid out — the presentation
+ * shape the author picks at runtime, independent of what the section
+ * means.
+ *
+ * This is the axis that lets one {@link CvItem} record serve every
+ * module: the kind decides which of the item's optional fields are
+ * read and which are ignored. An item carrying a
+ * {@code period} rendered under {@link #ENTRIES} simply does not show
+ * a date column — the same data under {@link #ENTRIES_DATED} does.
+ * Each constant below names exactly what it reads, so "ignored" is a
+ * documented contract rather than a surprise.
+ *
+ * Every kind lowers onto the renderers this package already ships
+ * (see {@code components.ModuleRenderer}); none of them draws
+ * anything a hand-built {@link RowsSection}, {@link EntriesSection} or
+ * {@link ParagraphSection} could not.
+ *
+ * The orthogonal axes are {@link SectionRole} — what the section
+ * means, which is what a multi-column preset places on — and
+ * {@link BodyStyle}, which decides how one item's description lines
+ * render. Keeping them apart is what lets a "Volunteering" module be
+ * shaped exactly like Education without a new type.
+ *
+ * @since 2.3.0
+ */
+public enum CvKind {
+
+ /**
+ * Prose — a summary, an objective, a statement. Each item renders
+ * as its description, one paragraph per body line.
+ *
+ * Reads {@code body} only. The {@code title} is ignored here on
+ * purpose: the section already carries a heading, and a prose block
+ * that repeated it would print the same words twice. For a labelled
+ * one-liner ({@code Languages: English, German}) reach for
+ * {@link #INLINE_LIST}, which is what that shape is.
+ */
+ PARAGRAPH,
+
+ /**
+ * A bullet per item, description on the same line —
+ * {@code • Throughput: doubled it}. The shape of a short list where
+ * each entry is a label and a value ({@link RowStyle#BULLETED}).
+ *
+ * Reads {@code title}, {@code link}, {@code body}. Ignores
+ * {@code subtitle}, {@code period}, {@code location}. A body of
+ * several lines is joined with spaces; if the lines are meant to
+ * stand apart, the module wants {@link #BULLETS_STACKED}.
+ */
+ BULLETS,
+
+ /**
+ * A bullet per item, description stacked underneath and indented to
+ * the title — the shape a Projects section takes when the
+ * description is a sentence rather than a value
+ * ({@link RowStyle#BULLETED_STACKED}).
+ *
+ * Reads {@code title}, {@code link}, {@code body}. Ignores
+ * {@code subtitle}, {@code period}, {@code location}.
+ *
+ * Inline or stacked is the module's choice, not something
+ * inferred from how long a description happens to be: the same
+ * section reads one way throughout, and an author who picked
+ * "bulleted list with descriptions underneath" gets it whether the
+ * first entry is one line or five.
+ */
+ BULLETS_STACKED,
+
+ /**
+ * One line per item, the description collapsed into a
+ * comma-separated run after a bold label —
+ * {@code Languages: Java 21, Kotlin, SQL}. The shape skills and
+ * languages take in a narrow column.
+ *
+ * Reads {@code title} and {@code body}. Ignores {@code link},
+ * {@code subtitle}, {@code period}, {@code location}.
+ */
+ INLINE_LIST,
+
+ /**
+ * Timeline entries without the date column: bold title, italic
+ * subtitle line, description beneath.
+ *
+ * Reads {@code title}, {@code link}, {@code subtitle},
+ * {@code location}, {@code body}. Ignores {@code period} — this
+ * is the kind to pick when the dates exist in the data but should
+ * not show.
+ */
+ ENTRIES,
+
+ /**
+ * Timeline entries with the date column right-aligned against the
+ * title — Education, Experience, and anything shaped like them.
+ *
+ * Reads every field: {@code title}, {@code link},
+ * {@code subtitle}, {@code period}, {@code location},
+ * {@code body}.
+ */
+ ENTRIES_DATED
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java
index e3da6bbe7..c5e2d2660 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java
@@ -17,13 +17,21 @@
* items with four fields (title, subtitle, date, body).
* {@link SkillsSection} — grouped skill categories where each
* category owns an ordered list of skill labels.
+ * {@link ModuleSection} — a section whose shape is a value
+ * rather than a type: one {@link CvItem} record plus a
+ * {@link CvKind} chosen at runtime. The four above stay the
+ * natural choice for a CV written in Java, where the author
+ * picks the record and the compiler checks it; this one is for a
+ * CV assembled from data, where the shape is not known until it
+ * arrives. It renders through the same components, so both
+ * routes lay out the same content identically.
*
*
* Every implementation carries a {@code title} — the banner text
* the renderer wraps in a styled panel above the section body.
*/
public sealed interface CvSection
- permits ParagraphSection, RowsSection, EntriesSection, SkillsSection {
+ permits ParagraphSection, RowsSection, EntriesSection, SkillsSection, ModuleSection {
/**
* Banner heading shown above this section's body.
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java
new file mode 100644
index 000000000..331078a78
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java
@@ -0,0 +1,178 @@
+package com.demcha.compose.document.templates.cv.data;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A section assembled at runtime: a heading, what it means
+ * ({@link SectionRole}), how it draws ({@link CvKind}), and the
+ * {@link CvItem}s it holds.
+ *
+ * The other {@link CvSection} implementations each fix one shape at
+ * compile time — {@link ParagraphSection} is prose, {@link RowsSection}
+ * is rows, {@link EntriesSection} is a timeline. That is the right
+ * model for a CV written in Java, where the author picks the record and
+ * the compiler checks it. It is the wrong one for a CV assembled from
+ * data at runtime: a user who has just chosen "Volunteering, shaped
+ * like Education, with dates" cannot instantiate a different record per
+ * choice, and every new shape would mean a new type.
+ *
+ * So this record moves the choice into a value. One item type carries
+ * every optional field; the kind decides which are read and which are
+ * ignored; the role says where the section belongs without a preset
+ * having to recognise its heading. The result is that a module nobody
+ * anticipated needs no new code — only a different
+ * {@code (role, kind)} pair.
+ *
+ * It renders through the same components as everything else. Every
+ * kind lowers onto {@link ParagraphSection}-, {@link RowsSection}- or
+ * {@link EntriesSection}-shaped output, so a module drawn as
+ * {@link CvKind#ENTRIES_DATED} is laid out exactly like the
+ * {@code EntriesSection} carrying the same content — which the parity
+ * suite holds to, layout node for layout node.
+ *
+ * {@code
+ * 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();
+ * }
+ *
+ * @param title non-blank banner heading, in the author's own words
+ * @param role what the section means; {@link SectionRole#OTHER} when
+ * the catalogue has no name for it
+ * @param kind how the items draw
+ * @param items ordered items; null entries are dropped
+ * @since 2.3.0
+ */
+public record ModuleSection(String title, SectionRole role, CvKind kind, List items)
+ implements CvSection {
+
+ /**
+ * Validates that every field is non-null and {@code title} is
+ * non-blank, drops null items, and defensively copies the list.
+ */
+ public ModuleSection {
+ Objects.requireNonNull(title, "title");
+ Objects.requireNonNull(role, "role");
+ Objects.requireNonNull(kind, "kind");
+ Objects.requireNonNull(items, "items");
+ if (title.isBlank()) {
+ throw new IllegalArgumentException("title must not be blank");
+ }
+ List cleaned = new ArrayList<>(items.size());
+ for (CvItem item : items) {
+ if (item != null) {
+ cleaned.add(item);
+ }
+ }
+ items = List.copyOf(cleaned);
+ }
+
+ /**
+ * Fluent builder seeded with the three choices that define the
+ * module.
+ *
+ * @param title non-blank banner heading
+ * @param role what the section means
+ * @param kind how its items draw
+ * @return new builder
+ */
+ public static Builder builder(String title, SectionRole role, CvKind kind) {
+ return new Builder(title, role, kind);
+ }
+
+ /**
+ * Module assembled from a fixed set of items.
+ *
+ * @param title non-blank banner heading
+ * @param role what the section means
+ * @param kind how its items draw
+ * @param items items in source order; null becomes empty
+ * @return a {@code ModuleSection} carrying the supplied items
+ */
+ public static ModuleSection of(String title, SectionRole role, CvKind kind, CvItem... items) {
+ return new ModuleSection(title, role, kind,
+ items == null ? List.of() : Arrays.asList(items));
+ }
+
+ /**
+ * Prose module — the common case of a summary or objective, where
+ * the section is one block of text and naming a kind and a role
+ * adds nothing.
+ *
+ * @param title non-blank banner heading
+ * @param text the prose; each argument is its own paragraph
+ * @return a {@link CvKind#PARAGRAPH} module under
+ * {@link SectionRole#SUMMARY}
+ */
+ public static ModuleSection summary(String title, String... text) {
+ // The item's title is the section's: PARAGRAPH reads only the body, so
+ // it names the item without reaching the page.
+ return of(title, SectionRole.SUMMARY, CvKind.PARAGRAPH,
+ CvItem.of(title).paragraphs(text));
+ }
+
+ /**
+ * Mutable builder.
+ */
+ public static final class Builder {
+ private final String title;
+ private final SectionRole role;
+ private final CvKind kind;
+ private final List items = new ArrayList<>();
+
+ private Builder(String title, SectionRole role, CvKind kind) {
+ this.title = title;
+ this.role = role;
+ this.kind = kind;
+ }
+
+ /**
+ * Appends one pre-built item.
+ *
+ * @param item the item to append (non-null)
+ * @return this builder for chaining
+ */
+ public Builder item(CvItem item) {
+ this.items.add(Objects.requireNonNull(item, "item"));
+ return this;
+ }
+
+ /**
+ * Appends a title-only item — the shape a bulleted list or an
+ * inline list of one-liners takes.
+ *
+ * @param title what the entry is called; required, non-blank
+ * @return this builder for chaining
+ */
+ public Builder item(String title) {
+ return item(CvItem.of(title));
+ }
+
+ /**
+ * Appends a labelled item whose description is the given
+ * lines, read as prose.
+ *
+ * @param title what the entry is called; required, non-blank
+ * @param body description lines; null or blank lines dropped
+ * @return this builder for chaining
+ */
+ public Builder item(String title, String... body) {
+ return item(CvItem.of(title).paragraphs(body));
+ }
+
+ /**
+ * Builds the immutable {@link ModuleSection}.
+ *
+ * @return the assembled section
+ */
+ public ModuleSection build() {
+ return new ModuleSection(title, role, kind, items);
+ }
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/SectionRole.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/SectionRole.java
new file mode 100644
index 000000000..de00b77ad
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/SectionRole.java
@@ -0,0 +1,54 @@
+package com.demcha.compose.document.templates.cv.data;
+
+/**
+ * What a {@link ModuleSection} means, stated by the author
+ * instead of guessed from its heading.
+ *
+ * Multi-column presets have to decide what belongs in a sidebar,
+ * and until now they decided it by matching the section's title
+ * against a list of English keywords each preset kept privately. A CV
+ * whose headings read {@code "Ausbildung"} or {@code "Навыки"} matched
+ * nothing, and a heading nobody anticipated was placed by whatever the
+ * preset does with leftovers. The role carries that decision in the
+ * data, where the author already knows the answer.
+ *
+ * It is deliberately separate from {@link CvKind}: the role says
+ * what a section is, the kind says how it draws. A "Volunteering"
+ * module shaped exactly like Education is
+ * {@code role = OTHER, kind = ENTRIES_DATED} — a combination no single
+ * enum could express without one constant per pairing.
+ *
+ * {@link #OTHER} is the honest default and is never a second-class
+ * citizen: a preset that cannot place it by role falls back to the
+ * heading the author wrote, in document order.
+ *
+ * @since 2.3.0
+ */
+public enum SectionRole {
+
+ /** Profile, objective, professional summary — the opening prose. */
+ SUMMARY,
+
+ /** Employment history. */
+ EXPERIENCE,
+
+ /** Degrees, certifications, courses. */
+ EDUCATION,
+
+ /** Technical or professional skills, however they are grouped. */
+ SKILLS,
+
+ /** Personal or professional projects. */
+ PROJECTS,
+
+ /** Spoken languages and proficiency. */
+ LANGUAGES,
+
+ /**
+ * Anything else — awards, volunteering, publications, interests,
+ * references, a section this catalogue has no name for. Carries no
+ * placement hint, so presets fall back to the author's own
+ * heading.
+ */
+ OTHER
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java
index f5ff77798..54adaf5e0 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java
@@ -40,8 +40,33 @@
* — grouped skills: category plus ordered skill labels. This
* keeps skills semantic so presets can render them as tables,
* sidebar chips, or inline rows without reparsing text.
+ * {@link com.demcha.compose.document.templates.cv.data.ModuleSection}
+ * — the shape as a value: one
+ * {@link com.demcha.compose.document.templates.cv.data.CvItem}
+ * record carrying every optional field, plus a
+ * {@link com.demcha.compose.document.templates.cv.data.CvKind}
+ * that decides which of them are read. For CVs assembled at
+ * runtime, where the author picks "dated entries" or "bullets"
+ * from a menu and no compile-time type can be chosen per
+ * choice.
*
*
+ * Which one to reach for
+ *
+ * Writing a CV in Java: the four fixed shapes. The compiler checks
+ * the record you picked, and a project is visibly a
+ * {@code RowsSection} rather than a section that happens to hold
+ * rows.
+ *
+ * Assembling one from data — a form, a JSON payload, an LLM: the
+ * module. The section's shape and meaning arrive as values
+ * ({@code CvKind}, {@code SectionRole}), so a heading nobody
+ * anticipated — "Volunteering", shaped like Education — needs no new
+ * type and no new branch. Both routes render through the same
+ * components, and the parity suite holds them to laying out the same
+ * content identically, so the choice is about how the CV is authored,
+ * not about what it can look like.
+ *
* Placement
*
* Sections live inside a {@link com.demcha.compose.document.templates.cv.data.CvDocument}
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 6f70fd3ab..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");
@@ -161,8 +167,13 @@ private static void renderBody(SectionBuilder host,
renderEntry(host, entry, theme);
}
} else {
- throw new IllegalStateException(
- "Unknown CvSection subtype: " + section.getClass().getName());
+ // A shape this preset has no styled path for — today the runtime
+ // ModuleSection. Hand it to the canonical dispatcher rather than
+ // throwing: a section the author put in the document reaches the
+ // 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, KIT);
}
}
@@ -180,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 e4dbab8da..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
@@ -242,12 +242,49 @@ private void renderDetailBody(SectionBuilder host, CvSection section) {
new CvRow(group.category(), group.skillsInline()));
}
} else {
- throw new IllegalStateException(
- "Unknown CvSection subtype: "
- + section.getClass().getName());
+ // A shape this preset has no serif-styled path for — today the
+ // runtime ModuleSection. The canonical dispatcher renders it
+ // rather than the render failing on a section the author
+ // legitimately added.
+ 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 42637967f..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);
@@ -146,6 +147,13 @@ private void renderSectionBody(SectionBuilder section, CvSection cvSection,
renderEntries(section, entries);
} else if (cvSection instanceof RowsSection rows) {
renderRows(section, rows);
+ } else {
+ // A shape this preset has no editorial-styled path for — today
+ // the runtime ModuleSection. Without this branch the section
+ // 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, kit());
}
}
@@ -165,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);
@@ -274,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/data/ModuleSectionTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java
new file mode 100644
index 000000000..9930c6bce
--- /dev/null
+++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java
@@ -0,0 +1,170 @@
+package com.demcha.compose.document.templates.cv.data;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The runtime-assembled section and its item record: what they require,
+ * what they normalise, and what they refuse.
+ */
+class ModuleSectionTest {
+
+ @Test
+ void anItemNeedsOnlyATitle() {
+ CvItem item = CvItem.of("Mentor, Rails Girls");
+
+ assertThat(item.title()).isEqualTo("Mentor, Rails Girls");
+ assertThat(item.link()).isNull();
+ assertThat(item.subtitle()).isEmpty();
+ assertThat(item.period()).isEmpty();
+ assertThat(item.location()).isEmpty();
+ assertThat(item.body()).isEmpty();
+ assertThat(item.bodyStyle()).isEqualTo(BodyStyle.PARAGRAPH);
+ assertThat(item.url()).isEmpty();
+ }
+
+ @Test
+ void anItemWithoutATitleIsRejected() {
+ assertThatThrownBy(() -> CvItem.of(" "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("title");
+ assertThatThrownBy(() -> CvItem.of(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("title");
+ }
+
+ @Test
+ void theOptionalFieldsNormaliseNullToBlank() {
+ // An import layer that has no value for a field passes null rather than
+ // inventing a placeholder; every renderer downstream tests isBlank().
+ CvItem item = new CvItem("Title", null, null, null, null, null, null);
+
+ assertThat(item.subtitle()).isEmpty();
+ assertThat(item.period()).isEmpty();
+ assertThat(item.location()).isEmpty();
+ assertThat(item.body()).isEmpty();
+ assertThat(item.bodyStyle()).isEqualTo(BodyStyle.PARAGRAPH);
+ }
+
+ @Test
+ void blankAndNullBodyLinesAreDropped() {
+ CvItem item = CvItem.of("Role").bullets("Shipped it", " ", null, "Measured it");
+
+ assertThat(item.body()).containsExactly("Shipped it", "Measured it");
+ assertThat(item.bodyStyle()).isEqualTo(BodyStyle.BULLETS);
+ }
+
+ @Test
+ void theBodyListIsCopiedAndUnmodifiable() {
+ List source = new ArrayList<>(List.of("first"));
+ CvItem item = CvItem.of("Role").body(source, BodyStyle.PARAGRAPH);
+ source.add("added after the fact");
+
+ assertThat(item.body()).containsExactly("first");
+ assertThat(item.body().getClass().getName()).doesNotContain("ArrayList");
+ }
+
+ @Test
+ void theWithStyleMethodsReadInRenderOrder() {
+ CvItem item = CvItem.of("Senior Backend Engineer")
+ .linkedTo("https://acme.example")
+ .at("Acme GmbH")
+ .in("Berlin, DE")
+ .period("2021 - Present")
+ .bullets("Cut p99 latency 40%");
+
+ assertThat(item.title()).isEqualTo("Senior Backend Engineer");
+ assertThat(item.url()).isEqualTo("https://acme.example");
+ assertThat(item.link().label()).isEqualTo("Senior Backend Engineer");
+ assertThat(item.subtitle()).isEqualTo("Acme GmbH");
+ assertThat(item.location()).isEqualTo("Berlin, DE");
+ assertThat(item.period()).isEqualTo("2021 - Present");
+ assertThat(item.body()).containsExactly("Cut p99 latency 40%");
+ }
+
+ @Test
+ void aBlankLinkTargetLeavesTheTitlePlain() {
+ assertThat(CvItem.of("Role").linkedTo("").link()).isNull();
+ assertThat(CvItem.of("Role").linkedTo(null).link()).isNull();
+ }
+
+ @Test
+ void aModuleCarriesItsRoleAndKind() {
+ ModuleSection module = ModuleSection.builder("Volunteering",
+ SectionRole.OTHER, CvKind.ENTRIES_DATED)
+ .item(CvItem.of("Mentor").period("2019"))
+ .build();
+
+ assertThat(module.title()).isEqualTo("Volunteering");
+ assertThat(module.role()).isEqualTo(SectionRole.OTHER);
+ assertThat(module.kind()).isEqualTo(CvKind.ENTRIES_DATED);
+ assertThat(module.items()).hasSize(1);
+ assertThat(module).isInstanceOf(CvSection.class);
+ }
+
+ @Test
+ void aModuleWithoutATitleIsRejected() {
+ assertThatThrownBy(() -> ModuleSection.of(" ", SectionRole.OTHER, CvKind.BULLETS))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("title");
+ }
+
+ @Test
+ void aModuleRejectsANullRoleOrKind() {
+ assertThatThrownBy(() -> ModuleSection.of("Skills", null, CvKind.BULLETS))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("role");
+ assertThatThrownBy(() -> ModuleSection.of("Skills", SectionRole.SKILLS, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("kind");
+ }
+
+ @Test
+ void nullItemsAreDroppedAndTheListIsCopied() {
+ List source = new ArrayList<>(
+ Arrays.asList(CvItem.of("kept"), null, CvItem.of("also kept")));
+ ModuleSection module = new ModuleSection("Interests", SectionRole.OTHER,
+ CvKind.BULLETS, source);
+ source.clear();
+
+ assertThat(module.items()).extracting(CvItem::title)
+ .containsExactly("kept", "also kept");
+ // Both halves of "copied": the caller's list cannot reach in (above), and
+ // the accessor hands out nothing a caller could reach in through.
+ assertThatThrownBy(() -> module.items().add(CvItem.of("smuggled")))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
+ void aSummaryModuleIsProseUnderTheSummaryRole() {
+ ModuleSection module = ModuleSection.summary("Professional Summary",
+ "Backend engineer.", "Ten years of it.");
+
+ assertThat(module.role()).isEqualTo(SectionRole.SUMMARY);
+ assertThat(module.kind()).isEqualTo(CvKind.PARAGRAPH);
+ assertThat(module.items()).singleElement()
+ .extracting(CvItem::body, org.assertj.core.api.InstanceOfAssertFactories.LIST)
+ .containsExactly("Backend engineer.", "Ten years of it.");
+ }
+
+ @Test
+ void theBuilderShorthandsBuildTheSameItems() {
+ ModuleSection module = ModuleSection.builder("Interests", SectionRole.OTHER,
+ CvKind.BULLETS)
+ .item("Chess")
+ .item("Cycling", "Long-distance, mostly.")
+ .build();
+
+ assertThat(module.items()).extracting(CvItem::title)
+ .containsExactly("Chess", "Cycling");
+ assertThat(module.items().get(0).body()).isEmpty();
+ assertThat(module.items().get(1).body()).containsExactly("Long-distance, mostly.");
+ assertThat(module.items().get(1).bodyStyle()).isEqualTo(BodyStyle.PARAGRAPH);
+ }
+}
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);
+ }
+ }
+}
diff --git a/testing/pom.xml b/testing/pom.xml
index 82398d0d6..2432158e2 100644
--- a/testing/pom.xml
+++ b/testing/pom.xml
@@ -19,7 +19,7 @@
-->
io.github.demchaav
graph-compose-testing
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
GraphCompose Testing
Consumer testing support for GraphCompose: layout-snapshot assertions and PDF visual regression.
diff --git a/wrapper/pom.xml b/wrapper/pom.xml
index b4b414829..4746605aa 100644
--- a/wrapper/pom.xml
+++ b/wrapper/pom.xml
@@ -22,7 +22,7 @@
-->
io.github.demchaav
graph-compose
- 2.2.1-SNAPSHOT
+ 2.3.0-SNAPSHOT
GraphCompose
The graph-compose coordinate: a drop-in aggregator over graph-compose-core for a PDF-capable install.