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(); + DocumentTemplateMinimal 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 MapDraws 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.
+ * + *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.
+ * + *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