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