diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvPanelExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvPanelExample.java new file mode 100644 index 00000000..688c4ba0 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvPanelExample.java @@ -0,0 +1,49 @@ +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.Panel; +import com.demcha.examples.support.ExampleDataFactory; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Renders the v2 Panel CV preset against the shared grouped skills + * sample data — pale-teal header card with centred Poppins masthead, + * full-width Profile panel, two-column row pairing Skills + Education + * on the left with Experience + Projects on the right, and a closing + * Additional panel. + * + *

Output: + * {@code examples/target/generated-pdfs/templates/cv/cv-panel-v2.pdf}.

+ */ +public final class CvPanelExample { + + private CvPanelExample() { + } + + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare( + "templates/cv", "cv-panel-v2.pdf"); + CvDocument doc = ExampleDataFactory.sampleCvDocumentV2(); + DocumentTemplate template = Panel.create(); + + float m = (float) Panel.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/Panel.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/Panel.java new file mode 100644 index 00000000..959c57bd --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/Panel.java @@ -0,0 +1,508 @@ +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.DocumentStroke; +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.EntryCompactRenderer; +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.components.SectionLookup; +import com.demcha.compose.document.templates.cv.v2.components.SkillsRenderer; +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.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.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.data.Slot; +import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; +import com.demcha.compose.document.templates.widgets.CardWidget; +import com.demcha.compose.font.FontName; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * v2 port of the legacy "Panel" CV preset. + * + *

Panel-led CV. The page is composed of four full-width cards of + * equal width, all sharing the same shell (rounded corner, thin teal + * stroke):

+ *
    + *
  1. Header card — pale-teal fill, centred Poppins + * UPPERCASE name, optional job title, centred meta + link + * line.
  2. + *
  3. Profile card — full-width white card with + * UPPERCASE teal title, accent strip, and the summary + * paragraph.
  4. + *
  5. Two-column row — left card stacks + * Skills + Education, right card stacks + * Experience + Projects. Each side is one card with + * internal sub-modules separated by a small vertical gap, so the + * page reads as four panels of consistent width.
  6. + *
  7. Additional card — full-width closer with the + * same shell as Profile.
  8. + *
+ * + *

The preset stays a thin orchestrator. Every visual shell goes + * through {@link CardWidget}; the module title + accent strip pair is + * preset-local because no other v2 preset places the tick + * below the title. Body rendering uses a preset-local + * dispatcher (functionally equivalent to + * {@code SectionDispatcher.renderBody}) that draws + * {@link EntriesSection} headers as a single "title - date" paragraph + * via {@link EntryCompactRenderer#titleDateBody} instead of the + * standard {@code EntryRenderer}'s 2-column Row — the engine bans + * nested horizontal rows, and the side cards sit inside the + * {@code flow.addRow}.

+ */ +public final class Panel { + + /** Stable template identifier. */ + public static final String ID = "panel"; + + /** Human-readable display name. */ + public static final String DISPLAY_NAME = "Panel"; + + /** Recommended page margin (in points) — matches V1 ProductLeader. */ + public static final double RECOMMENDED_MARGIN = 18.0; + + /** V1 ProductLeader deep navy used for the masthead text. */ + private static final DocumentColor HEADER_TEXT = + DocumentColor.rgb(20, 44, 66); + + /** V1 ProductLeader teal accent used for module titles + links. */ + private static final DocumentColor ACCENT = + DocumentColor.rgb(0, 128, 128); + + /** V1 ProductLeader white panel fill. */ + private static final DocumentColor PANEL_FILL = DocumentColor.WHITE; + + /** Width of the accent strip drawn under each module title. */ + private static final double ACCENT_STRIP_WIDTH = 54.0; + + /** + * Stroke thickness shared by every Panel card (header, profile, + * side modules, additional). Keeping a single value here is the + * only knob that makes all panels render with visually identical + * borders — diverging this between the header and the modules + * leaks straight into the visible card outline width. + */ + private static final double PANEL_STROKE_THICKNESS = 0.45; + + private static final List SUMMARY_KEYS = + List.of("summary", "professional summary", "profile"); + private static final List SKILL_KEYS = + List.of("technical skills", "skills"); + private static final List EDUCATION_KEYS = + List.of("education", "certifications"); + private static final List EXPERIENCE_KEYS = + List.of("experience", "professional experience", "employment", "work"); + private static final List PROJECT_KEYS = + List.of("projects", "project"); + private static final List ADDITIONAL_KEYS = + List.of("additional information", "additional"); + + private Panel() { + } + + /** + * Builds the preset with its Panel theme. + */ + public static DocumentTemplate create() { + return create(CvTheme.panel()); + } + + /** + * 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"); + + // Pre-compute card widths so every panel renders at an + // identical outer width regardless of content. Sections in + // the v2 engine fit content min-width by default, so the + // longest line in (say) Skills would otherwise push that + // card wider than Education or Additional. The widthAnchor + // spacer below pins each card's content area to the + // pre-computed target. + double innerWidth = document.canvas().innerWidth(); + double gap = theme.spacing().pageFlowSpacing(); + double cardPadding = theme.spacing().bannerInnerPadding(); + double fullCardContentWidth = innerWidth - 2 * cardPadding; + double sideCardContentWidth = + (innerWidth - gap) / 2 - 2 * cardPadding; + + List sections = doc.sectionsIn(Slot.MAIN); + PageFlowBuilder flow = document.dsl() + .pageFlow() + .name("CvV2PanelRoot") + .spacing(gap); + + CvSection summary = SectionLookup.firstMatching(sections, SUMMARY_KEYS); + CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS); + CvSection education = SectionLookup.firstMatching(sections, EDUCATION_KEYS); + CvSection experience = SectionLookup.firstMatching(sections, EXPERIENCE_KEYS); + CvSection projects = SectionLookup.firstMatching(sections, PROJECT_KEYS); + CvSection additional = SectionLookup.firstMatching(sections, ADDITIONAL_KEYS); + + addHeader(flow, doc.identity(), fullCardContentWidth); + addFullWidthPanel(flow, "Profile", "Profile", summary, + fullCardContentWidth); + + // Left column = three separate cards (Skills, Education, + // Additional). Right column = two separate cards (Experience, + // Projects). Every card is anchored to sideCardContentWidth + // so all panels in each column are visually identical width. + boolean leftHasContent = hasContent(skills) || hasContent(education) + || hasContent(additional); + boolean rightHasContent = hasContent(experience) || hasContent(projects); + if (leftHasContent || rightHasContent) { + flow.addRow("CvV2PanelStacked", row -> row + .spacing(gap) + .weights(1.0, 1.0) + .addSection("CvV2PanelStackedLeft", left -> { + left.spacing(gap); + addSidePanel(left, "Skills", "Skills", + skills, sideCardContentWidth); + addSidePanel(left, "Education", "Education", + education, sideCardContentWidth); + addSidePanel(left, "Additional", "Additional", + additional, sideCardContentWidth); + }) + .addSection("CvV2PanelStackedRight", right -> { + right.spacing(gap); + addSidePanel(right, "Experience", "Experience", + experience, sideCardContentWidth); + addSidePanel(right, "Projects", "Projects", + projects, sideCardContentWidth); + })); + } + + flow.build(); + } + + private void addHeader(PageFlowBuilder flow, CvIdentity identity, + double anchorWidth) { + CardWidget.render(flow, "CvV2PanelHeader", + headerStyle(), + card -> { + widthAnchor(card, anchorWidth); + // Inline the name paragraph instead of going through + // Headline.uppercaseCentered — that widget calls + // host.padding(theme.spacing.headlinePadding()) which + // overrides the card's padding set by CardWidget, + // making the header outline visibly wider than the + // panel cards below it. + card.addParagraph(paragraph -> paragraph + .text(identity.name().full() + .toUpperCase(Locale.ROOT)) + .textStyle(nameStyle()) + .align(TextAlign.CENTER) + .margin(DocumentInsets.zero())); + if (!identity.jobTitle().isBlank()) { + card.addParagraph(paragraph -> paragraph + .text(identity.jobTitle()) + .textStyle(headerBodyStyle()) + .align(TextAlign.CENTER) + .margin(DocumentInsets.zero())); + } + String contact = joinPipe(identity.contact().address(), + identity.contact().phone()); + if (!contact.isBlank()) { + card.addParagraph(paragraph -> paragraph + .text(contact) + .textStyle(headerMetaStyle()) + .align(TextAlign.CENTER) + .margin(DocumentInsets.zero())); + } + addLinkRow(card, identity); + }); + } + + private void addLinkRow(SectionBuilder section, CvIdentity identity) { + boolean hasEmail = !identity.contact().email().isBlank(); + boolean hasLinks = !identity.links().isEmpty(); + if (!hasEmail && !hasLinks) { + return; + } + DocumentTextStyle bodyStyle = headerMetaStyle(); + DocumentTextStyle linkStyle = headerLinkStyle(); + section.addParagraph(paragraph -> paragraph + .textStyle(bodyStyle) + .align(TextAlign.CENTER) + .margin(DocumentInsets.zero()) + .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())); + } + } + })); + } + + /** + * Renders a full-width top-level card (Profile). Shares the + * exact same shell as {@link #addSidePanel} and + * {@link #headerStyle} so every panel on the page draws with + * the same outline width and corner radius. + */ + private void addFullWidthPanel(PageFlowBuilder flow, String name, + String title, CvSection section, + double anchorWidth) { + if (!hasContent(section)) { + return; + } + CardWidget.render(flow, "CvV2Panel" + name + "Card", + panelStyle(), + card -> { + widthAnchor(card, anchorWidth); + renderModuleBody(card, title, section); + }); + } + + /** + * Renders one panel card inside a left/right column section. + * Each section in a side column gets its own card so the + * column reads as a stack of separately-bordered panels of + * identical width but varying height. + */ + private void addSidePanel(SectionBuilder column, String name, + String title, CvSection section, + double anchorWidth) { + if (!hasContent(section)) { + return; + } + CardWidget.render(column, "CvV2Panel" + name + "Card", + panelStyle(), + card -> { + widthAnchor(card, anchorWidth); + renderModuleBody(card, title, section); + }); + } + + /** + * Anchors a card's content min-width to the given target. Sections + * in the v2 engine default to fit-content widths inside columns; + * adding a zero-height spacer of the exact target width forces the + * card's content area to that width so every panel renders at the + * same outer width regardless of how long its longest paragraph is. + */ + private void widthAnchor(SectionBuilder card, double width) { + card.spacer(width, 0.0); + } + + private static boolean hasContent(CvSection section) { + return section != null && SectionLookup.hasContent(section); + } + + private void renderModuleBody(SectionBuilder card, String title, + CvSection section) { + card.addParagraph(paragraph -> paragraph + .text(title.toUpperCase(Locale.ROOT)) + .textStyle(moduleTitleStyle()) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero())) + .addShape(shape -> shape + .name("CvV2PanelAccent_" + + SectionLookup.normalize(title)) + .size(ACCENT_STRIP_WIDTH, + theme.spacing().accentRuleWidth()) + .fillColor(ACCENT) + .cornerRadius( + theme.spacing().accentRuleWidth() / 2.0) + .margin(DocumentInsets.zero())); + renderCardBody(card, section); + } + + /** + * Preset-local body dispatcher. Functionally equivalent to + * {@link com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher} + * except that {@link EntriesSection} entries are drawn through + * {@link EntryCompactRenderer#titleDateBody} (single-paragraph + * "title - date" header) instead of the standard + * {@code EntryRenderer}'s two-column Row header. The engine + * bans nested horizontal rows; since every Panel module card + * may sit inside the page-level 2-column {@code flow.addRow}, + * the entry header must stay row-free here. + */ + private void renderCardBody(SectionBuilder card, CvSection section) { + if (section instanceof ParagraphSection paragraph) { + ParagraphRenderer.render(card, paragraph.body(), theme); + } else if (section instanceof SkillsSection skills) { + SkillsRenderer.render(card, skills, theme); + } else if (section instanceof RowsSection rows) { + boolean stackedNeedsSeparator = + rows.style() == RowStyle.BULLETED_STACKED; + for (int i = 0; i < rows.rows().size(); i++) { + if (i > 0 && stackedNeedsSeparator) { + card.spacer(0, theme.spacing().entrySeparation()); + } + RowRenderer.render(card, rows.rows().get(i), + rows.style(), theme); + } + } else if (section instanceof EntriesSection entries) { + for (int i = 0; i < entries.entries().size(); i++) { + if (i > 0) { + card.spacer(0, theme.spacing().entrySeparation()); + } + CvEntry entry = entries.entries().get(i); + EntryCompactRenderer.titleDateBody(card, entry, + theme.entryTitleStyle(), + theme.entryDateStyle(), + theme.entrySubtitleStyle(), + theme.bodyStyle(), + " - ", + 1.0, + DocumentInsets.zero(), + DocumentInsets.zero(), + DocumentInsets.top(theme.spacing().paragraphMarginTop()), + theme.typography().bodyLineSpacing(), + false); + } + } + } + + /** + * Shared shell for every module card (Profile, Skills, + * Education, Additional, Experience, Projects). White fill, + * the same stroke/corner as the header, and the + * {@code bannerInnerPadding} the theme exposes. + */ + private CardWidget.Style panelStyle() { + return CardWidget.Style.builder() + .spacing(theme.spacing().sectionBodySpacing()) + .padding(DocumentInsets.of( + theme.spacing().bannerInnerPadding())) + .fillColor(PANEL_FILL) + .stroke(DocumentStroke.of(theme.palette().rule(), + PANEL_STROKE_THICKNESS)) + .cornerRadius(theme.spacing().bannerCornerRadius()) + .build(); + } + + /** + * Header card shell. Same outline (stroke, padding, corner) as + * {@link #panelStyle()} — only the fill is tinted teal so the + * masthead reads as a distinct band while still being visually + * the same width as every other panel below it. + */ + private CardWidget.Style headerStyle() { + return CardWidget.Style.builder() + .spacing(4) + .padding(DocumentInsets.of( + theme.spacing().bannerInnerPadding())) + .fillColor(theme.palette().banner()) + .stroke(DocumentStroke.of(theme.palette().rule(), + PANEL_STROKE_THICKNESS)) + .cornerRadius(theme.spacing().bannerCornerRadius()) + .build(); + } + + private DocumentTextStyle nameStyle() { + return CvTextStyles.of(FontName.POPPINS, + theme.typography().sizeHeadline(), + DocumentTextDecoration.BOLD, + HEADER_TEXT); + } + + private DocumentTextStyle headerBodyStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeBody(), + DocumentTextDecoration.DEFAULT, + theme.palette().ink()); + } + + private DocumentTextStyle headerMetaStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeContact(), + DocumentTextDecoration.DEFAULT, + theme.palette().ink()); + } + + private DocumentTextStyle headerLinkStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeContact(), + DocumentTextDecoration.UNDERLINE, + ACCENT); + } + + private DocumentTextStyle moduleTitleStyle() { + return CvTextStyles.of(FontName.POPPINS, + theme.typography().sizeBanner(), + DocumentTextDecoration.BOLD, + 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 0c9bc4e6..c3e96546 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 @@ -123,6 +123,23 @@ public static CvPalette editorialBlue() { DocumentColor.rgb(193, 201, 211)); } + /** + * Panel palette ported from the v1 {@code PanelCvTemplateComposer} + * (ProductLeader tokens): body slate ink, slightly lighter slate + * for italic subtitles, the pale teal stroke used by every panel + * border, and the pale teal header card fill. The deeper header + * navy (rgb(20,44,66)), teal accent (rgb(0,128,128)), and white + * panel fill are preset-local because they are the fifth/sixth/ + * seventh tokens — other v2 presets do not share them today. + */ + public static CvPalette panel() { + return new CvPalette( + DocumentColor.rgb(54, 68, 84), // ink — V1 BODY_TEXT/HEADER_META slate + DocumentColor.rgb(105, 117, 132), // muted — slightly lighter slate + DocumentColor.rgb(179, 214, 211), // rule — V1 PANEL_STROKE pale teal + DocumentColor.rgb(231, 246, 244)); // banner — V1 HEADER_FILL pale teal + } + /** * Executive palette ported from the v1 {@code ExecutiveSlateCvTemplate}: * mid-slate body ink, soft muted slate for italic subtitles, the 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 732e5d27..477a6279 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 @@ -279,6 +279,30 @@ public static CvSpacing editorialBlue() { 3.0); // entrySeparation } + /** + * Spacing for the Panel preset: card-led layout that has to fit + * Header / Profile / two-column row / Additional on one A4 page, + * so paddings and inter-card gaps are tight by design. Corner + * radius and accent rule width match the V1 ProductLeader tokens. + */ + public static CvSpacing panel() { + return new CvSpacing( + 6, // pageFlowSpacing (tight inter-card gap) + 3, // sectionBodySpacing (inside a card) + DocumentInsets.zero(), // sectionBodyPadding (the card supplies its own padding) + DocumentInsets.zero(), // headlinePadding + DocumentInsets.zero(), // contactPadding + 7.0, // bannerCornerRadius (V1 CORNER_RADIUS) + 8.0, // bannerInnerPadding (compact card padding) + DocumentInsets.zero(), // bannerMargin + 2.2, // accentRuleWidth (V1 ACCENT_HEIGHT) + 1.0, // paragraphMarginTop + 8.0, // entryHeaderRowSpacing + 1.0, // entryTitleWeight + 0.45, // entryDateWeight + 2.0); // entrySeparation + } + /** * Spacing for the Executive preset: generous executive feel with * an 8pt page-flow rhythm, compact module bodies, and a 1.1pt 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 13f6adf9..9d3e59ab 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 @@ -167,6 +167,21 @@ public static CvTheme editorialBlue() { CvDecoration.classic()); } + /** + * The "Panel" look — Poppins headlines + Lato body, pale teal + * header card and module panels with thin teal stroke, deep navy + * masthead text, and teal section headings with a small accent + * strip beneath each title. Visual signature ported from the v1 + * {@code PanelCvTemplateComposer} (ProductLeader tokens). + */ + public static CvTheme panel() { + return new CvTheme( + CvPalette.panel(), + CvTypography.panel(), + CvSpacing.panel(), + CvDecoration.classic()); + } + /** * The "Executive" look — Poppins masthead + Lato body, deep slate * primary, warm bronze accent on module headings and contact 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 015f7d89..c3c84669 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 @@ -191,6 +191,26 @@ public static CvTypography editorialBlue() { 1.45); // line spacing } + /** + * Poppins headline + Lato body scale ported from the v1 + * {@code PanelCvTemplateComposer} (ProductLeader tokens): a 22pt + * uppercase name in the tinted header card, a 10.4pt section-title + * slot for the teal module headings, and a 9.4pt body with 1.2 + * line spacing tuned for the dense card layout. + */ + public static CvTypography panel() { + return new CvTypography( + FontName.POPPINS, FontName.LATO, + 22.0, // headline (centered uppercase name) + 8.9, // contact (V1 META_SIZE = body - 0.5) + 10.4, // banner / module title (V1 SECTION_SIZE) + 9.4, // entry title + 9.4, // entry date + 9.0, // entry subtitle (italic) + 9.4, // body (V1 BODY_SIZE) + 1.2); // line spacing + } + /** * Poppins headline + Lato body scale ported from the v1 * {@code ExecutiveSlateCvTemplate}: a 24pt uppercase masthead, a diff --git a/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java b/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java index 30c52007..34e4cb5b 100644 --- a/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java +++ b/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java @@ -1,5 +1,6 @@ package com.demcha.compose.document.templates.widgets; +import com.demcha.compose.document.dsl.PageFlowBuilder; import com.demcha.compose.document.dsl.SectionBuilder; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentCornerRadius; @@ -31,21 +32,47 @@ public static void render(SectionBuilder parent, Style safeStyle = style == null ? Style.builder().build() : style; parent.addSection(name, card -> { - card.spacing(safeStyle.spacing()) - .padding(safeStyle.padding()); - if (safeStyle.fillColor() != null) { - card.fillColor(safeStyle.fillColor()); - } - if (safeStyle.stroke() != null) { - card.stroke(safeStyle.stroke()); - } - if (safeStyle.cornerRadius() != null) { - card.cornerRadius(safeStyle.cornerRadius()); - } + applyStyle(card, safeStyle); + content.accept(card); + }); + } + + /** + * Top-level overload — renders the card as a page-flow section so + * presets can place full-width cards directly under + * {@link PageFlowBuilder} without wrapping them in a parent + * section. Visual shell behaves identically to the + * {@link #render(SectionBuilder, String, Style, Consumer)} + * variant. + */ + public static void render(PageFlowBuilder flow, + String name, + Style style, + Consumer content) { + Objects.requireNonNull(flow, "flow"); + Objects.requireNonNull(content, "content"); + Style safeStyle = style == null ? Style.builder().build() : style; + + flow.addSection(name, card -> { + applyStyle(card, safeStyle); content.accept(card); }); } + private static void applyStyle(SectionBuilder card, Style style) { + card.spacing(style.spacing()) + .padding(style.padding()); + if (style.fillColor() != null) { + card.fillColor(style.fillColor()); + } + if (style.stroke() != null) { + card.stroke(style.stroke()); + } + if (style.cornerRadius() != null) { + card.cornerRadius(style.cornerRadius()); + } + } + /** * Visual shell options for {@link CardWidget}. */ 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 6e791f64..a08b259c 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 @@ -114,7 +114,10 @@ private static Stream presets() { (Supplier>) CompactMono::create), Arguments.of("executive", Executive.RECOMMENDED_MARGIN, - (Supplier>) Executive::create)); + (Supplier>) Executive::create), + Arguments.of("panel", + Panel.RECOMMENDED_MARGIN, + (Supplier>) Panel::create)); } /** diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/PanelSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/PanelSmokeTest.java new file mode 100644 index 00000000..05410d14 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/PanelSmokeTest.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 Panel preset. Covers the header card with + * optional job title + link row, plus the two-column row composition + * fed through {@link com.demcha.compose.document.templates.cv.v2.components.SectionLookup} + * and {@link com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher}. + */ +class PanelSmokeTest { + + @Test + void exposes_stable_identity() { + DocumentTemplate template = Panel.create(); + assertThat(template.id()).isEqualTo("panel"); + assertThat(template.displayName()).isEqualTo("Panel"); + } + + @Test + void default_factory_renders_full_document() throws Exception { + renderAndAssertNonEmpty(Panel.create(), fullDocument()); + } + + @Test + void custom_theme_factory_renders() throws Exception { + renderAndAssertNonEmpty(Panel.create(CvTheme.panel()), 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") + .jobTitle("Product Lead") + .contact("+44 0", "j@d.com", "London") + .link("LinkedIn", "https://linkedin.com/in/jane-doe") + .build()) + .sections( + new ParagraphSection("Professional Summary", + "Builds **reliable** product platforms."), + 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("Lead", "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/panel-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/panel-page-0.png new file mode 100644 index 00000000..a2af93a3 Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/panel-page-0.png differ