diff --git a/docs/templates/v2-layered/authoring-presets.md b/docs/templates/v2-layered/authoring-presets.md index 387c44a0..37662ac0 100644 --- a/docs/templates/v2-layered/authoring-presets.md +++ b/docs/templates/v2-layered/authoring-presets.md @@ -2,9 +2,9 @@ You like the layered architecture, but the shipped presets (`BoxedSections`, `MinimalUnderlined`, `ModernProfessional`, -`CenteredHeadline`) don't match the design you want. This doc walks -you through writing a new preset from scratch — **without subclassing, -without duplicating rendering code**. +`CenteredHeadline`, `BlueBanner`) don't match the design you want. +This doc walks you through writing a new preset from scratch — +**without subclassing, without duplicating rendering code**. If you haven't read [quickstart.md](quickstart.md) and [using-templates.md](using-templates.md), do those first. @@ -91,6 +91,7 @@ small set of named variants. | Variant | Visual | |---|---| | `SectionHeader.banner(host, title, theme)` | Pale-grey panel + centred spaced-caps inside | +| `SectionHeader.fullWidthBanner(host, title, theme[, style])` | Full-width fill banner + centred spaced-caps inside; surrounding rules stay in preset page flow | | `SectionHeader.underlined(host, title, theme)` | Small left spaced-caps + thin rule below | | `SectionHeader.flat(host, title, color, theme)` | Large bold title in a given colour, no panel | | `SectionHeader.flatSpacedCaps(host, title, color, theme, titleStyle)` | Small left spaced-caps title in a soft colour, no panel | diff --git a/docs/templates/v2-layered/quickstart.md b/docs/templates/v2-layered/quickstart.md index 2838b3f4..e6b23c14 100644 --- a/docs/templates/v2-layered/quickstart.md +++ b/docs/templates/v2-layered/quickstart.md @@ -19,8 +19,8 @@ GraphCompose's templates v2 (layered) gives you: you can drop into a preset. - **Presets as compositions** — a preset orchestrates widgets in a page flow. `BoxedSections`, `MinimalUnderlined`, - `ModernProfessional`, `CenteredHeadline` ship today; writing your - own is ~150 lines. + `ModernProfessional`, `CenteredHeadline`, `BlueBanner` ship today; + writing your own is ~150 lines. You hand a `CvDocument` to a preset, you get a PDF. The preset internally composes widgets that read theme tokens that ultimately @@ -88,7 +88,8 @@ Same data, different visual. That's the layering. ``` ┌─────────────────────────────────────────────────────────────┐ │ presets/ BoxedSections, MinimalUnderlined, │ -│ ModernProfessional, CenteredHeadline │ +│ ModernProfessional, CenteredHeadline, │ +│ BlueBanner │ │ — composition of widgets in a page flow │ └─────────────────────────────────────────────────────────────┘ │ compose from widgets diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index e09eafb9..142344dc 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -159,9 +159,9 @@ CvDocument doc = CvDocument.builder() ``` **Single-column presets** (`BoxedSections`, `MinimalUnderlined`, -`ModernProfessional`, `CenteredHeadline`) render only `Slot.MAIN`. -Sidebar content is silently dropped — switch to a multi-column preset -to render it. +`ModernProfessional`, `CenteredHeadline`, `BlueBanner`) render only +`Slot.MAIN`. Sidebar content is silently dropped — switch to a +multi-column preset to render it. If you don't use slots at all, your sections go to `MAIN` and every preset renders them. The slot model is opt-in. @@ -171,7 +171,7 @@ preset renders them. The slot model is opt-in. ## Picking a preset -Four shipped today: +Five shipped today: | Preset | Visual signature | |---|---| @@ -179,6 +179,7 @@ Four shipped today: | `MinimalUnderlined.create()` | Centred name with thin rule, small spaced-caps section titles with accent rule, single page | | `ModernProfessional.create()` | Right-aligned big slate-blue name, flat bright-blue bold section titles, dense single page | | `CenteredHeadline.create()` | Centred spaced-caps name, small subheadline, full-width rules around contact and modules | +| `BlueBanner.create()` | Centred PT-Serif name, compact Lato body, blue full-width section banners between thin rules | Each factory has a no-arg form (uses a sensible default theme) and a `create(CvTheme)` form (custom theme). 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 1ee92c73..141632cb 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 @@ -324,7 +324,7 @@ as DSL plumbing. Below is the current catalog. | Variant | Visual | Used in | |---|---|---| -| `Headline.spacedCentered(host, name, theme)` | centred letter-spaced uppercase (`J A N E D O E`) | BoxedSections, MinimalUnderlined, CenteredHeadline | +| `Headline.spacedCentered(host, name, theme)` | centred letter-spaced uppercase (`J A N E D O E`) | BoxedSections, MinimalUnderlined, CenteredHeadline, BlueBanner | | `Headline.rightAligned(host, name, theme)` | right-aligned plain bold (`Jane Doe`) | ModernProfessional | | `Headline.render(host, name, theme, align, spacedCaps)` | low-level: pick any alignment + transform | — | @@ -338,7 +338,7 @@ as DSL plumbing. Below is the current catalog. | Variant | Visual | Used in | |---|---|---| -| `ContactLine.centered(host, identity, theme)` | centred, phone → email → address → links | BoxedSections, MinimalUnderlined, CenteredHeadline | +| `ContactLine.centered(host, identity, theme)` | centred, phone → email → address → links | BoxedSections, MinimalUnderlined, CenteredHeadline, BlueBanner | | `ContactLine.rightAligned(host, identity, theme)` | right-aligned, address → phone → email → links | ModernProfessional | | `ContactLine.twoRowRightAligned(host, identity, theme, bodyStyle, linkStyle, separatorStyle)` | right-aligned address/phone row plus email/link row | ModernProfessional | | `ContactLine.render(host, identity, theme, align, order)` | low-level: pick alignment + field order | — | @@ -352,6 +352,7 @@ change ` | ` to ` · ` or anything else. | Variant | Visual | Used in | |---|---|---| | `SectionHeader.banner(host, title, theme)` | pale-grey panel with centred spaced-caps inside | BoxedSections | +| `SectionHeader.fullWidthBanner(host, title, theme[, style])` | full-width fill banner with centred spaced-caps inside; rules around it stay in the preset page flow | BlueBanner | | `SectionHeader.underlined(host, title, theme)` | small spaced-caps left-aligned, thin rule below | MinimalUnderlined | | `SectionHeader.flat(host, title, color, theme)` | large bold title in a given colour, no panel | ModernProfessional | | `SectionHeader.flatSpacedCaps(host, title, color, theme, titleStyle)` | small spaced-caps title in a soft colour, no panel | CenteredHeadline | 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 45d7007b..904d3779 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 @@ -22,6 +22,7 @@ * │ MinimalUnderlined ← another composition, same pieces │ * │ ModernProfessional ← corporate composition variant │ * │ CenteredHeadline ← classic centred headline variant │ + * │ BlueBanner ← full-width banner composition │ * └─────────────────────────────────────────────────────────────┘ * │ compose from * ▼ @@ -31,8 +32,8 @@ * │ Subheadline .centeredSpacedCaps │ * │ ContactLine .centered | .rightAligned │ * │ .twoRowRightAligned │ - * │ SectionHeader .banner | .underlined | .flat │ - * │ .flatSpacedCaps │ + * │ SectionHeader .banner | .fullWidthBanner | .underlined │ + * │ .flat | .flatSpacedCaps │ * └─────────────────────────────────────────────────────────────┘ * │ delegate to │ read tokens from * ▼ ▼ diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java new file mode 100644 index 00000000..8672607f --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java @@ -0,0 +1,358 @@ +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.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.MarkdownInline; +import com.demcha.compose.document.templates.cv.v2.components.ParagraphRenderer; +import com.demcha.compose.document.templates.cv.v2.components.RowRenderer; +import com.demcha.compose.document.templates.cv.v2.data.CvDocument; +import com.demcha.compose.document.templates.cv.v2.data.CvEntry; +import com.demcha.compose.document.templates.cv.v2.data.CvRow; +import com.demcha.compose.document.templates.cv.v2.data.CvSection; +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.Slot; +import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; +import com.demcha.compose.document.templates.cv.v2.widgets.ContactLine; +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.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** + * v2 port of the legacy "Blue Banner" CV preset. + * + *

