diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvExecutiveExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvExecutiveExample.java
new file mode 100644
index 00000000..5ce0d8a0
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvExecutiveExample.java
@@ -0,0 +1,48 @@
+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.Executive;
+import com.demcha.examples.support.ExampleDataFactory;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.nio.file.Path;
+
+/**
+ * Renders the v2 Executive CV preset against the shared grouped
+ * skills sample data — uppercase Poppins slate masthead, Lato meta +
+ * link row, full-width muted rule, and bronze Poppins module
+ * headings over a single-column body.
+ *
+ *
Output:
+ * {@code examples/target/generated-pdfs/templates/cv/cv-executive-v2.pdf}.
+ */
+public final class CvExecutiveExample {
+
+ private CvExecutiveExample() {
+ }
+
+ public static Path generate() throws Exception {
+ Path outputFile = ExampleOutputPaths.prepare(
+ "templates/cv", "cv-executive-v2.pdf");
+ CvDocument doc = ExampleDataFactory.sampleCvDocumentV2();
+ DocumentTemplate template = Executive.create();
+
+ float m = (float) Executive.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/Executive.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/Executive.java
new file mode 100644
index 00000000..88296e5c
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/Executive.java
@@ -0,0 +1,242 @@
+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.CvTextStyles;
+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.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.document.templates.cv.v2.widgets.Headline;
+import com.demcha.compose.document.templates.cv.v2.widgets.SectionHeader;
+import com.demcha.compose.font.FontName;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * v2 port of the legacy "Executive" CV preset.
+ *
+ * Polished business CV with restrained slate typography, a compact
+ * left-aligned header (UPPERCASE name in deep slate, meta line, link
+ * row, full-width muted rule below), and warm bronze module headings
+ * over a single-column body. Visual signature ported from the legacy
+ * {@code ExecutiveSlateCvTemplate}: Poppins for headings, Lato for
+ * body, slate primary, bronze accent.
+ *
+ * The preset stays a thin orchestrator — the header block is
+ * preset-local inline DSL because V1 splits meta and links across two
+ * rows (no v2 contact widget has that exact shape today), while
+ * everything below the header reuses {@link SectionHeader#flat} for
+ * the bronze module titles and {@link SectionDispatcher#renderBody}
+ * for the body of every {@code CvSection} subtype.
+ */
+public final class Executive {
+
+ /** Stable template identifier. */
+ public static final String ID = "executive";
+
+ /** Human-readable display name. */
+ public static final String DISPLAY_NAME = "Executive";
+
+ /** Recommended page margin (in points) — generous for an executive feel. */
+ public static final double RECOMMENDED_MARGIN = 28.0;
+
+ /**
+ * Deeper slate used by the V1 Executive masthead. The theme's
+ * {@code palette().ink()} is the body-text slate; this is a
+ * preset-local fifth token because no other v2 preset shares it.
+ */
+ private static final DocumentColor PRIMARY_NAME =
+ DocumentColor.rgb(24, 35, 51);
+
+ /**
+ * Warm bronze used by the V1 Executive module headings and the
+ * underlined contact links. Preset-local sixth token.
+ */
+ private static final DocumentColor ACCENT =
+ DocumentColor.rgb(172, 112, 55);
+
+ private Executive() {
+ }
+
+ /**
+ * Builds the preset with its Executive theme.
+ */
+ public static DocumentTemplate create() {
+ return create(CvTheme.executive());
+ }
+
+ /**
+ * Builds the preset with a caller-supplied theme.
+ */
+ 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");
+
+ double width = document.canvas().innerWidth();
+ PageFlowBuilder flow = document.dsl()
+ .pageFlow()
+ .name("CvV2ExecutiveRoot")
+ .spacing(theme.spacing().pageFlowSpacing());
+
+ addHeader(flow, doc.identity(), width);
+
+ List sections = doc.sectionsIn(Slot.MAIN);
+ for (int i = 0; i < sections.size(); i++) {
+ CvSection sec = sections.get(i);
+ int idx = i;
+ flow.addSection("CvV2ExecutiveTitle_" + idx, host ->
+ SectionHeader.flat(host,
+ sec.title().toUpperCase(Locale.ROOT),
+ ACCENT, theme));
+ flow.addSection("CvV2ExecutiveBody_" + idx, host ->
+ SectionDispatcher.renderBody(host, sec, theme));
+ }
+
+ flow.build();
+ }
+
+ private void addHeader(PageFlowBuilder flow, CvIdentity identity,
+ double width) {
+ flow.addSection("CvV2ExecutiveHeader", section -> {
+ section.spacing(2)
+ .padding(DocumentInsets.zero());
+ Headline.uppercaseLeftAligned(section, identity.name(), theme,
+ nameStyle());
+ String meta = joinPipe(identity.contact().address(),
+ identity.contact().phone());
+ if (!meta.isBlank()) {
+ section.addParagraph(paragraph -> paragraph
+ .text(meta)
+ .textStyle(metaStyle())
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(2)));
+ }
+ addLinkRow(section, identity);
+ section.addLine(line -> line
+ .name("CvV2ExecutiveHeaderRule")
+ .horizontal(width)
+ .color(theme.palette().rule())
+ .thickness(theme.spacing().accentRuleWidth())
+ .margin(DocumentInsets.top(5)));
+ });
+ }
+
+ private void addLinkRow(SectionBuilder section, CvIdentity identity) {
+ boolean hasEmail = !identity.contact().email().isBlank();
+ boolean hasLinks = !identity.links().isEmpty();
+ if (!hasEmail && !hasLinks) {
+ return;
+ }
+ DocumentTextStyle bodyStyle = linkRowBodyStyle();
+ DocumentTextStyle linkStyle = linkRowLinkStyle();
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(bodyStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(1))
+ .rich(rich -> {
+ boolean first = true;
+ String email = identity.contact().email();
+ if (!email.isBlank()) {
+ rich.with(email, linkStyle,
+ new DocumentLinkOptions("mailto:" + email));
+ first = false;
+ }
+ for (CvLink link : identity.links()) {
+ if (link.label().isBlank()) {
+ continue;
+ }
+ if (!first) {
+ rich.style(" | ", bodyStyle);
+ }
+ first = false;
+ if (link.url().isBlank()) {
+ rich.style(link.label(), bodyStyle);
+ } else {
+ rich.with(link.label(), linkStyle,
+ new DocumentLinkOptions(link.url()));
+ }
+ }
+ }));
+ }
+
+ private DocumentTextStyle nameStyle() {
+ return CvTextStyles.of(FontName.POPPINS,
+ theme.typography().sizeHeadline(),
+ DocumentTextDecoration.BOLD,
+ PRIMARY_NAME);
+ }
+
+ private DocumentTextStyle metaStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeContact(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle linkRowBodyStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeBody(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle linkRowLinkStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeBody(),
+ DocumentTextDecoration.UNDERLINE,
+ ACCENT);
+ }
+
+ private static String joinPipe(String... parts) {
+ StringBuilder sb = new StringBuilder();
+ for (String part : parts) {
+ if (part == null || part.isBlank()) {
+ continue;
+ }
+ if (sb.length() > 0) {
+ sb.append(" | ");
+ }
+ sb.append(part.trim());
+ }
+ return sb.toString();
+ }
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java
index fcb943fe..0c9bc4e6 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java
@@ -122,4 +122,22 @@ public static CvPalette editorialBlue() {
DocumentColor.rgb(86, 136, 255),
DocumentColor.rgb(193, 201, 211));
}
+
+ /**
+ * Executive palette ported from the v1 {@code ExecutiveSlateCvTemplate}:
+ * mid-slate body ink, soft muted slate for italic subtitles, the
+ * V1 muted-rule grey for thin separators, and a fallback banner
+ * tone inherited from the classic palette (the preset does not
+ * draw banner panels). The display name colour (deeper slate
+ * rgb(24,35,51)) and bronze accent (rgb(172,112,55)) are
+ * preset-local because they are the fifth and sixth tokens —
+ * other v2 presets do not share them today.
+ */
+ public static CvPalette executive() {
+ return new CvPalette(
+ DocumentColor.rgb(49, 58, 72), // ink — V1 BODY slate
+ DocumentColor.rgb(105, 115, 130), // muted — slightly lighter slate
+ DocumentColor.rgb(193, 201, 211), // rule — V1 MUTED_RULE
+ DocumentColor.rgb(220, 226, 230)); // banner — unused, inherits classic
+ }
}
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 665bd1a7..732e5d27 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
@@ -278,4 +278,27 @@ public static CvSpacing editorialBlue() {
0.45, // entryDateWeight
3.0); // entrySeparation
}
+
+ /**
+ * Spacing for the Executive preset: generous executive feel with
+ * an 8pt page-flow rhythm, compact module bodies, and a 1.1pt
+ * full-width rule under the masthead.
+ */
+ public static CvSpacing executive() {
+ return new CvSpacing(
+ 8, // pageFlowSpacing
+ 3, // sectionBodySpacing
+ DocumentInsets.zero(), // sectionBodyPadding
+ DocumentInsets.zero(), // headlinePadding
+ DocumentInsets.top(2), // contactPadding (unused — preset composes header inline)
+ 0.0, // bannerCornerRadius (unused)
+ 5.0, // bannerInnerPadding (unused)
+ DocumentInsets.zero(), // bannerMargin (unused)
+ 1.1, // accentRuleWidth (V1 header rule)
+ 2.0, // paragraphMarginTop
+ 8.0, // entryHeaderRowSpacing
+ 1.0, // entryTitleWeight
+ 0.45, // entryDateWeight
+ 3.0); // entrySeparation
+ }
}
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 a7e7e905..13f6adf9 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
@@ -166,6 +166,21 @@ public static CvTheme editorialBlue() {
CvSpacing.editorialBlue(),
CvDecoration.classic());
}
+
+ /**
+ * The "Executive" look — Poppins masthead + Lato body, deep slate
+ * primary, warm bronze accent on module headings and contact
+ * links, and a thin full-width muted rule under the header.
+ * Visual signature ported from the legacy
+ * {@code ExecutiveSlateCvTemplate}.
+ */
+ public static CvTheme executive() {
+ return new CvTheme(
+ CvPalette.executive(),
+ CvTypography.executive(),
+ CvSpacing.executive(),
+ 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 e64b3246..015f7d89 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
@@ -190,4 +190,24 @@ public static CvTypography editorialBlue() {
9.4, // body
1.45); // line spacing
}
+
+ /**
+ * Poppins headline + Lato body scale ported from the v1
+ * {@code ExecutiveSlateCvTemplate}: a 24pt uppercase masthead, a
+ * 10.8pt section-title slot driving the bronze module headings,
+ * and a 9.5pt body with 1.25 line-spacing tuned for an executive
+ * single-column resume density.
+ */
+ public static CvTypography executive() {
+ return new CvTypography(
+ FontName.POPPINS, FontName.LATO,
+ 24.0, // headline (uppercase masthead)
+ 9.1, // contact meta (V1 META_SIZE = body - 0.4)
+ 10.8, // banner / section title (V1 SECTION_SIZE)
+ 9.5, // entry title
+ 9.5, // entry date
+ 9.0, // entry subtitle (italic)
+ 9.5, // body (V1 BODY_SIZE)
+ 1.25); // line spacing
+ }
}
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java
index c2d0eafa..6e791f64 100644
--- a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java
@@ -111,7 +111,10 @@ private static Stream presets() {
(Supplier>) ClassicSerif::create),
Arguments.of("compact_mono",
CompactMono.RECOMMENDED_MARGIN,
- (Supplier>) CompactMono::create));
+ (Supplier>) CompactMono::create),
+ Arguments.of("executive",
+ Executive.RECOMMENDED_MARGIN,
+ (Supplier>) Executive::create));
}
/**
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ExecutiveSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ExecutiveSmokeTest.java
new file mode 100644
index 00000000..3f773eb3
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ExecutiveSmokeTest.java
@@ -0,0 +1,90 @@
+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.data.SkillsSection;
+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 Executive preset. Covers the inline header
+ * with email-only / link-only / mixed link rows and the
+ * {@link com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher}
+ * fan-out across every {@code CvSection} subtype.
+ */
+class ExecutiveSmokeTest {
+
+ @Test
+ void exposes_stable_identity() {
+ DocumentTemplate template = Executive.create();
+ assertThat(template.id()).isEqualTo("executive");
+ assertThat(template.displayName()).isEqualTo("Executive");
+ }
+
+ @Test
+ void default_factory_renders_full_document() throws Exception {
+ renderAndAssertNonEmpty(Executive.create(), fullDocument());
+ }
+
+ @Test
+ void custom_theme_factory_renders() throws Exception {
+ renderAndAssertNonEmpty(Executive.create(CvTheme.executive()),
+ fullDocument());
+ }
+
+ private static void renderAndAssertNonEmpty(
+ DocumentTemplate template,
+ CvDocument doc) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(420, 595)
+ .margin(DocumentInsets.of(28))
+ .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("Professional Summary",
+ "Builds **reliable** document pipelines."),
+ SkillsSection.builder("Technical Skills")
+ .group("Languages", "Java 21", "Kotlin")
+ .group("Testing", "JUnit 5", "AssertJ")
+ .build(),
+ EntriesSection.builder("Education & Certifications")
+ .entry("MSc Computer Science",
+ "University of Manchester",
+ "2019-2021",
+ "Distinction.")
+ .build(),
+ RowsSection.builder("Projects", RowStyle.BULLETED_STACKED)
+ .row("GraphCompose (Java, PDFBox)",
+ "Declarative PDF layout engine.")
+ .build(),
+ EntriesSection.builder("Professional Experience")
+ .entry("Engineer", "Acme", "2021-2024",
+ "Built rendering services.")
+ .build(),
+ RowsSection.builder("Additional Information", RowStyle.PLAIN)
+ .row("Languages", "English, German")
+ .build())
+ .build();
+ }
+}
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/executive-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/executive-page-0.png
new file mode 100644
index 00000000..1aa5f6a9
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/executive-page-0.png differ
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/executive-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/executive-page-1.png
new file mode 100644
index 00000000..ef3a0bb7
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/executive-page-1.png differ