From 36b8d097c66a32644b9ba6a2be22b3524ed6627b Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 23 May 2026 15:20:59 +0200
Subject: [PATCH 1/2] =?UTF-8?q?v2(Phase=201):=20slot=20mapping=20=E2=80=94?=
=?UTF-8?q?=20CvDocument=20supports=20multi-column=20placement?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes the single-column-only regression that v2 had vs. the legacy
CvBuilder.place(slot, name) API. Single PR, no engine changes, no v1
edits, no behavioural change for existing single-column callers.
What's new
----------
- data/Slot.java — enum {MAIN, SIDEBAR, FOOTER}. MAIN is the default
when a section is built without an explicit slot.
- data/CvDocument refactor: canonical record is now
(CvIdentity, List) where Placement(Slot, CvSection).
Custom sections() method still returns the flat list (source-order)
so debug/inspect call sites keep working. New sectionsIn(Slot) and
slotOf(CvSection) accessors.
- CvDocument.Builder gains:
.section(Slot slot, CvSection section)
.sections(Slot slot, CvSection... values)
The pre-existing zero-slot calls (.section(s), .sections(s1, s2))
default to MAIN — every existing call site keeps compiling and
rendering identically.
- @Deprecated CvDocument.ofMainSections(identity, list) — migration
helper for any caller that used a direct two-arg constructor. The
Builder is the recommended path forward.
What's updated
--------------
- presets/BoxedSections + presets/MinimalUnderlined now iterate
doc.sectionsIn(Slot.MAIN) instead of doc.sections(). Sidebar /
footer placements are silently dropped by single-column presets —
documented in AUTHORS.md.
- AUTHORS.md gains Recipe 6 ("place sections in slots") with both the
builder-side and the preset-side patterns. Old Recipe 6 (conditional
sections) renumbered to 7.
- package-info.java gains a "Slots — placing sections in columns"
section in the root v2 package.
- data/package-info.java mentions Placement and Slot.
Tests
-----
- New CvDocumentSlotTest with 10 cases (default MAIN, explicit slot,
source-order across slots, sectionsIn filter, slotOf, deprecated
ofMainSections, null guards).
- All existing v2 tests stay green (BoxedSectionsSmokeTest,
MinimalUnderlinedSmokeTest, CvDecorationTest, CvNameTest,
CvContactTest).
- Full mvn test: 881/881 pass.
- cv-boxed-sections-v2.pdf and cv-minimal-underlined.pdf render
pixel-identical to baseline (sample data is all MAIN today).
---
.../document/templates/cv/v2/AUTHORS.md | 101 ++++++++-
.../templates/cv/v2/data/CvDocument.java | 195 +++++++++++++++---
.../document/templates/cv/v2/data/Slot.java | 34 +++
.../templates/cv/v2/data/package-info.java | 11 +
.../templates/cv/v2/package-info.java | 34 +++
.../cv/v2/presets/BoxedSections.java | 6 +-
.../cv/v2/presets/MinimalUnderlined.java | 5 +-
.../cv/v2/data/CvDocumentSlotTest.java | 149 +++++++++++++
8 files changed, 506 insertions(+), 29 deletions(-)
create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java
create mode 100644 src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md b/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md
index a8f3a5b4..6cba35f8 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md
@@ -13,6 +13,60 @@ This guide answers the **"how do I…"** questions.
---
+## The scaffold is persona-neutral
+
+Nothing in the v2 API assumes a software-developer audience. The
+sample data shipped with the package happens to be a developer CV
+(Jordan Rivera, "Platform Engineer", GitHub, Projects, Tech Skills),
+but that's only because we needed *some* fixture for visual
+regression. The same builders fit any persona — skip the link / section
+types you don't need.
+
+```java
+// A primary school teacher's CV — no GitHub, no Projects, no Tech Skills
+CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Maria", "Lopez")
+ .contact("+34 600 000 000", "maria@example.com", "Madrid, Spain")
+ // no .link(...) calls — she has no public profiles, and that is fine
+ .build())
+ .section(new ParagraphSection("About Me",
+ "Primary school teacher with 12 years' experience in literacy " +
+ "and inclusive education."))
+ .section(EntriesSection.builder("Teaching Experience")
+ .entry("Lead Teacher Y3", "Colegio Santa Ana", "2018-Present",
+ "Year-3 lead teacher; designed the school's reading-rota " +
+ "and mentored two newly-qualified teachers.")
+ .entry("Year Teacher", "Escuela Primaria Goya", "2013-2018",
+ "Y1-Y2 generalist; led the SEN reading-club after hours.")
+ .build())
+ .section(RowsSection.builder("Languages", RowStyle.PLAIN)
+ .row("Spanish", "Native")
+ .row("English", "Fluent (CEFR C1)")
+ .row("Catalan", "Conversational")
+ .build())
+ .section(RowsSection.builder("Certifications", RowStyle.PLAIN)
+ .row("Inclusive Education", "Universidad Complutense, 2020")
+ .row("Children's First Aid", "Cruz Roja, 2022")
+ .build())
+ .build();
+```
+
+Notice what's absent:
+
+- **No `link(...)` calls.** Optional, simply omitted.
+- **No Projects, no Skills.** The three section types
+ (`ParagraphSection`, `RowsSection`, `EntriesSection`) work for any
+ content shape — you choose what to put in them and what to call
+ them.
+- **No required IT vocabulary anywhere.** Section titles are free
+ strings (`"About Me"`, `"Teaching Experience"`, `"Certifications"`).
+
+The rest of this guide leans on dev-style examples for continuity,
+but every recipe below works the same way for any persona.
+
+---
+
## Recipe 1 — change a bullet glyph
You want `▶` instead of `•`, or numbered bullets, or em-dashes.
@@ -211,7 +265,52 @@ guard.)
---
-## Recipe 6 — conditional sections (data-driven)
+## Recipe 6 — place sections in slots (sidebar / footer)
+
+A `CvDocument` is not just a flat list of sections — every section is
+**placed** into a `Slot` (one of `MAIN`, `SIDEBAR`, `FOOTER`).
+Single-column presets like `BoxedSections` read only `Slot.MAIN`
+sections; multi-column presets read whichever slots they support.
+
+**Placing a section into a slot** (builder API):
+
+```java
+CvDocument doc = CvDocument.builder()
+ .identity(identity)
+ .section(summary) // defaults to MAIN
+ .section(Slot.MAIN, technicalSkills) // explicit, same as above
+ .section(Slot.SIDEBAR, languagesSpoken) // sidebar column
+ .section(Slot.SIDEBAR, certifications) // sidebar column
+ .sections(Slot.MAIN, experience, projects) // varargs in a slot
+ .build();
+```
+
+**Reading by slot** (in a preset's `compose()`):
+
+```java
+// Single-column preset — render only MAIN, drop the rest
+List mainSections = doc.sectionsIn(Slot.MAIN);
+
+// Two-column preset
+List mainSections = doc.sectionsIn(Slot.MAIN);
+List sidebarSections = doc.sectionsIn(Slot.SIDEBAR);
+```
+
+**Why this matters:** when the user feeds your document to a
+single-column preset, sidebar sections are silently dropped (they
+have no place to render). When the same document goes through a
+two-column preset, sidebar sections show up in the sidebar column.
+The data model is the same — only the preset's interpretation
+differs.
+
+**Tip:** `doc.sections()` returns *every* section in source order
+regardless of slot. Use it for debug printing or unfiltered
+iteration — not in a preset's render loop unless you really want
+sidebar content to flow inline with main.
+
+---
+
+## Recipe 7 — conditional sections (data-driven)
Real CVs often hide a section when there's nothing to put in it:
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java
index de294886..bd214778 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java
@@ -6,27 +6,127 @@
/**
* Root of the v2 CV data model — required {@link CvIdentity} block
- * plus an ordered list of {@link CvSection} entries that render in
- * source order beneath the header.
+ * plus an ordered list of section {@link Placement}s.
*
- * This record carries no styling or rendering decision. Pair it
- * with a
- * {@link com.demcha.compose.document.templates.cv.v2.theme.CvTheme}
- * and a preset from
- * {@code com.demcha.compose.document.templates.cv.v2.presets} to
- * produce a PDF.
+ * Each {@link Placement} pairs a {@link CvSection} with a
+ * {@link Slot} so multi-column presets can decide which sections go
+ * into the sidebar vs. the main column without having to guess from
+ * section titles. Sections built without an explicit slot default to
+ * {@link Slot#MAIN}.
*
- * @param identity required identity / contact block
- * @param sections ordered sections; rendered in source order
+ * Single-column presets read {@link #sectionsIn(Slot)
+ * sectionsIn(Slot.MAIN)} and ignore the rest; multi-column presets
+ * call {@code sectionsIn} once per slot they support.
+ *
+ * @param identity required identity / contact block
+ * @param placements ordered placements; render order matches source
+ * order within each slot
*/
-public record CvDocument(CvIdentity identity, List sections) {
+public record CvDocument(CvIdentity identity, List placements) {
public CvDocument {
Objects.requireNonNull(identity, "identity");
- Objects.requireNonNull(sections, "sections");
- sections = List.copyOf(sections);
+ Objects.requireNonNull(placements, "placements");
+ placements = List.copyOf(placements);
+ }
+
+ /**
+ * Backward-compatible factory that wraps every supplied section
+ * in a {@link Slot#MAIN} placement. Provided so callers
+ * migrating from the pre-slot {@code new CvDocument(identity,
+ * sections)} pattern have a 1:1 replacement without rewriting
+ * their construction code.
+ *
+ * Equivalent to building each section through
+ * {@link Builder#section(CvSection)} (which also defaults to
+ * {@link Slot#MAIN}). Prefer the {@link Builder} in new code —
+ * multi-column presets need explicit slot placement that the
+ * builder makes obvious at the call site.
+ *
+ * @deprecated since the slot model — prefer the {@link Builder}
+ * so non-MAIN slots are visible at the call site.
+ */
+ @Deprecated
+ public static CvDocument ofMainSections(CvIdentity identity,
+ List sections) {
+ return new CvDocument(identity, toMainPlacements(sections));
+ }
+
+ /**
+ * One section together with the slot it should render in.
+ *
+ * @param slot placement region (never null)
+ * @param section section body (never null)
+ */
+ public record Placement(Slot slot, CvSection section) {
+
+ public Placement {
+ Objects.requireNonNull(slot, "slot");
+ Objects.requireNonNull(section, "section");
+ }
+ }
+
+ // -- accessors -------------------------------------------------------
+
+ /**
+ * Returns every section in this document in source order,
+ * regardless of slot. Useful for debugging, indexing, or
+ * iterating all sections without caring where they render.
+ *
+ * Presets should usually call {@link #sectionsIn(Slot)}
+ * instead — a single-column preset that iterates this flat list
+ * will accidentally render sidebar content inline with the main
+ * flow.
+ *
+ * @return flat list of all sections, source order
+ */
+ public List sections() {
+ List out = new ArrayList<>(placements.size());
+ for (Placement p : placements) {
+ out.add(p.section());
+ }
+ return List.copyOf(out);
+ }
+
+ /**
+ * Returns the sections placed in {@code slot}, in source order.
+ * The standard call from a preset's {@code compose()} method.
+ *
+ * @param slot non-null slot
+ * @return sections placed in that slot; empty list if none
+ */
+ public List sectionsIn(Slot slot) {
+ Objects.requireNonNull(slot, "slot");
+ List out = new ArrayList<>();
+ for (Placement p : placements) {
+ if (p.slot() == slot) {
+ out.add(p.section());
+ }
+ }
+ return List.copyOf(out);
+ }
+
+ /**
+ * Returns the slot a given section was placed in. Uses identity
+ * comparison ({@code ==}) to avoid surprises when two sections
+ * happen to be record-equal but were added separately.
+ *
+ * @param section section instance to look up
+ * @return its slot, or {@link Slot#MAIN} if the section is not
+ * in this document
+ */
+ public Slot slotOf(CvSection section) {
+ Objects.requireNonNull(section, "section");
+ for (Placement p : placements) {
+ if (p.section() == section) {
+ return p.slot();
+ }
+ }
+ return Slot.MAIN;
}
+ // -- builder ---------------------------------------------------------
+
/**
* @return new fluent builder
*/
@@ -35,12 +135,16 @@ public static Builder builder() {
}
/**
- * Mutable builder. Section order in the builder is preserved 1:1
- * in the built document.
+ * Mutable builder for {@link CvDocument}.
+ *
+ * The {@code section}-family methods append to the placement
+ * list in call order. Pass a {@link Slot} as the first argument
+ * to place into a non-main slot; omit it to default to
+ * {@link Slot#MAIN}.
*/
public static final class Builder {
private CvIdentity identity;
- private final List sections = new ArrayList<>();
+ private final List placements = new ArrayList<>();
private Builder() {
}
@@ -50,37 +154,76 @@ public Builder identity(CvIdentity value) {
return this;
}
+ /**
+ * Appends one section into {@link Slot#MAIN}.
+ */
public Builder section(CvSection section) {
- this.sections.add(Objects.requireNonNull(section, "section"));
+ return section(Slot.MAIN, section);
+ }
+
+ /**
+ * Appends one section into a chosen slot.
+ */
+ public Builder section(Slot slot, CvSection section) {
+ this.placements.add(new Placement(slot, section));
return this;
}
/**
- * Varargs convenience for the common case where every section
- * is constructed up-front. Equivalent to chained
- * {@link #section(CvSection)} calls.
- *
- * @param values one or more sections in render order
- * @return this builder
+ * Varargs convenience — all sections placed in
+ * {@link Slot#MAIN}.
*/
public Builder sections(CvSection... values) {
Objects.requireNonNull(values, "values");
for (CvSection s : values) {
- section(s);
+ section(Slot.MAIN, s);
+ }
+ return this;
+ }
+
+ /**
+ * Varargs convenience — all supplied sections placed in
+ * {@code slot}.
+ */
+ public Builder sections(Slot slot, CvSection... values) {
+ Objects.requireNonNull(slot, "slot");
+ Objects.requireNonNull(values, "values");
+ for (CvSection s : values) {
+ section(slot, s);
}
return this;
}
+ /**
+ * List variant — all sections placed in {@link Slot#MAIN}.
+ */
public Builder sections(List values) {
Objects.requireNonNull(values, "values");
for (CvSection s : values) {
- section(s);
+ section(Slot.MAIN, s);
}
return this;
}
+ /**
+ * Appends a pre-built {@link Placement}.
+ */
+ public Builder placement(Placement placement) {
+ this.placements.add(Objects.requireNonNull(placement, "placement"));
+ return this;
+ }
+
public CvDocument build() {
- return new CvDocument(identity, sections);
+ return new CvDocument(identity, placements);
+ }
+ }
+
+ private static List toMainPlacements(List sections) {
+ Objects.requireNonNull(sections, "sections");
+ List out = new ArrayList<>(sections.size());
+ for (CvSection s : sections) {
+ out.add(new Placement(Slot.MAIN, s));
}
+ return out;
}
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java
new file mode 100644
index 00000000..da501d65
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java
@@ -0,0 +1,34 @@
+package com.demcha.compose.document.templates.cv.v2.data;
+
+/**
+ * Logical placement region inside a {@link CvDocument}. Presets read
+ * the slot of each section to decide where on the page it should
+ * appear — main column, sidebar, or footer.
+ *
+ * Single-column presets like
+ * {@code com.demcha.compose.document.templates.cv.v2.presets.BoxedSections}
+ * iterate only {@link #MAIN} sections; sections placed in
+ * {@link #SIDEBAR} or {@link #FOOTER} are silently dropped by such
+ * presets. Multi-column presets (two-column sidebar, magazine
+ * layouts) iterate {@link #MAIN} and {@link #SIDEBAR} separately to
+ * fill their columns.
+ *
+ * Sections that don't specify a slot at build time default to
+ * {@link #MAIN} — so single-column callers keep working without
+ * changes after the slot model was introduced.
+ */
+public enum Slot {
+
+ /** Primary content column. Default when no slot is specified. */
+ MAIN,
+
+ /** Sidebar / secondary column — typically narrower than MAIN. */
+ SIDEBAR,
+
+ /**
+ * Footer area below the main flow — useful for references,
+ * disclaimers, or page-bottom notes. Currently only rendered by
+ * presets that explicitly opt in.
+ */
+ FOOTER
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java
index 7d3fe544..8ee6da21 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java
@@ -39,6 +39,17 @@
* and Professional Experience interchangeably.
*
*
+ * Placement
+ *
+ * Sections live inside a {@link com.demcha.compose.document.templates.cv.v2.data.CvDocument}
+ * as ordered {@link com.demcha.compose.document.templates.cv.v2.data.CvDocument.Placement}
+ * entries. Each placement pairs a section with a
+ * {@link com.demcha.compose.document.templates.cv.v2.data.Slot}
+ * ({@code MAIN}, {@code SIDEBAR}, {@code FOOTER}). Single-column
+ * presets read only {@code MAIN}; multi-column presets read multiple
+ * slots. Sections built without an explicit slot default to
+ * {@code MAIN}, so existing call sites stay valid.
+ *
* Adding a new section type
*
*
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java
index ce983f0a..1fc7d5a7 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java
@@ -5,6 +5,14 @@
* documents on top of GraphCompose. Below is the conceptual model
* every author should hold in their head before writing a template.
*
+ * Persona-neutral. Nothing here assumes a
+ * software-developer audience. The shipped sample data is a
+ * developer CV because we needed a fixture for visual regression,
+ * but the same builders fit a teacher, a chef, a nurse, an
+ * artist — anyone. Skip the link / section types you don't need;
+ * section titles are free strings and links are optional. See
+ * {@code AUTHORS.md} for a non-IT example.
+ *
* Four layers, one responsibility each
*
*
@@ -119,6 +127,31 @@
* }
* }
*
+ * Slots — placing sections in columns
+ *
+ * Every section is placed into a
+ * {@link com.demcha.compose.document.templates.cv.v2.data.Slot} —
+ * {@code MAIN}, {@code SIDEBAR}, or {@code FOOTER}. Single-column
+ * presets read only {@code MAIN} sections; multi-column presets
+ * read whichever slots they support, leaving the data model
+ * unchanged.
+ *
+ * {@code
+ * CvDocument doc = CvDocument.builder()
+ * .identity(identity)
+ * .section(summary) // implicit MAIN
+ * .section(Slot.SIDEBAR, languages) // sidebar column
+ * .build();
+ * }
+ *
+ * Inside a preset:
+ *
+ * {@code
+ * for (CvSection s : doc.sectionsIn(Slot.MAIN)) {
+ * // render s into the main flow
+ * }
+ * }
+ *
* Going further — extension recipes
*
* See {@code AUTHORS.md} alongside this package for longer
@@ -128,6 +161,7 @@
*
Build a new theme variant (navy / compact / serif)
* Write a brand-new preset that reuses existing renderers
* Add a brand-new section subtype (compile-checked dispatch)
+ * Place sections in slots (sidebar / footer)
*
*
* What this package is not
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java
index 57d51364..33c1705f 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java
@@ -9,6 +9,7 @@
import com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher;
import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
import com.demcha.compose.document.templates.cv.v2.data.CvSection;
+import com.demcha.compose.document.templates.cv.v2.data.Slot;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
import java.util.List;
@@ -114,7 +115,10 @@ public void compose(DocumentSession document, CvDocument doc) {
ContactRenderer.render(section, doc.identity(), theme);
});
- List sections = doc.sections();
+ // Single-column preset — only renders MAIN-slot sections.
+ // Sidebar / footer placements are intentionally dropped here;
+ // switch to a multi-column preset to render them.
+ List sections = doc.sectionsIn(Slot.MAIN);
for (int i = 0; i < sections.size(); i++) {
final CvSection sec = sections.get(i);
final int idx = i;
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java
index 77dda81e..20c52adc 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java
@@ -12,6 +12,7 @@
import com.demcha.compose.document.templates.cv.v2.components.TextOrnaments;
import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
import com.demcha.compose.document.templates.cv.v2.data.CvSection;
+import com.demcha.compose.document.templates.cv.v2.data.Slot;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
import java.util.List;
@@ -108,7 +109,9 @@ public void compose(DocumentSession document, CvDocument doc) {
ContactRenderer.render(section, doc.identity(), theme);
});
- List sections = doc.sections();
+ // Single-column preset — only renders MAIN-slot sections.
+ // Sidebar / footer placements are intentionally dropped here.
+ List sections = doc.sectionsIn(Slot.MAIN);
for (int i = 0; i < sections.size(); i++) {
final CvSection sec = sections.get(i);
final int idx = i;
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java
new file mode 100644
index 00000000..272e852f
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java
@@ -0,0 +1,149 @@
+package com.demcha.compose.document.templates.cv.v2.data;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CvDocumentSlotTest {
+
+ private static CvIdentity identity() {
+ return CvIdentity.builder()
+ .name("Jane", "Doe")
+ .contact("+44 0", "j@d.com", "London")
+ .build();
+ }
+
+ private static ParagraphSection summary() {
+ return new ParagraphSection("Summary", "body");
+ }
+
+ private static RowsSection skills() {
+ return RowsSection.builder("Skills", RowStyle.BULLETED)
+ .row("Languages", "Java").build();
+ }
+
+ @Test
+ void builder_section_defaults_to_main() {
+ ParagraphSection s = summary();
+ CvDocument doc = CvDocument.builder()
+ .identity(identity())
+ .section(s)
+ .build();
+
+ assertThat(doc.placements()).hasSize(1);
+ assertThat(doc.placements().get(0).slot()).isEqualTo(Slot.MAIN);
+ assertThat(doc.slotOf(s)).isEqualTo(Slot.MAIN);
+ }
+
+ @Test
+ void builder_section_with_explicit_slot_uses_it() {
+ ParagraphSection s = summary();
+ CvDocument doc = CvDocument.builder()
+ .identity(identity())
+ .section(Slot.SIDEBAR, s)
+ .build();
+
+ assertThat(doc.placements().get(0).slot()).isEqualTo(Slot.SIDEBAR);
+ assertThat(doc.slotOf(s)).isEqualTo(Slot.SIDEBAR);
+ }
+
+ @Test
+ void sections_returns_all_regardless_of_slot() {
+ ParagraphSection mainSec = summary();
+ RowsSection sidebarSec = skills();
+ CvDocument doc = CvDocument.builder()
+ .identity(identity())
+ .section(mainSec)
+ .section(Slot.SIDEBAR, sidebarSec)
+ .build();
+
+ assertThat(doc.sections()).containsExactly(mainSec, sidebarSec);
+ }
+
+ @Test
+ void sectionsIn_filters_by_slot() {
+ ParagraphSection mainSec = summary();
+ RowsSection sidebarSec = skills();
+ CvDocument doc = CvDocument.builder()
+ .identity(identity())
+ .section(mainSec)
+ .section(Slot.SIDEBAR, sidebarSec)
+ .build();
+
+ assertThat(doc.sectionsIn(Slot.MAIN)).containsExactly(mainSec);
+ assertThat(doc.sectionsIn(Slot.SIDEBAR)).containsExactly(sidebarSec);
+ assertThat(doc.sectionsIn(Slot.FOOTER)).isEmpty();
+ }
+
+ @Test
+ void slotOf_returns_main_for_unknown_section() {
+ ParagraphSection orphan = new ParagraphSection("Orphan", "x");
+ CvDocument doc = CvDocument.builder()
+ .identity(identity())
+ .section(summary())
+ .build();
+
+ assertThat(doc.slotOf(orphan)).isEqualTo(Slot.MAIN);
+ }
+
+ @Test
+ void sections_varargs_with_slot_places_all_in_that_slot() {
+ ParagraphSection a = new ParagraphSection("A", "x");
+ ParagraphSection b = new ParagraphSection("B", "y");
+ CvDocument doc = CvDocument.builder()
+ .identity(identity())
+ .sections(Slot.SIDEBAR, a, b)
+ .build();
+
+ assertThat(doc.sectionsIn(Slot.SIDEBAR)).containsExactly(a, b);
+ assertThat(doc.sectionsIn(Slot.MAIN)).isEmpty();
+ }
+
+ @Test
+ @SuppressWarnings("deprecation")
+ void deprecated_ofMainSections_wraps_everything_in_main() {
+ ParagraphSection a = new ParagraphSection("A", "x");
+ ParagraphSection b = new ParagraphSection("B", "y");
+ CvDocument doc = CvDocument.ofMainSections(identity(), List.of(a, b));
+
+ assertThat(doc.sectionsIn(Slot.MAIN)).containsExactly(a, b);
+ assertThat(doc.sectionsIn(Slot.SIDEBAR)).isEmpty();
+ }
+
+ @Test
+ void placements_preserves_source_order_across_slots() {
+ ParagraphSection a = new ParagraphSection("A", "x");
+ RowsSection b = skills();
+ ParagraphSection c = new ParagraphSection("C", "z");
+ CvDocument doc = CvDocument.builder()
+ .identity(identity())
+ .section(a)
+ .section(Slot.SIDEBAR, b)
+ .section(c)
+ .build();
+
+ // sections() preserves the global add order
+ assertThat(doc.sections()).containsExactly(a, b, c);
+ // sectionsIn filters but keeps relative order
+ assertThat(doc.sectionsIn(Slot.MAIN)).containsExactly(a, c);
+ }
+
+ @Test
+ void rejects_null_section() {
+ CvDocument.Builder b = CvDocument.builder().identity(identity());
+ assertThatThrownBy(() -> b.section(null))
+ .isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> b.section(Slot.SIDEBAR, null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void rejects_null_slot() {
+ CvDocument.Builder b = CvDocument.builder().identity(identity());
+ assertThatThrownBy(() -> b.section(null, summary()))
+ .isInstanceOf(NullPointerException.class);
+ }
+}
From 3017893eb4519ee5b7e15d7ff903d99f8be97dac Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 24 May 2026 13:26:37 +0200
Subject: [PATCH 2/2] v2(Phase 2): port ModernProfessional preset
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Second preset on the v2 architecture — proves the
compose-don't-subclass pattern works for a visually-different style
on the same data + same body renderers. Single-column, no slots
needed (those are exercised in Phase 3).
What's new
----------
- presets/ModernProfessional.java — Helvetica-based preset with
right-aligned big slate-blue display name, flat bright-blue bold
section titles (no banner panels), pipe-separated contact + links
on the right. Visual signature ported from the v1 preset of the
same name.
- theme/CvTheme.modernProfessional() factory — Helvetica typography,
tighter spacing, classic palette + decoration. Body renderers
(ParagraphRenderer, RowRenderer, EntryRenderer, SectionDispatcher)
consumed unchanged.
- theme/CvTypography.modernProfessional() — Helvetica scale: 28pt
name, 17.4pt section title, 10pt body.
- theme/CvSpacing.modernProfessional() — tighter spacing for
single-page-friendly proportions.
- examples/CvModernV2Example — renders cv-modern-professional-v2.pdf
against the shared sample data.
- ModernProfessionalSmokeTest — 4 tests (identity, default theme,
custom theme, theme portability).
Architectural notes
-------------------
- The preset's three preset-specific colours (slate-blue name,
bright-blue section title, royal-blue link) live as private static
finals inside the preset class, NOT in CvPalette. The palette has
four fields today (ink/muted/rule/banner); adding three more for
a single preset would pollute the shared theme record. When a
second preset reaches for the same accent colours, extract them
to CvPalette and update both call sites.
- Section title rendering is an inline private method
(renderSectionTitle), same pattern as MinimalUnderlined. Once 3+
presets need a flat-title rendering, factor out a
SectionTitleRenderer component with style variants. Until then,
per-preset inline keeps each preset readable end-to-end.
- Preset reads doc.sectionsIn(Slot.MAIN) — sidebar content is
silently dropped, consistent with the single-column convention
established in Phase 1.
Test results
------------
- 38/38 v2 tests pass (4 new ModernProfessional + 34 prior).
- cv-modern-professional-v2.pdf renders cleanly with the v1 visual
signature (slate-blue name right, bright-blue section titles,
Helvetica body). One row of content spills onto page 2 vs the v1
single-page render — minor spacing fidelity gap, not blocking.
Engine and v1 surface untouched.
---
.../templates/cv/v2/CvModernV2Example.java | 47 ++++
.../cv/v2/presets/ModernProfessional.java | 228 ++++++++++++++++++
.../templates/cv/v2/theme/CvSpacing.java | 24 ++
.../templates/cv/v2/theme/CvTheme.java | 19 ++
.../templates/cv/v2/theme/CvTypography.java | 17 ++
.../presets/ModernProfessionalSmokeTest.java | 81 +++++++
6 files changed, 416 insertions(+)
create mode 100644 examples/src/main/java/com/demcha/examples/templates/cv/v2/CvModernV2Example.java
create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java
create mode 100644 src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessionalSmokeTest.java
diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvModernV2Example.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvModernV2Example.java
new file mode 100644
index 00000000..a3e23479
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvModernV2Example.java
@@ -0,0 +1,47 @@
+package com.demcha.examples.templates.cv.v2;
+
+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.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
+import com.demcha.compose.document.templates.cv.v2.presets.ModernProfessional;
+import com.demcha.examples.support.ExampleDataFactory;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.nio.file.Path;
+
+/**
+ * Renders the v2 Modern Professional CV preset against the shared
+ * sample data — single-page, right-aligned slate-blue name, flat
+ * bright-blue section titles.
+ *
+ * Output:
+ * {@code examples/target/generated-pdfs/templates/cv/cv-modern-professional-v2.pdf}.
+ */
+public final class CvModernV2Example {
+
+ private CvModernV2Example() {
+ }
+
+ public static Path generate() throws Exception {
+ Path outputFile = ExampleOutputPaths.prepare(
+ "templates/cv", "cv-modern-professional-v2.pdf");
+ CvDocument doc = ExampleDataFactory.sampleCvDocumentV2();
+ DocumentTemplate template = ModernProfessional.create();
+
+ float m = (float) ModernProfessional.RECOMMENDED_MARGIN;
+ try (DocumentSession document = GraphCompose.document(outputFile)
+ .pageSize(DocumentPageSize.A4)
+ .margin(m, m, m, m)
+ .create()) {
+ template.compose(document, doc);
+ document.buildPdf();
+ }
+ return outputFile;
+ }
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("Generated: " + generate());
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java
new file mode 100644
index 00000000..03cf976e
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java
@@ -0,0 +1,228 @@
+package com.demcha.compose.document.templates.cv.v2.presets;
+
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.DocumentLinkOptions;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+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.v2.components.SectionDispatcher;
+import com.demcha.compose.document.templates.cv.v2.data.CvContact;
+import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
+import com.demcha.compose.document.templates.cv.v2.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.v2.data.CvLink;
+import com.demcha.compose.document.templates.cv.v2.data.CvSection;
+import com.demcha.compose.document.templates.cv.v2.data.Slot;
+import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import com.demcha.compose.font.FontName;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * v2 port of the canonical "Modern Professional" CV preset.
+ *
+ * Visual signature ported from the legacy v1 preset:
+ *
+ * - Right-aligned big slate-blue display name
+ * (no spaced caps, no centring) at the top.
+ * - Right-aligned pipe-separated contact + link row beneath.
+ * - Large bright-blue bold section titles —
+ * flat, left-aligned, no banner panel.
+ * - Body text in Helvetica 10pt — denser than Boxed Sections.
+ * - Single-page-friendly proportions on A4 with 18pt margins.
+ *
+ *
+ * Why some colours live inside this preset and not in
+ * {@link CvTheme}: the slate-blue display name and the
+ * bright-blue accent for section titles are unique to this preset —
+ * no other v2 preset shares them today. Putting them in
+ * {@link com.demcha.compose.document.templates.cv.v2.theme.CvPalette}
+ * would pollute the palette with single-use fields. When (or if) a
+ * second preset reaches for the same colours, extract them to
+ * {@code CvPalette} and update both presets.
+ *
+ * Architectural lesson learned in Phase 2:
+ * single-column presets that don't fit the boxed-banner visual
+ * (e.g. flat titles, underlined titles, coloured titles) currently
+ * inline their own {@code renderSectionTitle} helper. Once 3+ presets
+ * share this need, factor out a {@code SectionTitleRenderer}
+ * component with style variants. Until then, the per-preset inline
+ * helper keeps each preset readable end-to-end.
+ */
+public final class ModernProfessional {
+
+ /** Stable template identifier. */
+ public static final String ID = "modern-professional";
+
+ /** Human-readable display name. */
+ public static final String DISPLAY_NAME = "Modern Professional";
+
+ /** Recommended page margin (in points) — matches the legacy v1 preset. */
+ public static final double RECOMMENDED_MARGIN = 18.0;
+
+ /** Slate-blue used by the display name. Preset-specific. */
+ private static final DocumentColor NAME_COLOR = DocumentColor.rgb(44, 62, 80);
+
+ /** Bright-blue used by section titles. Preset-specific. */
+ private static final DocumentColor SECTION_TITLE_COLOR =
+ DocumentColor.rgb(41, 128, 185);
+
+ /** Royal-blue used by contact links. Preset-specific. */
+ private static final DocumentColor LINK_COLOR = DocumentColor.rgb(65, 105, 225);
+
+ private ModernProfessional() {
+ }
+
+ /**
+ * Builds the preset with the Modern Professional theme
+ * ({@link CvTheme#modernProfessional()}).
+ */
+ public static DocumentTemplate create() {
+ return create(CvTheme.modernProfessional());
+ }
+
+ /**
+ * Builds the preset with a caller-supplied theme. Allows
+ * variations on the Modern Professional theme (different
+ * typography scale, custom spacing) without forking this class.
+ */
+ public static DocumentTemplate create(CvTheme theme) {
+ Objects.requireNonNull(theme, "theme");
+ return new Template(theme);
+ }
+
+ private static final class Template implements DocumentTemplate {
+
+ private final CvTheme theme;
+
+ Template(CvTheme theme) {
+ this.theme = theme;
+ }
+
+ @Override
+ public String id() {
+ return ID;
+ }
+
+ @Override
+ public String displayName() {
+ return DISPLAY_NAME;
+ }
+
+ @Override
+ public void compose(DocumentSession document, CvDocument doc) {
+ Objects.requireNonNull(document, "document");
+ Objects.requireNonNull(doc, "doc");
+
+ PageFlowBuilder pageFlow = document.dsl()
+ .pageFlow()
+ .name("CvV2ModernRoot")
+ .spacing(theme.spacing().pageFlowSpacing())
+ .addSection("Header", section ->
+ renderHeader(section, doc.identity()))
+ .addSection("Contact", section ->
+ renderContact(section, doc.identity()));
+
+ // Single-column preset — only MAIN slot.
+ List sections = doc.sectionsIn(Slot.MAIN);
+ for (int i = 0; i < sections.size(); i++) {
+ final CvSection sec = sections.get(i);
+ final int idx = i;
+ pageFlow.addSection("Title_" + idx, host ->
+ renderSectionTitle(host, sec.title()));
+ pageFlow.addSection("Body_" + idx, host ->
+ SectionDispatcher.renderBody(host, sec, theme));
+ }
+
+ pageFlow.build();
+ }
+
+ /**
+ * Big slate-blue display name, right-aligned. No spaced caps.
+ */
+ private void renderHeader(SectionBuilder section, CvIdentity identity) {
+ DocumentTextStyle nameStyle = DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA_BOLD)
+ .size(theme.typography().sizeHeadline())
+ .decoration(DocumentTextDecoration.BOLD)
+ .color(NAME_COLOR)
+ .build();
+
+ section.padding(DocumentInsets.zero())
+ .addParagraph(p -> p
+ .text(identity.name().full())
+ .textStyle(nameStyle)
+ .align(TextAlign.RIGHT)
+ .margin(DocumentInsets.zero()));
+ }
+
+ /**
+ * Right-aligned pipe-separated contact + links. Links rendered
+ * underlined in royal blue; contact strings in body grey.
+ */
+ private void renderContact(SectionBuilder section, CvIdentity identity) {
+ DocumentTextStyle bodyStyle = DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA)
+ .size(theme.typography().sizeContact())
+ .color(theme.palette().ink())
+ .build();
+ DocumentTextStyle linkStyle = DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA)
+ .size(theme.typography().sizeContact())
+ .decoration(DocumentTextDecoration.UNDERLINE)
+ .color(LINK_COLOR)
+ .build();
+ DocumentTextStyle separatorStyle = DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA)
+ .size(theme.typography().sizeContact())
+ .color(theme.palette().rule())
+ .build();
+
+ CvContact c = identity.contact();
+ section.padding(theme.spacing().contactPadding())
+ .accentBottom(theme.palette().rule(),
+ theme.spacing().accentRuleWidth())
+ .addParagraph(p -> p
+ .textStyle(bodyStyle)
+ .align(TextAlign.RIGHT)
+ .margin(DocumentInsets.zero())
+ .rich(rich -> {
+ rich.style(c.address(), bodyStyle);
+ rich.style(" | ", separatorStyle);
+ rich.style(c.phone(), bodyStyle);
+ rich.style(" | ", separatorStyle);
+ rich.link(c.email(),
+ new DocumentLinkOptions("mailto:" + c.email()));
+ for (CvLink l : identity.links()) {
+ rich.style(" | ", separatorStyle);
+ rich.style(l.label(), linkStyle);
+ }
+ }));
+ }
+
+ /**
+ * Flat bright-blue bold section title, left-aligned, no panel.
+ * This is the visual hallmark of the Modern Professional look.
+ */
+ private void renderSectionTitle(SectionBuilder section, String title) {
+ DocumentTextStyle titleStyle = DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA_BOLD)
+ .size(theme.typography().sizeBanner())
+ .decoration(DocumentTextDecoration.BOLD)
+ .color(SECTION_TITLE_COLOR)
+ .build();
+
+ section.padding(new DocumentInsets(8, 0, 2, 0))
+ .addParagraph(p -> p
+ .text(title)
+ .textStyle(titleStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero()));
+ }
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java
index 2b4ae669..10881856 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java
@@ -72,4 +72,28 @@ public static CvSpacing classic() {
1.0, // entryTitleWeight
0.45); // entryDateWeight
}
+
+ /**
+ * Tighter spacing for the Modern Professional preset — no banner
+ * panels, denser body, single-page-friendly proportions.
+ * Banner-related fields (corner radius, inner padding, margin)
+ * are left non-zero so a future preset that wants to draw an MP
+ * banner can read them; the canonical MP preset ignores them.
+ */
+ public static CvSpacing modernProfessional() {
+ return new CvSpacing(
+ 4, // pageFlowSpacing
+ 3, // sectionBodySpacing
+ new DocumentInsets(2, 0, 0, 0), // sectionBodyPadding
+ new DocumentInsets(0, 0, 0, 0), // headlinePadding
+ new DocumentInsets(0, 0, 6, 0), // contactPadding
+ 0.0, // bannerCornerRadius (unused)
+ 5.0, // bannerInnerPadding (unused)
+ DocumentInsets.top(6), // bannerMargin (unused — section title margin)
+ 0.7, // accentRuleWidth
+ 2.0, // paragraphMarginTop
+ 10.0, // entryHeaderRowSpacing
+ 1.0, // entryTitleWeight
+ 0.45); // entryDateWeight
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java
index d62e96a1..a7cb88e3 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java
@@ -68,6 +68,25 @@ public static CvTheme boxedClassic() {
CvDecoration.classic());
}
+ /**
+ * The "Modern Professional" look — Helvetica throughout, larger
+ * scale, tighter spacing. Body palette is the classic ink/muted
+ * pair; the preset itself adds the slate-blue name and
+ * bright-blue section title accents because those colours are not
+ * shared with any other v2 preset today.
+ *
+ * When (or if) a second preset wants the same accent palette,
+ * extract those colours into a new field on {@link CvPalette} and
+ * point both presets at it.
+ */
+ public static CvTheme modernProfessional() {
+ return new CvTheme(
+ CvPalette.classic(),
+ CvTypography.modernProfessional(),
+ CvSpacing.modernProfessional(),
+ CvDecoration.classic());
+ }
+
// -- pre-built text-style helpers ------------------------------------
// Renderers ask the theme for an already-composed DocumentTextStyle
// instead of re-assembling font + size + decoration + colour every
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java
index e3cdbb53..1e4dea62 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java
@@ -61,4 +61,21 @@ public static CvTypography classic() {
8.6, // body
1.4); // line spacing
}
+
+ /**
+ * Helvetica scale for the Modern Professional preset — larger
+ * display name, larger section titles, comfortable body size.
+ */
+ public static CvTypography modernProfessional() {
+ return new CvTypography(
+ FontName.HELVETICA_BOLD, FontName.HELVETICA,
+ 28.0, // headline (display name)
+ 9.0, // contact
+ 17.4, // banner (used as section title here)
+ 10.5, // entry title
+ 10.0, // entry date
+ 9.5, // entry subtitle
+ 10.0, // body
+ 1.35); // line spacing
+ }
}
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessionalSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessionalSmokeTest.java
new file mode 100644
index 00000000..88a30db1
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessionalSmokeTest.java
@@ -0,0 +1,81 @@
+package com.demcha.compose.document.templates.cv.v2.presets;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
+import com.demcha.compose.document.templates.cv.v2.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.v2.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.v2.data.RowStyle;
+import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
+import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Smoke test for the v2 ModernProfessional preset — proves the
+ * compose-don't-subclass pattern works for a second visually-distinct
+ * preset on the same data + same body renderers.
+ */
+class ModernProfessionalSmokeTest {
+
+ @Test
+ void exposes_stable_identity() {
+ DocumentTemplate template = ModernProfessional.create();
+ assertThat(template.id()).isEqualTo("modern-professional");
+ assertThat(template.displayName()).isEqualTo("Modern Professional");
+ }
+
+ @Test
+ void default_factory_uses_modernProfessional_theme_and_renders() throws Exception {
+ DocumentTemplate template = ModernProfessional.create();
+ renderAndAssertNonEmpty(template, fullDocument());
+ }
+
+ @Test
+ void custom_theme_factory_renders() throws Exception {
+ DocumentTemplate template =
+ ModernProfessional.create(CvTheme.modernProfessional());
+ renderAndAssertNonEmpty(template, fullDocument());
+ }
+
+ @Test
+ void renders_with_classic_theme_too() throws Exception {
+ // Preset should not assume any specific theme — handing it the
+ // boxedClassic theme must not throw.
+ DocumentTemplate template =
+ ModernProfessional.create(CvTheme.boxedClassic());
+ renderAndAssertNonEmpty(template, fullDocument());
+ }
+
+ private static void renderAndAssertNonEmpty(
+ DocumentTemplate template, CvDocument doc) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(420, 595)
+ .margin(DocumentInsets.of(18))
+ .create()) {
+
+ template.compose(session, doc);
+ assertThat(session.roots()).isNotEmpty();
+ }
+ }
+
+ private static CvDocument fullDocument() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Jane", "Doe")
+ .contact("+44 0", "j@d.com", "London")
+ .link("LinkedIn", "https://linkedin.com/in/jane-doe")
+ .build())
+ .sections(
+ new ParagraphSection("Summary", "body"),
+ RowsSection.builder("Skills", RowStyle.BULLETED)
+ .row("Languages", "Java").build(),
+ EntriesSection.builder("Experience")
+ .entry("Engineer", "Acme", "2020", "did stuff").build())
+ .build();
+ }
+}