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:

+ * + * + *

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