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();
+ }
+}