diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvTimelineMinimalExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvTimelineMinimalExample.java new file mode 100644 index 00000000..884018c9 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvTimelineMinimalExample.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.TimelineMinimal; +import com.demcha.examples.support.ExampleDataFactory; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Renders the v2 Timeline Minimal CV preset against the shared + * grouped skills sample data — spaced uppercase Barlow Condensed + * name, right-aligned contact stack with PNG icons, and the central + * vertical timeline axis (4 segments / 3 circles) separating the + * sidebar (Education / Skills / Expertise / Languages) from the main + * column (Professional Profile / Work Experience). + * + *

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

+ */ +public final class CvTimelineMinimalExample { + + private CvTimelineMinimalExample() { + } + + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare( + "templates/cv", "cv-timeline-minimal-v2.pdf"); + CvDocument doc = ExampleDataFactory.sampleCvDocumentV2(); + DocumentTemplate template = TimelineMinimal.create(); + + float m = (float) TimelineMinimal.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/TimelineMinimal.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimal.java new file mode 100644 index 00000000..00bf835b --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimal.java @@ -0,0 +1,639 @@ +package com.demcha.compose.document.templates.cv.v2.presets; + +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.RowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.node.InlineImageAlignment; +import com.demcha.compose.document.node.TextAlign; +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.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 com.demcha.compose.document.templates.widgets.TimelineAxisWidget; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * v2 port of the legacy "Timeline Minimal" CV preset. + * + *

Minimal two-column CV with a vertical timeline axis between the + * sidebar (Education / Skills / Expertise / Languages / Interests / + * References) and the main column (Professional Profile / Work + * Experience). Visual signature ported from the v1 + * {@code TimelineMinimalCvTemplateComposer}: spaced caps name in + * Barlow Condensed, contact stack with PNG icons, all-grey palette, + * three timeline dots between four axis segments.

+ * + *

The preset stays a thin orchestrator. The 3-column body layout + * (sidebar / axis / main) and the contact icon row are preset-local + * because no other v2 preset uses this visual today. Section bodies + * are flattened to a list of lines via a preset-local helper so the + * sidebar can apply per-module truncation limits — the canonical + * shared dispatchers do not enforce that shape.

+ */ +public final class TimelineMinimal { + + /** Stable template identifier. */ + public static final String ID = "timeline-minimal"; + + /** Human-readable display name. */ + public static final String DISPLAY_NAME = "Timeline Minimal"; + + /** Recommended page margin (in points) — matches V1 TimelineMinimal. */ + public static final double RECOMMENDED_MARGIN = 22.0; + + /** Diameter of each timeline marker; 4 segments + 3 markers by default. */ + private static final double TIMELINE_DOT = 7.0; + + /** Default total axis height — sized for a one-page CV. */ + private static final double TIMELINE_AXIS_HEIGHT = 620.0; + + /** Top inset before the first axis segment starts. */ + private static final double TIMELINE_TOP_PADDING = 28.0; + + /** Number of vertical line segments; markers between = segmentCount - 1. */ + private static final int TIMELINE_SEGMENT_COUNT = 4; + + /** Stroke thickness of every line segment. */ + private static final double TIMELINE_LINE_THICKNESS = 0.75; + + /** Stroke thickness of the marker outline. */ + private static final double TIMELINE_MARKER_STROKE = 0.8; + + private static final double CONTACT_ICON_SIZE = 10.5; + private static final double CONTACT_ICON_BASELINE_OFFSET = -1.35; + private static final String CONTACT_ICON_ROOT = + "/templates/cv/timeline-minimal/icons/"; + private static final Map CONTACT_ICON_CACHE = + new ConcurrentHashMap<>(); + + 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 TimelineMinimal() { + } + + /** + * Builds the preset with its Timeline Minimal theme. + */ + public static DocumentTemplate create() { + return create(CvTheme.timelineMinimal()); + } + + /** + * Builds the preset with a caller-supplied theme. + */ + public static DocumentTemplate create(CvTheme theme) { + Objects.requireNonNull(theme, "theme"); + return new Template(theme); + } + + private static final class Template implements DocumentTemplate { + + private final CvTheme theme; + + Template(CvTheme theme) { + this.theme = theme; + } + + @Override + public String id() { + return ID; + } + + @Override + public String displayName() { + return DISPLAY_NAME; + } + + @Override + public void compose(DocumentSession document, CvDocument doc) { + Objects.requireNonNull(document, "document"); + Objects.requireNonNull(doc, "doc"); + + double width = document.canvas().innerWidth(); + List sections = doc.sectionsIn(Slot.MAIN); + + document.dsl() + .pageFlow() + .name("CvV2TimelineMinimalRoot") + .spacing(theme.spacing().pageFlowSpacing()) + .addRow("CvV2TimelineMinimalHeader", row -> row + .spacing(3) + .weights(1.00, 0.61) + .addSection("CvV2TimelineMinimalName", + section -> addNameBlock(section, doc.identity())) + .addSection("CvV2TimelineMinimalContact", + section -> addContact(section, doc.identity()))) + .addLine(line -> line + .name("CvV2TimelineMinimalHeaderRule") + .horizontal(width) + .color(theme.palette().rule()) + .thickness(theme.spacing().accentRuleWidth()) + .margin(DocumentInsets.zero())) + .addRow("CvV2TimelineMinimalBody", row -> addBodyRow(row, + List.of( + new ModulePlacement("Education", + SectionLookup.firstMatching(sections, + EDUCATION_KEYS), + 5), + new ModulePlacement("Skills", + SectionLookup.firstMatching(sections, + SKILL_KEYS), + 6), + new ModulePlacement("Expertise", + SectionLookup.firstMatching(sections, + PROJECT_KEYS), + 3), + new ModulePlacement("Languages", + SectionLookup.firstMatching(sections, + ADDITIONAL_KEYS), + 3)), + List.of( + new ModulePlacement("Professional Profile", + SectionLookup.firstMatching(sections, + SUMMARY_KEYS), + 1), + new ModulePlacement("Work Experience", + SectionLookup.firstMatching(sections, + EXPERIENCE_KEYS), + 4)), + TIMELINE_AXIS_HEIGHT)) + .build(); + } + + private void addNameBlock(SectionBuilder section, CvIdentity identity) { + section.spacing(4) + .addParagraph(paragraph -> paragraph + .text(spacedUpper(identity.name().full())) + .textStyle(nameStyle()) + .margin(DocumentInsets.zero())); + String jobTitle = identity.jobTitle(); + if (!jobTitle.isBlank()) { + section.addParagraph(paragraph -> paragraph + .text(jobTitle.toUpperCase(Locale.ROOT)) + .textStyle(jobTitleStyle()) + .margin(DocumentInsets.zero())); + } + } + + private void addBodyRow(RowBuilder row, + List sidebarModules, + List mainModules, + double axisHeight) { + row.spacing(16) + .weights(0.74, 0.12, 1.74) + .addSection("CvV2TimelineMinimalSidebar", sidebar -> { + sidebar.spacing(10); + for (ModulePlacement placement : sidebarModules) { + addSidebarModule(sidebar, placement.title(), + placement.section(), placement.limit()); + } + }) + .addSection("CvV2TimelineMinimalAxis", axis -> + TimelineAxisWidget.render(axis, + timelineAxisStyle(), axisHeight)) + .addSection("CvV2TimelineMinimalMain", main -> { + main.spacing(11); + for (ModulePlacement placement : mainModules) { + boolean bullets = placement.limit() > 1; + addMainModule(main, placement.title(), + placement.section(), bullets, placement.limit()); + } + }); + } + + private void addContact(SectionBuilder section, CvIdentity identity) { + section.spacing(3); + DocumentTextStyle textStyle = contactTextStyle(); + DocumentTextStyle fallbackIconStyle = fallbackIconStyle(); + for (ContactItem item : contactItems(identity)) { + section.addParagraph(paragraph -> paragraph + .textStyle(textStyle) + .align(TextAlign.RIGHT) + .link(item.linkOptions()) + .margin(DocumentInsets.zero()) + .rich(rich -> { + rich.style(item.text(), textStyle); + rich.plain(" "); + if (item.iconFile() != null) { + rich.image(contactIcon(item.iconFile()), + CONTACT_ICON_SIZE, + CONTACT_ICON_SIZE, + InlineImageAlignment.CENTER, + CONTACT_ICON_BASELINE_OFFSET, + item.linkOptions()); + } else { + rich.style(item.fallbackIcon(), + fallbackIconStyle); + } + })); + } + } + + private List contactItems(CvIdentity identity) { + if (identity == null) { + return List.of(); + } + List items = new ArrayList<>(); + addContactItem(items, "LOC", "location.png", + identity.contact().address(), null); + addContactItem(items, "TEL", "phone.png", + identity.contact().phone(), null); + String email = identity.contact().email(); + if (!email.isBlank()) { + addContactItem(items, "@", "email.png", email, + new DocumentLinkOptions("mailto:" + email)); + } + for (CvLink link : identity.links()) { + String label = link.label(); + if (label.isBlank()) { + continue; + } + String url = link.url(); + addContactItem(items, pickFallbackIcon(label), + pickIconFile(label), label, + url.isBlank() + ? null + : new DocumentLinkOptions(url.trim())); + } + return List.copyOf(items); + } + + private static void addContactItem(List items, + String fallbackIcon, + String iconFile, + String text, + DocumentLinkOptions linkOptions) { + if (text != null && !text.isBlank()) { + items.add(new ContactItem(fallbackIcon, iconFile, text, + linkOptions)); + } + } + + private DocumentImageData contactIcon(String iconFile) { + return DocumentImageData.fromBytes( + CONTACT_ICON_CACHE.computeIfAbsent(iconFile, + TimelineMinimal::readIconBytes)); + } + + private void addSidebarModule(SectionBuilder sidebar, String title, + CvSection section, int limit) { + List lines = sectionLines(section); + if (lines.isEmpty()) { + return; + } + sidebar.addSection("CvV2TimelineMinimalSidebar" + + SectionLookup.normalize(title), block -> { + block.spacing(6) + .addParagraph(paragraph -> paragraph + .text(title.toUpperCase(Locale.ROOT)) + .textStyle(sidebarTitleStyle()) + .margin(DocumentInsets.zero())); + for (String line : lines.stream().limit(limit).toList()) { + block.addParagraph(paragraph -> paragraph + .text(excerpt(line, 76)) + .textStyle(sidebarBodyStyle()) + .lineSpacing(1) + .margin(DocumentInsets.zero())); + } + block.addLine(line -> line + .horizontal(118) + .color(theme.palette().rule()) + .thickness(0.65) + .margin(DocumentInsets.top(5))); + }); + } + + /** + * Style applied to the central timeline axis. Drop a custom + * {@link TimelineAxisWidget.Style} here (or expose it through + * the theme) to swap the marker shape, sizing, or colours. + */ + private TimelineAxisWidget.Style timelineAxisStyle() { + return TimelineAxisWidget.Style.builder() + .marker(TimelineAxisWidget.Marker.CIRCLE) + .markerSize(TIMELINE_DOT) + .markerStroke(DocumentStroke.of( + theme.palette().banner(), + TIMELINE_MARKER_STROKE)) + .segmentCount(TIMELINE_SEGMENT_COUNT) + .lineColor(theme.palette().rule()) + .lineThickness(TIMELINE_LINE_THICKNESS) + .padding(new DocumentInsets(TIMELINE_TOP_PADDING, 0, 0, 0)) + .build(); + } + + private void addMainModule(SectionBuilder main, String title, + CvSection section, boolean bullets, + int limit) { + List lines = sectionLines(section); + if (lines.isEmpty()) { + return; + } + main.addSection("CvV2TimelineMinimalMain" + + SectionLookup.normalize(title), block -> { + block.spacing(5) + .addParagraph(paragraph -> paragraph + .text(title.toUpperCase(Locale.ROOT)) + .textStyle(mainTitleStyle()) + .margin(DocumentInsets.zero())); + if (bullets) { + for (String line : lines.stream().limit(limit).toList()) { + block.addParagraph(paragraph -> paragraph + .text(excerpt(line, 136)) + .textStyle(mainBulletStyle()) + .lineSpacing(1.2) + .bulletOffset("-") + .margin(DocumentInsets.zero())); + } + } else { + block.addParagraph(paragraph -> paragraph + .text(excerpt(lines.get(0), 245)) + .textStyle(mainBodyStyle()) + .lineSpacing(1.4) + .margin(DocumentInsets.zero())); + } + block.addLine(line -> line + .horizontal(300) + .color(theme.palette().rule()) + .thickness(0.65) + .margin(DocumentInsets.top(6))); + }); + } + + // -- style factories --------------------------------------------- + + private DocumentTextStyle nameStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + theme.typography().sizeHeadline(), + DocumentTextDecoration.DEFAULT, + theme.palette().ink()); + } + + private DocumentTextStyle jobTitleStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + 9.5, + DocumentTextDecoration.BOLD, + theme.palette().ink()); + } + + private DocumentTextStyle contactTextStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeContact(), + DocumentTextDecoration.BOLD, + theme.palette().muted()); + } + + private DocumentTextStyle fallbackIconStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + 8.0, + DocumentTextDecoration.BOLD, + theme.palette().muted()); + } + + private DocumentTextStyle sidebarTitleStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + theme.typography().sizeEntryTitle(), + DocumentTextDecoration.BOLD, + theme.palette().ink()); + } + + private DocumentTextStyle sidebarBodyStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeEntrySubtitle(), + DocumentTextDecoration.BOLD, + theme.palette().ink()); + } + + private DocumentTextStyle mainTitleStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + theme.typography().sizeBanner(), + DocumentTextDecoration.BOLD, + theme.palette().ink()); + } + + private DocumentTextStyle mainBulletStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeBody(), + DocumentTextDecoration.DEFAULT, + theme.palette().ink()); + } + + private DocumentTextStyle mainBodyStyle() { + return CvTextStyles.of(theme.typography().bodyFont(), + theme.typography().sizeEntryDate(), + DocumentTextDecoration.DEFAULT, + theme.palette().ink()); + } + } + + // -- helpers ----------------------------------------------------------- + + /** + * Flattens a {@link CvSection} into a list of single-line strings + * suitable for the truncation-driven sidebar / main rendering. v2 + * {@code SectionDispatcher} would produce richly-styled multi-paragraph + * output, which is not what Timeline Minimal needs — its layout + * relies on knowing the exact line count so per-module + * {@code limit} can drop overflow without breaking the visual flow + * around the fixed-height timeline axis. + */ + private static List sectionLines(CvSection section) { + if (section == null || !SectionLookup.hasContent(section)) { + return List.of(); + } + List lines = new ArrayList<>(); + if (section instanceof ParagraphSection paragraph) { + addLines(lines, paragraph.body()); + } else if (section instanceof SkillsSection skills) { + for (SkillGroup group : skills.groups()) { + String label = group.category(); + String body = group.skillsInline(); + if (label.isBlank() && body.isBlank()) { + continue; + } + if (label.isBlank()) { + lines.add(body); + } else if (body.isBlank()) { + lines.add(label); + } else { + lines.add(label + ": " + body); + } + } + } else if (section instanceof EntriesSection entries) { + for (CvEntry entry : entries.entries()) { + String header = entry.title(); + String subtitle = entry.subtitle(); + String dates = entry.date(); + String body = entry.body(); + StringBuilder line = new StringBuilder(); + if (!header.isBlank()) { + line.append(header); + } + if (!subtitle.isBlank()) { + if (line.length() > 0) { + line.append(" | "); + } + line.append(subtitle); + } + if (!dates.isBlank()) { + if (line.length() > 0) { + line.append(" - "); + } + line.append(dates); + } + if (line.length() > 0) { + lines.add(line.toString()); + } + if (!body.isBlank()) { + addLines(lines, body); + } + } + } else if (section instanceof RowsSection rows) { + for (CvRow row : rows.rows()) { + String label = row.label(); + String body = row.body(); + if (label.isBlank() && body.isBlank()) { + continue; + } + if (label.isBlank()) { + lines.add(body); + } else if (body.isBlank()) { + lines.add(label); + } else { + lines.add(label + ": " + body); + } + } + } + return List.copyOf(lines); + } + + private static void addLines(List lines, String value) { + for (String line : safe(value).split("\\R")) { + String clean = MarkdownInline.plainText(line).trim(); + if (!clean.isBlank()) { + lines.add(clean); + } + } + } + + private static byte[] readIconBytes(String iconFile) { + try (InputStream input = TimelineMinimal.class.getResourceAsStream( + CONTACT_ICON_ROOT + iconFile)) { + if (input == null) { + throw new IllegalStateException( + "Missing timeline minimal contact icon: " + iconFile); + } + return input.readAllBytes(); + } catch (IOException e) { + throw new UncheckedIOException( + "Failed to read timeline minimal contact icon: " + iconFile, + e); + } + } + + private static String pickIconFile(String label) { + String normalized = SectionLookup.normalize(label); + if (normalized.contains("linkedin")) { + return "linkedin.png"; + } + if (normalized.contains("github")) { + return "github.png"; + } + if (normalized.contains("dribbble")) { + return "dribbble.png"; + } + if (normalized.contains("google")) { + return "google.png"; + } + return null; + } + + private static String pickFallbackIcon(String label) { + String normalized = SectionLookup.normalize(label); + if (normalized.contains("linkedin")) { + return "in"; + } + if (normalized.contains("github")) { + return "GH"; + } + return "@"; + } + + private static String spacedUpper(String value) { + String upper = safe(value).toUpperCase(Locale.ROOT); + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < upper.length(); i++) { + char current = upper.charAt(i); + builder.append(current); + if (Character.isWhitespace(current)) { + builder.append(" "); + } + if (Character.isLetter(current) && i + 1 < upper.length() + && Character.isLetter(upper.charAt(i + 1))) { + builder.append(' '); + } + } + return builder.toString(); + } + + private static String excerpt(String value, int maxChars) { + String clean = MarkdownInline.plainText(value) + .replaceAll("\\s+", " ").trim(); + if (clean.length() <= maxChars) { + return clean; + } + int boundary = clean.lastIndexOf(' ', maxChars - 1); + int end = boundary > maxChars / 2 ? boundary : maxChars - 1; + return clean.substring(0, end).trim() + "..."; + } + + private static String safe(String value) { + return value == null ? "" : value; + } + + private record ModulePlacement(String title, CvSection section, int limit) { + } + + private record ContactItem(String fallbackIcon, String iconFile, + String text, DocumentLinkOptions linkOptions) { + } +} 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 c3e96546..ae51315c 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,21 @@ public static CvPalette editorialBlue() { DocumentColor.rgb(193, 201, 211)); } + /** + * Timeline Minimal palette: an all-grey scale ported from the v1 + * {@code TimelineMinimalCvTemplateComposer} — medium-grey ink, + * softer grey for metadata + body bullets, pale rule for the + * timeline axis and module underlines, and the dot token reused + * for the three circles of the central timeline axis. + */ + public static CvPalette timelineMinimal() { + return new CvPalette( + DocumentColor.rgb(74, 74, 74), // ink — V1 INK + DocumentColor.rgb(122, 122, 122), // muted — V1 SOFT + DocumentColor.rgb(195, 195, 195), // rule — V1 RULE + DocumentColor.rgb(170, 170, 170)); // banner — V1 DOT (reused as "timeline accent") + } + /** * Panel palette ported from the v1 {@code PanelCvTemplateComposer} * (ProductLeader tokens): body slate ink, slightly lighter slate 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 477a6279..73e237bc 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 Timeline Minimal preset: tight 3-column layout + * with a fixed-width axis between the sidebar and main column. + * Body content is text-only (no cards / banners), so banner + * tokens are unused but kept at neutral defaults. + */ + public static CvSpacing timelineMinimal() { + return new CvSpacing( + 12, // pageFlowSpacing (gap between header row + rule + body row) + 5, // sectionBodySpacing + DocumentInsets.zero(), // sectionBodyPadding + DocumentInsets.zero(), // headlinePadding + DocumentInsets.zero(), // contactPadding + 0.0, // bannerCornerRadius (unused) + 0.0, // bannerInnerPadding (unused) + DocumentInsets.zero(), // bannerMargin (unused) + 0.8, // accentRuleWidth (header underline) + 1.0, // paragraphMarginTop + 8.0, // entryHeaderRowSpacing + 1.0, // entryTitleWeight + 0.45, // entryDateWeight + 2.0); // entrySeparation + } + /** * Spacing for the Panel preset: card-led layout that has to fit * Header / Profile / two-column row / Additional on one A4 page, 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 9d3e59ab..6387c883 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 "Timeline Minimal" look — Barlow Condensed display + Lato + * body, all-grey palette, spaced uppercase name, right-aligned + * contact stack with PNG icons, and a thin vertical timeline axis + * with three circles separating the sidebar from the main column. + * Visual signature ported from the v1 + * {@code TimelineMinimalCvTemplateComposer}. + */ + public static CvTheme timelineMinimal() { + return new CvTheme( + CvPalette.timelineMinimal(), + CvTypography.timelineMinimal(), + CvSpacing.timelineMinimal(), + CvDecoration.classic()); + } + /** * The "Panel" look — Poppins headlines + Lato body, pale teal * header card and module panels with thin teal stroke, deep navy 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 c3c84669..0eac04e4 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 } + /** + * Barlow Condensed headline + Lato body scale ported from the v1 + * {@code TimelineMinimalCvTemplateComposer}: 28pt spaced-caps + * masthead, 12.5pt sidebar module titles, 13.5pt main module + * titles, 7.5-7.9pt body. Compact sizes squeeze the 3-column + * sidebar / axis / main layout onto one page. + */ + public static CvTypography timelineMinimal() { + return new CvTypography( + FontName.BARLOW_CONDENSED, FontName.LATO, + 28.0, // headline (spaced uppercase masthead) + 7.8, // contact (right-aligned contact stack) + 13.5, // banner / main module title + 12.5, // entry title (reused as sidebar module title size) + 7.9, // entry date (body size in main) + 7.5, // entry subtitle (sidebar body size) + 7.8, // body + 1.2); // line spacing + } + /** * Poppins headline + Lato body scale ported from the v1 * {@code PanelCvTemplateComposer} (ProductLeader tokens): a 22pt diff --git a/src/main/java/com/demcha/compose/document/templates/widgets/TimelineAxisWidget.java b/src/main/java/com/demcha/compose/document/templates/widgets/TimelineAxisWidget.java new file mode 100644 index 00000000..6753f62b --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/widgets/TimelineAxisWidget.java @@ -0,0 +1,266 @@ +package com.demcha.compose.document.templates.widgets; + +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; + +import java.util.Objects; + +/** + * Shared timeline-axis widget for template presets. + * + *

Draws a vertical line broken by a configurable number of markers + * (circles, squares, or none). Used by the CV Timeline Minimal preset + * to separate the sidebar from the main column, but the visual is + * generic enough to live in the shared widget layer — proposals, + * cover letters, or process / step documents can reuse the same + * widget by tweaking marker shape, spacing, and stroke colour.

+ * + *

Geometry

+ * + *

The widget renders {@code segmentCount} vertical line segments + * separated by {@code segmentCount - 1} markers. Total axis height is: + *

+ * + *
+ *   total = segmentCount * segmentLength + (segmentCount - 1) * markerSize
+ * 
+ * + *

Use the {@link #render(SectionBuilder, Style, double)} overload + * if you want to fix the total height and let the widget compute the + * segment length automatically — handy when the axis must match a + * sibling column's height.

+ * + *

Cross-page behaviour

+ * + *

The widget itself is a deterministic sequence of lines and + * markers; it does not coordinate with the layout engine on page + * boundaries. If a host section is split across pages by the engine, + * the line / marker sequence is split with it, and a marker that + * straddles a page break may be clipped. Callers that need an axis + * that visually restarts on each page should compose the widget + * inside a flow that controls page breaks explicitly (for example + * one {@code render(...)} call per logical page).

+ */ +public final class TimelineAxisWidget { + + /** Marker shape drawn between line segments. */ + public enum Marker { + /** A circle with the configured stroke + fill. */ + CIRCLE, + /** A square with the configured stroke + fill. */ + SQUARE, + /** No marker — line segments join directly. */ + NONE + } + + private TimelineAxisWidget() { + } + + /** + * Renders the timeline axis using the supplied {@link Style}. The + * total height is implied by {@code segmentCount * segmentLength + * + (segmentCount - 1) * markerSize}. + */ + public static void render(SectionBuilder host, Style style) { + Objects.requireNonNull(host, "host"); + Style safeStyle = style == null ? Style.builder().build() : style; + drawAxis(host, safeStyle); + } + + /** + * Renders the timeline axis with an explicit overall height. The + * widget keeps the supplied {@code marker}, {@code markerSize} + * and {@code segmentCount} and adjusts {@code segmentLength} so + * the rendered axis is exactly {@code totalHeight} tall (subject + * to non-negative segment lengths — short axes with many markers + * fall back to zero-length segments). + * + * @param host host section receiving the axis + * @param style configured style; only {@code segmentLength} + * is recomputed + * @param totalHeight target total height of the axis + */ + public static void render(SectionBuilder host, Style style, + double totalHeight) { + Objects.requireNonNull(host, "host"); + Style safeStyle = style == null ? Style.builder().build() : style; + int markers = Math.max(0, safeStyle.segmentCount() - 1); + double markerOverhead = markers * safeStyle.markerSize(); + double segmentLength = Math.max(0.0, + (totalHeight - markerOverhead) / safeStyle.segmentCount()); + Style adjusted = safeStyle.toBuilder() + .segmentLength(segmentLength) + .build(); + drawAxis(host, adjusted); + } + + private static void drawAxis(SectionBuilder host, Style style) { + host.spacing(0).padding(style.padding()); + int segments = style.segmentCount(); + double lineLeftOffset = Math.max(0.0, + (style.markerSize() - style.lineThickness()) / 2.0); + for (int i = 0; i < segments; i++) { + host.addLine(line -> line + .vertical(style.segmentLength()) + .color(style.lineColor()) + .thickness(style.lineThickness()) + .margin(new DocumentInsets(0, 0, 0, lineLeftOffset))); + if (i < segments - 1) { + renderMarker(host, style); + } + } + } + + private static void renderMarker(SectionBuilder host, Style style) { + DocumentStroke stroke = style.markerStroke() != null + ? style.markerStroke() + : (style.lineColor() != null + ? DocumentStroke.of(style.lineColor(), 0.8) + : null); + DocumentColor fill = style.markerFillColor() != null + ? style.markerFillColor() + : DocumentColor.WHITE; + switch (style.marker()) { + case CIRCLE -> host.addCircle(style.markerSize(), circle -> { + if (stroke != null) { + circle.stroke(stroke); + } + circle.fillColor(fill); + }); + case SQUARE -> host.addShape(shape -> { + shape.name("TimelineAxisMarkerSquare") + .size(style.markerSize(), style.markerSize()) + .fillColor(fill) + .margin(DocumentInsets.zero()); + if (stroke != null) { + shape.stroke(stroke); + } + }); + case NONE -> { + // No marker — the next line segment starts immediately. + } + } + } + + /** + * Visual configuration for {@link TimelineAxisWidget}. + * + * @param marker shape drawn between segments + * @param markerSize diameter (CIRCLE) or side length (SQUARE) + * @param markerFillColor fill colour of the marker; {@code null} + * falls back to {@link DocumentColor#WHITE} + * @param markerStroke stroke around the marker; {@code null} + * falls back to {@code lineColor} at 0.8pt + * @param segmentLength length of each vertical line segment + * @param segmentCount number of segments (at least 1); + * markers between = {@code segmentCount - 1} + * @param lineColor colour of every line segment + * @param lineThickness thickness of every line segment + * @param padding inset applied to the host section + * before any drawing + */ + public record Style(Marker marker, + double markerSize, + DocumentColor markerFillColor, + DocumentStroke markerStroke, + double segmentLength, + int segmentCount, + DocumentColor lineColor, + double lineThickness, + DocumentInsets padding) { + + public Style { + marker = marker == null ? Marker.CIRCLE : marker; + markerSize = Math.max(0.0, markerSize); + segmentLength = Math.max(0.0, segmentLength); + segmentCount = Math.max(1, segmentCount); + lineThickness = lineThickness <= 0.0 ? 0.75 : lineThickness; + padding = padding == null ? DocumentInsets.zero() : padding; + } + + public static Builder builder() { + return new Builder(); + } + + public Builder toBuilder() { + return new Builder() + .marker(marker) + .markerSize(markerSize) + .markerFillColor(markerFillColor) + .markerStroke(markerStroke) + .segmentLength(segmentLength) + .segmentCount(segmentCount) + .lineColor(lineColor) + .lineThickness(lineThickness) + .padding(padding); + } + + public static final class Builder { + private Marker marker = Marker.CIRCLE; + private double markerSize = 7.0; + private DocumentColor markerFillColor = DocumentColor.WHITE; + private DocumentStroke markerStroke; + private double segmentLength = 150.0; + private int segmentCount = 4; + private DocumentColor lineColor; + private double lineThickness = 0.75; + private DocumentInsets padding = DocumentInsets.zero(); + + private Builder() { + } + + public Builder marker(Marker value) { + this.marker = value; + return this; + } + + public Builder markerSize(double value) { + this.markerSize = value; + return this; + } + + public Builder markerFillColor(DocumentColor value) { + this.markerFillColor = value; + return this; + } + + public Builder markerStroke(DocumentStroke value) { + this.markerStroke = value; + return this; + } + + public Builder segmentLength(double value) { + this.segmentLength = value; + return this; + } + + public Builder segmentCount(int value) { + this.segmentCount = value; + return this; + } + + public Builder lineColor(DocumentColor value) { + this.lineColor = value; + return this; + } + + public Builder lineThickness(double value) { + this.lineThickness = value; + return this; + } + + public Builder padding(DocumentInsets value) { + this.padding = value; + return this; + } + + public Style build() { + return new Style(marker, markerSize, markerFillColor, + markerStroke, segmentLength, segmentCount, + lineColor, lineThickness, padding); + } + } + } +} diff --git a/src/main/resources/templates/cv/timeline-minimal/icons/github.png b/src/main/resources/templates/cv/timeline-minimal/icons/github.png index ed809bdf..a4ec311c 100644 Binary files a/src/main/resources/templates/cv/timeline-minimal/icons/github.png and b/src/main/resources/templates/cv/timeline-minimal/icons/github.png differ diff --git a/src/main/resources/templates/cv/timeline-minimal/icons/linkedin.png b/src/main/resources/templates/cv/timeline-minimal/icons/linkedin.png index 949c8cff..947cfd67 100644 Binary files a/src/main/resources/templates/cv/timeline-minimal/icons/linkedin.png and b/src/main/resources/templates/cv/timeline-minimal/icons/linkedin.png differ 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 a08b259c..7cc67424 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 @@ -117,7 +117,10 @@ private static Stream presets() { (Supplier>) Executive::create), Arguments.of("panel", Panel.RECOMMENDED_MARGIN, - (Supplier>) Panel::create)); + (Supplier>) Panel::create), + Arguments.of("timeline_minimal", + TimelineMinimal.RECOMMENDED_MARGIN, + (Supplier>) TimelineMinimal::create)); } /** diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimalSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimalSmokeTest.java new file mode 100644 index 00000000..96dbf381 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimalSmokeTest.java @@ -0,0 +1,91 @@ +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 Timeline Minimal preset. Covers the icon + * contact stack and section-line flattening across every + * {@code CvSection} subtype. + */ +class TimelineMinimalSmokeTest { + + @Test + void exposes_stable_identity() { + DocumentTemplate template = TimelineMinimal.create(); + assertThat(template.id()).isEqualTo("timeline-minimal"); + assertThat(template.displayName()).isEqualTo("Timeline Minimal"); + } + + @Test + void default_factory_renders_full_document() throws Exception { + renderAndAssertNonEmpty(TimelineMinimal.create(), fullDocument()); + } + + @Test + void custom_theme_factory_renders() throws Exception { + renderAndAssertNonEmpty(TimelineMinimal.create(CvTheme.timelineMinimal()), + fullDocument()); + } + + private static void renderAndAssertNonEmpty( + DocumentTemplate template, + CvDocument doc) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 595) + .margin(DocumentInsets.of(22)) + .create()) { + template.compose(session, doc); + assertThat(session.roots()).isNotEmpty(); + } + } + + private static CvDocument fullDocument() { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jane", "Doe") + .jobTitle("Backend 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("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/timeline_minimal-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png new file mode 100644 index 00000000..5a1fd18f Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png differ