diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvEngineeringResumeExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvEngineeringResumeExample.java new file mode 100644 index 00000000..4742fe4a --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvEngineeringResumeExample.java @@ -0,0 +1,50 @@ +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.EngineeringResume; +import com.demcha.examples.support.ExampleDataFactory; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Renders the v2 Engineering Resume CV preset against the shared + * grouped skills sample data — full-width navy command header with + * UPPERCASE Barlow name, right-aligned contact stack with cyan-green + * underlined links, dark navy skill rail (Core Stack / Learning / + * Details) and white evidence cards for Leadership Experience and + * Technical Evidence on the right. + * + *

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

+ */ +public final class CvEngineeringResumeExample { + + private CvEngineeringResumeExample() { + } + + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare( + "templates/cv", "cv-engineering-resume-v2.pdf"); + CvDocument doc = ExampleDataFactory.sampleCvDocumentV2(); + DocumentTemplate template = EngineeringResume.create(); + + float m = (float) EngineeringResume.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/EngineeringResume.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/EngineeringResume.java new file mode 100644 index 00000000..5222019d --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/EngineeringResume.java @@ -0,0 +1,693 @@ +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.DocumentCornerRadius; +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.MarkdownInline; +import com.demcha.compose.document.templates.cv.v2.components.ProjectLabel; +import com.demcha.compose.document.templates.cv.v2.components.SectionLookup; +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.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.RowsSection; +import com.demcha.compose.document.templates.cv.v2.data.SkillGroup; +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 java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** + * v2 port of the legacy "Engineering Resume" CV preset. + * + *

Senior engineering CV with a full-width navy command header + * (UPPERCASE name, subtitle line, right-aligned contact stack with + * cyan-green underlined links), a dark navy skill rail + * (Core Stack / Learning / Details with + * green accent labels), and white evidence cards for Leadership + * Experience plus Technical Evidence on the right.

+ * + *

The preset stays a thin orchestrator. Theme tokens cover body + * ink / muted / rule / profile-band fill; the navy header, brighter + * green accent, navy-rail text variants and the cyan-green contact + * link colour stay preset-local because no other v2 preset shares + * them today (same pattern as NordicClean / EditorialBlue).

+ * + *

Body rendering uses a preset-local dispatcher because the engine + * bans nested horizontal rows and every body card sits inside the + * page-level 2-column {@code flow.addRow}; entries are drawn as a + * single "title / date" header paragraph instead of the standard + * {@code EntryRenderer}'s 2-column Row.

+ */ +public final class EngineeringResume { + + /** Stable template identifier. */ + public static final String ID = "engineering-resume"; + + /** Human-readable display name. */ + public static final String DISPLAY_NAME = "Engineering Resume"; + + /** Recommended page margin (in points) — matches V1 TechLead. */ + public static final double RECOMMENDED_MARGIN = 20.0; + + /** V1 TechLead deep navy used for the command header + rail fill. */ + private static final DocumentColor NAVY = + DocumentColor.rgb(13, 32, 47); + + /** V1 TechLead lighter navy used for inside-rail rule lines. */ + private static final DocumentColor NAVY_SOFT = + DocumentColor.rgb(35, 56, 72); + + /** V1 TechLead green accent for headings, strips and link colour. */ + private static final DocumentColor GREEN = + DocumentColor.rgb(27, 145, 104); + + /** Body text colour for items rendered inside the navy rail. */ + private static final DocumentColor RAIL_TEXT = + DocumentColor.rgb(220, 231, 236); + + /** Secondary text colour (e.g. dates) inside the navy rail. */ + private static final DocumentColor RAIL_DATE = + DocumentColor.rgb(182, 201, 210); + + /** Subtitle colour used under the masthead name. */ + private static final DocumentColor SUBTITLE_COLOR = + DocumentColor.rgb(190, 209, 219); + + /** Contact metadata colour (right-aligned over the navy header). */ + private static final DocumentColor CONTACT_META = + DocumentColor.rgb(196, 211, 220); + + /** Contact-link colour (cyan-green underlined over the navy header). */ + private static final DocumentColor CONTACT_LINK = + DocumentColor.rgb(78, 207, 161); + + /** Fallback masthead subtitle when {@code identity.jobTitle()} is blank. */ + private static final String SUBTITLE_FALLBACK = + "SECURE BACKEND SYSTEMS / DELIVERY LEADERSHIP"; + + 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 EngineeringResume() { + } + + /** + * Builds the preset with its Engineering Resume theme. + */ + public static DocumentTemplate create() { + return create(CvTheme.engineeringResume()); + } + + /** + * 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"); + + List sections = doc.sectionsIn(Slot.MAIN); + PageFlowBuilder flow = document.dsl() + .pageFlow() + .name("CvV2EngineeringResumeRoot") + .spacing(theme.spacing().pageFlowSpacing()); + + addHeader(flow, doc.identity()); + addBody(flow, sections); + + flow.build(); + } + + // -- Header -------------------------------------------------------- + + private void addHeader(PageFlowBuilder flow, CvIdentity identity) { + flow.addSection("CvV2EngineeringResumeHeader", section -> section + .spacing(5) + .padding(new DocumentInsets(13, 15, 13, 15)) + .fillColor(NAVY) + .cornerRadius(DocumentCornerRadius.top( + theme.spacing().bannerCornerRadius())) + .accentBottom(GREEN, theme.spacing().accentRuleWidth()) + .addRow("CvV2EngineeringResumeHeaderRow", row -> row + .spacing(12) + .weights(1.15, 0.85) + .addSection("CvV2EngineeringResumeIdentity", + identityBlock -> addIdentityBlock( + identityBlock, identity)) + .addSection("CvV2EngineeringResumeContact", + contact -> addContactStack(contact, + identity)))); + } + + private void addIdentityBlock(SectionBuilder block, CvIdentity identity) { + block.padding(DocumentInsets.zero()) + .spacing(3) + .addParagraph(paragraph -> paragraph + .text(identity.name().full() + .toUpperCase(Locale.ROOT)) + .textStyle(nameStyle()) + .autoSize(theme.typography().sizeHeadline(), 19.0) + .margin(DocumentInsets.zero())) + .addParagraph(paragraph -> paragraph + .text(headerSubtitleText(identity)) + .textStyle(subtitleStyle()) + .margin(DocumentInsets.zero())); + } + + private void addContactStack(SectionBuilder section, CvIdentity identity) { + section.spacing(2).padding(DocumentInsets.zero()); + DocumentTextStyle meta = contactMetaStyle(); + DocumentTextStyle link = contactLinkStyle(); + for (ContactPart part : contactParts(identity)) { + section.addParagraph(paragraph -> paragraph + .text(part.text()) + .textStyle(part.linkOptions() == null ? meta : link) + .link(part.linkOptions()) + .align(TextAlign.RIGHT) + .margin(DocumentInsets.zero())); + } + } + + private static List contactParts(CvIdentity identity) { + if (identity == null) { + return List.of(); + } + java.util.List parts = new java.util.ArrayList<>(); + addPart(parts, identity.contact().address(), null); + addPart(parts, identity.contact().phone(), null); + String email = identity.contact().email(); + if (!email.isBlank()) { + addPart(parts, email, + new DocumentLinkOptions("mailto:" + email)); + } + for (CvLink link : identity.links()) { + addPart(parts, link.label(), link.url().isBlank() + ? null + : new DocumentLinkOptions(link.url().trim())); + } + return List.copyOf(parts); + } + + private static void addPart(java.util.List parts, + String text, + DocumentLinkOptions linkOptions) { + if (text != null && !text.isBlank()) { + parts.add(new ContactPart(text.trim(), linkOptions)); + } + } + + private static String headerSubtitleText(CvIdentity identity) { + if (identity == null) { + return SUBTITLE_FALLBACK; + } + String jobTitle = identity.jobTitle(); + if (jobTitle == null || jobTitle.isBlank()) { + return SUBTITLE_FALLBACK; + } + return MarkdownInline.plainText(jobTitle) + .toUpperCase(Locale.ROOT); + } + + // -- Body 2-column ------------------------------------------------- + + private void addBody(PageFlowBuilder flow, List sections) { + CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS); + CvSection education = SectionLookup.firstMatching(sections, + EDUCATION_KEYS); + CvSection additional = SectionLookup.firstMatching(sections, + ADDITIONAL_KEYS); + CvSection summary = SectionLookup.firstMatching(sections, + SUMMARY_KEYS); + CvSection experience = SectionLookup.firstMatching(sections, + EXPERIENCE_KEYS); + CvSection projects = SectionLookup.firstMatching(sections, + PROJECT_KEYS); + + flow.addRow("CvV2EngineeringResumeBody", row -> row + .spacing(14) + .weights(0.76, 1.64) + .addSection("CvV2EngineeringResumeRail", rail -> { + rail.spacing(8) + .padding(new DocumentInsets(10, 10, 11, 10)) + .fillColor(NAVY) + .cornerRadius(DocumentCornerRadius.bottom( + theme.spacing().bannerCornerRadius())) + .accentTop(GREEN, 2.0); + addRailSkills(rail, skills); + addRailEducation(rail, education); + addRailAdditional(rail, additional); + }) + .addSection("CvV2EngineeringResumeMain", main -> { + main.spacing(8); + addProfile(main, summary); + addExperience(main, experience); + addProjects(main, projects); + })); + } + + // -- Rail modules --------------------------------------------------- + + private void addRailSkills(SectionBuilder parent, CvSection section) { + if (!hasContent(section) || !(section instanceof SkillsSection skills)) { + return; + } + parent.addSection("CvV2EngineeringResumeSkills", block -> { + addRailHeading(block, "Core Stack"); + List groups = skills.groups(); + for (int i = 0; i < Math.min(groups.size(), 7); i++) { + SkillGroup group = groups.get(i); + block.addParagraph(paragraph -> paragraph + .textStyle(railBodyStyle()) + .lineSpacing(1.0) + .margin(DocumentInsets.bottom(1.8)) + .rich(rich -> { + String category = group.category(); + if (!category.isBlank()) { + rich.style(category + ":", + railLabelStyle()); + rich.style(" " + + compactValues( + group.skillsInline(), 5), + railBodyStyle()); + } else { + rich.style( + compactValues(group.skillsInline(), + 6), + railBodyStyle()); + } + })); + } + }); + } + + private void addRailEducation(SectionBuilder parent, CvSection section) { + if (!hasContent(section) + || !(section instanceof EntriesSection entries)) { + return; + } + parent.addSection("CvV2EngineeringResumeEducation", block -> { + addRailHeading(block, "Learning"); + List list = entries.entries(); + for (int i = 0; i < Math.min(list.size(), 4); i++) { + CvEntry entry = list.get(i); + block.addParagraph(paragraph -> paragraph + .textStyle(railBodyStyle()) + .lineSpacing(1.0) + .margin(DocumentInsets.bottom(2.3)) + .rich(rich -> { + rich.style(entry.title(), railTitleStyle()); + if (!entry.date().isBlank()) { + rich.style(" / " + entry.date(), + railDateStyle()); + } + })); + } + }); + } + + private void addRailAdditional(SectionBuilder parent, CvSection section) { + if (!hasContent(section) + || !(section instanceof RowsSection rows)) { + return; + } + parent.addSection("CvV2EngineeringResumeAdditional", block -> { + addRailHeading(block, "Details"); + List list = rows.rows(); + for (int i = 0; i < Math.min(list.size(), 2); i++) { + CvRow row = list.get(i); + String text = row.label().isBlank() + ? row.body() + : (row.body().isBlank() + ? row.label() + : row.label() + ": " + row.body()); + String clean = MarkdownInline.plainText(text); + block.addParagraph(paragraph -> paragraph + .text(clean) + .textStyle(railBodyStyle()) + .lineSpacing(1.0) + .margin(DocumentInsets.bottom(1.8))); + } + }); + } + + // -- Main modules --------------------------------------------------- + + private void addProfile(SectionBuilder parent, CvSection section) { + if (!hasContent(section) + || !(section instanceof ParagraphSection summary)) { + return; + } + parent.addSection("CvV2EngineeringResumeProfile", card -> card + .spacing(4) + .padding(new DocumentInsets(8, 10, 8, 10)) + .fillColor(theme.palette().banner()) + .accentLeft(GREEN, 3.0) + .cornerRadius(DocumentCornerRadius.right( + theme.spacing().bannerCornerRadius())) + .addParagraph(paragraph -> paragraph + .text("ENGINEERING PROFILE") + .textStyle(profileHeadingStyle()) + .margin(DocumentInsets.zero())) + .addParagraph(paragraph -> paragraph + .text(MarkdownInline.plainText(summary.body())) + .textStyle(profileBodyStyle()) + .lineSpacing(1.2) + .margin(DocumentInsets.zero()))); + } + + private void addExperience(SectionBuilder parent, CvSection section) { + if (!hasContent(section) + || !(section instanceof EntriesSection entries)) { + return; + } + parent.addSection("CvV2EngineeringResumeExperience", block -> { + addMainHeading(block, "Leadership Experience"); + List list = entries.entries(); + for (int i = 0; i < Math.min(list.size(), 2); i++) { + CvEntry entry = list.get(i); + block.addSection("CvV2EngineeringResumeRoleCard", card -> { + card.spacing(3) + .padding(new DocumentInsets(6, 8, 6, 8)) + .fillColor(DocumentColor.WHITE) + .stroke(DocumentStroke.of(theme.palette().rule(), + 0.35)) + .cornerRadius(DocumentCornerRadius.right(3)) + .accentLeft(GREEN, 2.0); + addRoleHeader(card, entry); + if (!entry.subtitle().isBlank()) { + card.addParagraph(paragraph -> paragraph + .text(MarkdownInline.plainText( + entry.subtitle())) + .textStyle(subtitleBodyStyle()) + .margin(DocumentInsets.zero())); + } + if (!entry.body().isBlank()) { + card.addParagraph(paragraph -> paragraph + .text(MarkdownInline.plainText(entry.body())) + .textStyle(roleBodyStyle()) + .lineSpacing(1.08) + .margin(DocumentInsets.zero())); + } + }); + } + }); + } + + private void addProjects(SectionBuilder parent, CvSection section) { + if (!hasContent(section) + || !(section instanceof RowsSection rows)) { + return; + } + parent.addSection("CvV2EngineeringResumeProjects", block -> { + addMainHeading(block, "Technical Evidence"); + List list = rows.rows(); + for (int i = 0; i < Math.min(list.size(), 4); i++) { + CvRow row = list.get(i); + ProjectLabel label = ProjectLabel.parse(row.label()); + String body = MarkdownInline.plainText(row.body()); + block.addSection("CvV2EngineeringResumeProjectCard", card -> card + .spacing(3) + .padding(new DocumentInsets(5, 8, 5, 8)) + .fillColor(DocumentColor.WHITE) + .stroke(DocumentStroke.of(theme.palette().rule(), 0.3)) + .cornerRadius(3) + .addParagraph(paragraph -> paragraph + .textStyle(projectBodyStyle()) + .lineSpacing(1.06) + .margin(DocumentInsets.zero()) + .rich(rich -> { + rich.style(label.title(), + projectTitleStyle()); + if (!label.stack().isBlank()) { + rich.style(" (" + label.stack() + + ")", + projectContextStyle()); + } + if (!body.isBlank()) { + rich.style(" - " + body, + projectBodyStyle()); + } + }))); + } + }); + } + + // -- Headings ------------------------------------------------------- + + private void addRailHeading(SectionBuilder section, String title) { + section.spacing(3) + .addParagraph(paragraph -> paragraph + .text(title.toUpperCase(Locale.ROOT)) + .textStyle(railHeadingStyle()) + .margin(DocumentInsets.zero())) + .addLine(line -> line + .horizontal(82) + .color(NAVY_SOFT) + .thickness(0.8) + .margin(DocumentInsets.bottom(2))); + } + + private void addMainHeading(SectionBuilder section, String title) { + section.spacing(5) + .addParagraph(paragraph -> paragraph + .text(title.toUpperCase(Locale.ROOT)) + .textStyle(mainHeadingStyle()) + .margin(DocumentInsets.zero())) + .addLine(line -> line + .horizontal(176) + .color(GREEN) + .thickness(1.0) + .margin(DocumentInsets.bottom(1))); + } + + private void addRoleHeader(SectionBuilder card, CvEntry entry) { + card.addParagraph(paragraph -> paragraph + .textStyle(roleTitleStyle()) + .margin(DocumentInsets.zero()) + .rich(rich -> { + rich.style(entry.title(), roleTitleStyle()); + if (!entry.date().isBlank()) { + rich.style(" / " + entry.date(), + roleDateStyle()); + } + })); + } + + // -- Style factories ------------------------------------------------ + + private DocumentTextStyle nameStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + theme.typography().sizeHeadline(), + DocumentTextDecoration.BOLD, + DocumentColor.WHITE); + } + + private DocumentTextStyle subtitleStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + 7.6, + DocumentTextDecoration.BOLD, + SUBTITLE_COLOR); + } + + private DocumentTextStyle contactMetaStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeContact(), + DocumentTextDecoration.DEFAULT, + CONTACT_META); + } + + private DocumentTextStyle contactLinkStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeContact(), + DocumentTextDecoration.UNDERLINE, + CONTACT_LINK); + } + + private DocumentTextStyle railHeadingStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + 7.4, + DocumentTextDecoration.BOLD, + GREEN); + } + + private DocumentTextStyle railBodyStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + 6.95, + DocumentTextDecoration.DEFAULT, + RAIL_TEXT); + } + + private DocumentTextStyle railLabelStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + 6.95, + DocumentTextDecoration.BOLD, + GREEN); + } + + private DocumentTextStyle railTitleStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + 6.95, + DocumentTextDecoration.BOLD, + DocumentColor.WHITE); + } + + private DocumentTextStyle railDateStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + 6.7, + DocumentTextDecoration.DEFAULT, + RAIL_DATE); + } + + private DocumentTextStyle mainHeadingStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + theme.typography().sizeBanner(), + DocumentTextDecoration.BOLD, + GREEN); + } + + private DocumentTextStyle profileHeadingStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + 8.0, + DocumentTextDecoration.BOLD, + GREEN); + } + + private DocumentTextStyle profileBodyStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + 7.75, + DocumentTextDecoration.DEFAULT, + theme.palette().ink()); + } + + private DocumentTextStyle roleTitleStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeEntryTitle(), + DocumentTextDecoration.BOLD, + theme.palette().ink()); + } + + private DocumentTextStyle roleDateStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeEntryDate(), + DocumentTextDecoration.BOLD, + GREEN); + } + + private DocumentTextStyle subtitleBodyStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeEntrySubtitle(), + DocumentTextDecoration.DEFAULT, + theme.palette().muted()); + } + + private DocumentTextStyle roleBodyStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeBody(), + DocumentTextDecoration.DEFAULT, + theme.palette().ink()); + } + + private DocumentTextStyle projectTitleStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + 7.35, + DocumentTextDecoration.BOLD, + theme.palette().ink()); + } + + private DocumentTextStyle projectContextStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + 6.85, + DocumentTextDecoration.DEFAULT, + GREEN); + } + + private DocumentTextStyle projectBodyStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + 7.1, + DocumentTextDecoration.DEFAULT, + theme.palette().ink()); + } + } + + // -- Static helpers ---------------------------------------------------- + + private static boolean hasContent(CvSection section) { + return section != null && SectionLookup.hasContent(section); + } + + private static String compactValues(String value, int maxItems) { + String clean = MarkdownInline.plainText(value); + String[] tokens = clean.split(","); + StringBuilder builder = new StringBuilder(); + int count = 0; + for (String token : tokens) { + String trimmed = token.trim(); + if (trimmed.isBlank()) { + continue; + } + if (count > 0) { + builder.append(", "); + } + builder.append(trimmed); + count++; + if (count == maxItems) { + break; + } + } + return builder.toString(); + } + + private record ContactPart(String text, DocumentLinkOptions linkOptions) { + } +} diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimal.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimal.java index 00bf835b..571a1121 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimal.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimal.java @@ -509,18 +509,18 @@ private static List sectionLines(CvSection section) { line.append(header); } if (!subtitle.isBlank()) { - if (line.length() > 0) { + if (!line.isEmpty()) { line.append(" | "); } line.append(subtitle); } if (!dates.isBlank()) { - if (line.length() > 0) { + if (!line.isEmpty()) { line.append(" - "); } line.append(dates); } - if (line.length() > 0) { + if (!line.isEmpty()) { lines.add(line.toString()); } if (!body.isBlank()) { 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 ae51315c..d60c55f1 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)); } + /** + * Engineering Resume palette ported from the v1 + * {@code TechLeadCvTemplateComposer}: body-slate ink, muted slate + * for subtitles, pale teal-green rule that matches the white + * evidence cards, and the soft pale-green profile band fill. Deep + * navy header, brighter green accent, navy-rail variants and the + * cyan-green contact link colour stay preset-local as they are the + * fifth+ tokens — no other v2 preset shares them today. + */ + public static CvPalette engineeringResume() { + return new CvPalette( + DocumentColor.rgb(32, 42, 55), // ink — V1 INK body slate + DocumentColor.rgb(91, 105, 119), // muted — V1 MUTED subtitles + DocumentColor.rgb(190, 212, 204), // rule — V1 RULE pale teal-green + DocumentColor.rgb(232, 246, 239)); // banner — V1 GREEN_SOFT profile fill + } + /** * Timeline Minimal palette: an all-grey scale ported from the v1 * {@code TimelineMinimalCvTemplateComposer} — medium-grey ink, 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 73e237bc..ca44a98e 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 Engineering Resume preset: navy command header + * + 2-column body (navy skill rail / white evidence cards), with + * a 2.5pt accent rule under the header and tight 1pt paragraph + * top so the dense rail + card content reads as a single page. + */ + public static CvSpacing engineeringResume() { + return new CvSpacing( + 8, // pageFlowSpacing + 4, // sectionBodySpacing + DocumentInsets.zero(), // sectionBodyPadding + DocumentInsets.zero(), // headlinePadding + DocumentInsets.zero(), // contactPadding + 4.0, // bannerCornerRadius (card top/bottom radius) + 10.0, // bannerInnerPadding (card padding token) + DocumentInsets.zero(), // bannerMargin + 2.5, // accentRuleWidth (header bottom accent strip) + 1.0, // paragraphMarginTop + 8.0, // entryHeaderRowSpacing + 1.0, // entryTitleWeight + 0.45, // entryDateWeight + 2.0); // entrySeparation + } + /** * Spacing for the Timeline Minimal preset: tight 3-column layout * with a fixed-width axis between the sidebar and main column. 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 6387c883..81649c11 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,22 @@ public static CvTheme editorialBlue() { CvDecoration.classic()); } + /** + * The "Engineering Resume" look — Barlow display + Lato body, deep + * navy command header with cyan-green contact links, dark navy + * skill rail with green accent labels, and white evidence cards + * for Leadership Experience + Technical Evidence on the right. + * Visual signature ported from the v1 + * {@code TechLeadCvTemplateComposer}. + */ + public static CvTheme engineeringResume() { + return new CvTheme( + CvPalette.engineeringResume(), + CvTypography.engineeringResume(), + CvSpacing.engineeringResume(), + CvDecoration.classic()); + } + /** * The "Timeline Minimal" look — Barlow Condensed display + Lato * body, all-grey palette, spaced uppercase name, right-aligned 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 0eac04e4..a35cc4ce 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,25 @@ public static CvTypography editorialBlue() { 1.45); // line spacing } + /** + * Barlow headline + Lato body scale ported from the v1 + * {@code TechLeadCvTemplateComposer}: 24.5pt UPPERCASE masthead, + * 7.8pt main section headings, 7.25pt body, and a 1.08 line + * spacing tuned for dense engineering-resume cards. + */ + public static CvTypography engineeringResume() { + return new CvTypography( + FontName.BARLOW, FontName.LATO, + 24.5, // headline (UPPERCASE name in navy header) + 7.2, // contact (right-aligned navy header stack) + 7.8, // banner / main section heading + 8.0, // entry title (role title in experience cards) + 7.1, // entry date + 7.0, // entry subtitle (employer) + 7.25, // body + 1.08); // line spacing + } + /** * Barlow Condensed headline + Lato body scale ported from the v1 * {@code TimelineMinimalCvTemplateComposer}: 28pt spaced-caps 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 7cc67424..d761360a 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 @@ -120,7 +120,10 @@ private static Stream presets() { (Supplier>) Panel::create), Arguments.of("timeline_minimal", TimelineMinimal.RECOMMENDED_MARGIN, - (Supplier>) TimelineMinimal::create)); + (Supplier>) TimelineMinimal::create), + Arguments.of("engineering_resume", + EngineeringResume.RECOMMENDED_MARGIN, + (Supplier>) EngineeringResume::create)); } /** diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/EngineeringResumeSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/EngineeringResumeSmokeTest.java new file mode 100644 index 00000000..ca181216 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/EngineeringResumeSmokeTest.java @@ -0,0 +1,93 @@ +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 Engineering Resume preset. Covers the navy + * command header with subtitle + contact stack, plus the 2-column + * rail / main-card composition fed through + * {@link com.demcha.compose.document.templates.cv.v2.components.SectionLookup}. + */ +class EngineeringResumeSmokeTest { + + @Test + void exposes_stable_identity() { + DocumentTemplate template = EngineeringResume.create(); + assertThat(template.id()).isEqualTo("engineering-resume"); + assertThat(template.displayName()).isEqualTo("Engineering Resume"); + } + + @Test + void default_factory_renders_full_document() throws Exception { + renderAndAssertNonEmpty(EngineeringResume.create(), fullDocument()); + } + + @Test + void custom_theme_factory_renders() throws Exception { + renderAndAssertNonEmpty(EngineeringResume.create(CvTheme.engineeringResume()), + fullDocument()); + } + + private static void renderAndAssertNonEmpty( + DocumentTemplate template, + CvDocument doc) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 595) + .margin(DocumentInsets.of(20)) + .create()) { + template.compose(session, doc); + assertThat(session.roots()).isNotEmpty(); + } + } + + private static CvDocument fullDocument() { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jane", "Doe") + .jobTitle("Senior Platform Engineer") + .contact("+44 0", "j@d.com", "London") + .link("LinkedIn", "https://linkedin.com/in/jane-doe") + .link("GitHub", "https://github.com/jane") + .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("Senior Platform 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/engineering_resume-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/engineering_resume-page-0.png new file mode 100644 index 00000000..eecfc966 Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/engineering_resume-page-0.png differ