Visual signature: centred PT-Serif spaced-caps name, compact + * Lato contact row, full-width blue section banners sandwiched + * between thin dark-blue rules, and dense white body blocks.

+ * + *

Most of the preset is ordinary widget composition. The body + * renderer is deliberately preset-local because Blue Banner differs + * from the shared {@code EntryRenderer}: entry titles are uppercase, + * dates are bold, subtitles are regular ink, and project rows render + * without bullets.

+ */ +public final class BlueBanner { + + /** Stable template identifier. */ + public static final String ID = "blue-banner"; + + /** Human-readable display name. */ + public static final String DISPLAY_NAME = "Blue Banner"; + + /** Recommended page margin (in points). */ + public static final double RECOMMENDED_MARGIN = 28.0; + + private static final double BANNER_RULE_WIDTH = 500.0; + private static final double BANNER_RULE_HORIZONTAL_INSET = 18.0; + + private static final DocumentColor BANNER_TEXT = + DocumentColor.rgb(22, 32, 48); + + private static final List SUMMARY_KEYS = + List.of("summary", "professional summary", "profile"); + private static final List EXPERIENCE_KEYS = + List.of("experience", "professional experience", "employment", "work"); + private static final List EDUCATION_KEYS = + List.of("education", "certifications"); + private static final List SKILL_KEYS = + List.of("technical skills", "skills"); + private static final List ADDITIONAL_KEYS = + List.of("additional information", "additional"); + + private BlueBanner() { + } + + /** + * Builds the preset with the Blue Banner theme. + */ + public static DocumentTemplate create() { + return create(CvTheme.blueBanner()); + } + + /** + * Builds the preset with a caller-supplied theme. The caller can + * adjust palette, typography, spacing, or separator tokens without + * changing the page-flow composition. + */ + 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"); + + DocumentTextStyle bannerTitleStyle = DocumentTextStyle.builder() + .fontName(theme.typography().bodyFont()) + .size(theme.typography().sizeBanner()) + .decoration(DocumentTextDecoration.BOLD) + .color(BANNER_TEXT) + .build(); + + PageFlowBuilder pageFlow = document.dsl() + .pageFlow() + .name("CvV2BlueBannerRoot") + .spacing(theme.spacing().pageFlowSpacing()) + .addSection("Header", section -> + Headline.spacedCentered(section, + doc.identity().name(), theme)) + .addSection("Contact", section -> + ContactLine.centered(section, doc.identity(), theme)); + + List sections = orderedSections(doc); + for (int i = 0; i < sections.size(); i++) { + final CvSection sec = sections.get(i); + final int idx = i; + addBannerRule(pageFlow, "BlueBannerRuleTop_" + idx, theme, 3, 1); + pageFlow.addSection("BlueBannerTitle_" + idx, host -> + SectionHeader.fullWidthBanner(host, sec.title(), + theme, bannerTitleStyle)); + addBannerRule(pageFlow, "BlueBannerRuleBottom_" + idx, theme, 1, 1); + pageFlow.addSection("BlueBannerBody_" + idx, host -> + renderBody(host, sec, theme)); + } + + pageFlow.build(); + } + + private static void addBannerRule(PageFlowBuilder pageFlow, String name, + CvTheme theme, + double topMargin, + double bottomMargin) { + pageFlow.addLine(line -> line + .name(name) + .horizontal(BANNER_RULE_WIDTH) + .color(theme.palette().rule()) + .thickness(theme.spacing().accentRuleWidth()) + .margin(new DocumentInsets( + topMargin, + BANNER_RULE_HORIZONTAL_INSET, + bottomMargin, + BANNER_RULE_HORIZONTAL_INSET))); + } + } + + private static void renderBody(SectionBuilder host, + CvSection section, + CvTheme theme) { + host.spacing(theme.spacing().sectionBodySpacing()) + .padding(theme.spacing().sectionBodyPadding()); + + if (section instanceof ParagraphSection p) { + ParagraphRenderer.render(host, p.body(), theme); + } else if (section instanceof RowsSection r) { + renderRows(host, r, theme); + } else if (section instanceof EntriesSection e) { + for (CvEntry entry : e.entries()) { + renderEntry(host, entry, theme); + } + } else { + throw new IllegalStateException( + "Unknown CvSection subtype: " + section.getClass().getName()); + } + } + + private static void renderRows(SectionBuilder host, + RowsSection section, + CvTheme theme) { + if (section.style() == RowStyle.BULLETED_STACKED) { + for (CvRow row : section.rows()) { + renderPlainProjectRow(host, row, theme); + } + return; + } + for (CvRow row : section.rows()) { + RowRenderer.render(host, row, section.style(), theme); + } + } + + private static void renderPlainProjectRow(SectionBuilder host, + CvRow row, + CvTheme theme) { + String label = row.label().trim(); + String body = row.body().trim(); + DocumentTextStyle labelStyle = theme.entryTitleStyle(); + DocumentTextStyle bodyStyle = theme.bodyStyle(); + + host.addParagraph(p -> p + .textStyle(bodyStyle) + .lineSpacing(theme.typography().bodyLineSpacing()) + .align(TextAlign.LEFT) + .margin(DocumentInsets.top((float) theme.spacing().paragraphMarginTop())) + .rich(rich -> { + rich.style(stripBasicMarkdown(label), labelStyle); + if (!body.isBlank()) { + rich.style(" - ", bodyStyle); + MarkdownInline.append(rich, body, bodyStyle); + } + })); + } + + private static void renderEntry(SectionBuilder section, + CvEntry entry, + CvTheme theme) { + DocumentTextStyle titleStyle = theme.entryTitleStyle(); + DocumentTextStyle dateStyle = style(theme.typography().bodyFont(), + theme.typography().sizeEntryDate(), + DocumentTextDecoration.BOLD, + theme.palette().ink()); + DocumentTextStyle subtitleStyle = style(theme.typography().bodyFont(), + theme.typography().sizeEntrySubtitle(), + DocumentTextDecoration.DEFAULT, + theme.palette().ink()); + DocumentTextStyle bodyStyle = theme.bodyStyle(); + + section.addRow("BlueBannerEntryHeader", row -> row + .spacing(theme.spacing().entryHeaderRowSpacing()) + .weights(theme.spacing().entryTitleWeight(), + theme.spacing().entryDateWeight()) + .addSection("Title", titleColumn -> titleColumn + .padding(DocumentInsets.zero()) + .addParagraph(p -> p + .text(stripBasicMarkdown(entry.title()) + .toUpperCase(Locale.ROOT)) + .textStyle(titleStyle) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero()))) + .addSection("Date", dateColumn -> dateColumn + .padding(DocumentInsets.zero()) + .addParagraph(p -> p + .text(stripBasicMarkdown(entry.date())) + .textStyle(dateStyle) + .align(TextAlign.RIGHT) + .margin(DocumentInsets.zero())))); + + if (!entry.subtitle().isBlank()) { + section.addParagraph(p -> p + .text(stripBasicMarkdown(entry.subtitle())) + .textStyle(subtitleStyle) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero())); + } + + if (!entry.body().isBlank()) { + renderBodyParagraph(section, entry.body(), bodyStyle, + theme.typography().bodyLineSpacing(), + DocumentInsets.top((float) theme.spacing().paragraphMarginTop())); + } + } + + private static void renderBodyParagraph(SectionBuilder host, + String text, + DocumentTextStyle style, + double lineSpacing, + DocumentInsets margin) { + if (text == null || text.isBlank()) { + return; + } + host.addParagraph(p -> p + .textStyle(style) + .lineSpacing(lineSpacing) + .align(TextAlign.LEFT) + .margin(margin) + .rich(rich -> MarkdownInline.append(rich, text.trim(), style))); + } + + private static DocumentTextStyle style(FontName font, + double size, + DocumentTextDecoration decoration, + DocumentColor color) { + return DocumentTextStyle.builder() + .fontName(font) + .size(size) + .decoration(decoration) + .color(color) + .build(); + } + + private static List orderedSections(CvDocument doc) { + List sections = doc.sectionsIn(Slot.MAIN); + List ordered = new ArrayList<>(); + addIfPresent(ordered, findSection(sections, SUMMARY_KEYS)); + addIfPresent(ordered, findSection(sections, EXPERIENCE_KEYS)); + addIfPresent(ordered, findSection(sections, EDUCATION_KEYS)); + addIfPresent(ordered, findSection(sections, SKILL_KEYS)); + addIfPresent(ordered, findSection(sections, ADDITIONAL_KEYS)); + for (CvSection section : sections) { + addIfPresent(ordered, section); + } + return List.copyOf(ordered); + } + + private static void addIfPresent(List sections, CvSection section) { + if (section != null && !sections.contains(section)) { + sections.add(section); + } + } + + private static CvSection findSection(List sections, + List keys) { + for (CvSection section : sections) { + String normalizedTitle = normalize(section.title()); + for (String key : keys) { + if (normalizedTitle.contains(normalize(key))) { + return section; + } + } + } + return null; + } + + private static String stripBasicMarkdown(String value) { + if (value == null) { + return ""; + } + return value + .replace("**", "") + .replace("__", "") + .replace("`", "") + .replace("*", "") + .replace("_", ""); + } + + private static String normalize(String value) { + String safe = value == null ? "" : value; + StringBuilder builder = new StringBuilder(safe.length()); + for (int i = 0; i < safe.length(); i++) { + char current = Character.toLowerCase(safe.charAt(i)); + if (Character.isLetterOrDigit(current)) { + builder.append(current); + } + } + return builder.toString(); + } +} diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvDecoration.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvDecoration.java index 9a4c7aea..da00c6e2 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvDecoration.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvDecoration.java @@ -47,4 +47,12 @@ public record CvDecoration(String bulletGlyph, public static CvDecoration classic() { return new CvDecoration("• ", " ", " | "); } + + /** + * Blue Banner keeps classic bullets but uses the tighter contact + * separator spacing from the legacy preset. + */ + public static CvDecoration blueBanner() { + return new CvDecoration("• ", " ", " | "); + } } 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 ca8103af..c3285b04 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 @@ -56,4 +56,16 @@ public static CvPalette centeredHeadline() { DocumentColor.rgb(188, 188, 188), // rule (#BCBCBC) DocumentColor.rgb(220, 226, 230)); // banner (inherits classic) } + + /** + * Blue Banner palette: compact dark ink, blue section fills, and + * darker blue separator rules. + */ + public static CvPalette blueBanner() { + return new CvPalette( + DocumentColor.rgb(20, 25, 35), + DocumentColor.rgb(85, 85, 85), + DocumentColor.rgb(58, 82, 118), + DocumentColor.rgb(112, 146, 190)); + } } 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 c900d5bb..356492f3 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 @@ -167,4 +167,26 @@ public static CvSpacing modernProfessional() { 0.45, // entryDateWeight 2.5); // entrySeparation } + + /** + * Compact spacing for Blue Banner: tight body blocks, full-width + * title banners, and no extra artificial gap between entries. + */ + public static CvSpacing blueBanner() { + return new CvSpacing( + 4, // pageFlowSpacing + 3, // sectionBodySpacing + new DocumentInsets(3, 4, 0, 4), // sectionBodyPadding + new DocumentInsets(8, 0, 8, 0), // headlinePadding + new DocumentInsets(1.5, 0, 1.5, 0), // contactPadding + 0.0, // bannerCornerRadius + 3.2, // bannerInnerPadding + DocumentInsets.zero(), // bannerMargin + 0.55, // accentRuleWidth + 1.2, // paragraphMarginTop + 8.0, // entryHeaderRowSpacing + 1.0, // entryTitleWeight + 0.4, // entryDateWeight + 0.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 4652c83e..a9828463 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 @@ -102,6 +102,19 @@ public static CvTheme centeredHeadline() { CvDecoration.classic()); } + /** + * The "Blue Banner" look — PT Serif display name, Lato body, + * compact spacing, blue full-width section banners, and tighter + * pipe separators. + */ + public static CvTheme blueBanner() { + return new CvTheme( + CvPalette.blueBanner(), + CvTypography.blueBanner(), + CvSpacing.blueBanner(), + CvDecoration.blueBanner()); + } + // -- 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 7de1427b..376744db 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 @@ -102,4 +102,21 @@ public static CvTypography centeredHeadline() { 8.7, // body 1.45); // line spacing } + + /** + * Compact PT-Serif headline + Lato body scale used by the Blue + * Banner preset. + */ + public static CvTypography blueBanner() { + return new CvTypography( + FontName.PT_SERIF, FontName.LATO, + 20.0, // headline + 7.5, // contact + 7.3, // banner + 8.0, // entry title + 7.7, // entry date + 7.45, // entry subtitle + 7.7, // body + 1.3); // line spacing + } } diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java index f5239886..a8159447 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java @@ -17,6 +17,10 @@ *
    *
  • {@link #banner} — pale-grey panel with centred spaced-caps * title inside. Visual signature of {@code BoxedSections}.
  • + *
  • {@link #fullWidthBanner} — full-width fill banner with + * centred spaced-caps title. Used by compact editorial presets + * such as {@code BlueBanner}; surrounding rules stay in the + * preset's page-flow composition.
  • *
  • {@link #underlined} — small left-aligned spaced-caps title * with a thin accent rule beneath. Visual signature of * {@code MinimalUnderlined}.
  • @@ -62,6 +66,41 @@ public static void banner(SectionBuilder host, String title, CvTheme theme) { .margin(DocumentInsets.zero())); } + /** + * Full-width filled banner with centred spaced-caps title. This + * variant is for presets where the section title is a solid band + * in the main page flow (for example {@code BlueBanner}), not a + * soft inset panel. Any rules above or below the band are + * page-flow ornaments and should be composed by the preset. + */ + public static void fullWidthBanner(SectionBuilder host, String title, CvTheme theme) { + fullWidthBanner(host, title, theme, null); + } + + /** + * Full-width filled banner with an explicit title style override. + * + * @param titleStyleOverride text style for the banner label; pass + * {@code null} to use + * {@link CvTheme#bannerStyle()} + */ + public static void fullWidthBanner(SectionBuilder host, String title, + CvTheme theme, + DocumentTextStyle titleStyleOverride) { + DocumentTextStyle titleStyle = titleStyleOverride != null + ? titleStyleOverride + : theme.bannerStyle(); + host.fillColor(theme.palette().banner()) + .padding(new DocumentInsets(theme.spacing().bannerInnerPadding(), + 0, theme.spacing().bannerInnerPadding(), 0)) + .margin(theme.spacing().bannerMargin()) + .addParagraph(p -> p + .text(TextOrnaments.spacedUpper(title)) + .textStyle(titleStyle) + .align(TextAlign.CENTER) + .margin(DocumentInsets.zero())); + } + /** * Small left-aligned spaced-caps title with a thin accent rule * beneath. Visual signature of {@code MinimalUnderlined}. diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBannerSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBannerSmokeTest.java new file mode 100644 index 00000000..4f1d6c52 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBannerSmokeTest.java @@ -0,0 +1,76 @@ +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 BlueBanner preset. Covers the preset-local + * body dispatcher, especially bulletless project rows and custom + * uppercase timeline entries. + */ +class BlueBannerSmokeTest { + + @Test + void exposes_stable_identity() { + DocumentTemplate template = BlueBanner.create(); + assertThat(template.id()).isEqualTo("blue-banner"); + assertThat(template.displayName()).isEqualTo("Blue Banner"); + } + + @Test + void default_factory_renders_full_document() throws Exception { + DocumentTemplate template = BlueBanner.create(); + renderAndAssertNonEmpty(template, fullDocument()); + } + + @Test + void custom_theme_factory_renders() throws Exception { + DocumentTemplate template = + BlueBanner.create(CvTheme.blueBanner()); + renderAndAssertNonEmpty(template, fullDocument()); + } + + private static void renderAndAssertNonEmpty( + DocumentTemplate template, CvDocument doc) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 595) + .margin(DocumentInsets.of(24)) + .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("GitHub", "https://github.com/jane-doe") + .build()) + .sections( + new ParagraphSection("Professional Summary", "body"), + RowsSection.builder("Technical Skills", RowStyle.BULLETED) + .row("Languages", "Java").build(), + EntriesSection.builder("Professional Experience") + .entry("Engineer", "Acme", "2020-2024", "did stuff") + .build(), + RowsSection.builder("Projects", RowStyle.BULLETED_STACKED) + .row("X", "desc").build(), + RowsSection.builder("Additional Information", RowStyle.PLAIN) + .row("Languages", "English").build()) + .build(); + } +} 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 b4fe7583..a7dd6eb6 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 @@ -95,7 +95,10 @@ private static Stream presets() { (Supplier>) ModernProfessional::create), Arguments.of("centered_headline", CenteredHeadline.RECOMMENDED_MARGIN, - (Supplier>) CenteredHeadline::create)); + (Supplier>) CenteredHeadline::create), + Arguments.of("blue_banner", + BlueBanner.RECOMMENDED_MARGIN, + (Supplier>) BlueBanner::create)); } /** diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java index b1e1f9f3..d8839e41 100644 --- a/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java @@ -61,6 +61,9 @@ void sectionHeader_variants_render_without_throwing() throws Exception { CvTheme theme = CvTheme.boxedClassic(); renderWithSection(section -> SectionHeader.banner(section, "Professional Summary", theme)); + renderWithSection(section -> + SectionHeader.fullWidthBanner(section, "Professional Summary", + CvTheme.blueBanner())); renderWithSection(section -> SectionHeader.underlined(section, "Skills", theme)); renderWithSection(section -> diff --git a/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png new file mode 100644 index 00000000..13f82984 Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-1.png new file mode 100644 index 00000000..be4ceda8 Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-1.png differ