diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvSidebarPortraitExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvSidebarPortraitExample.java
new file mode 100644
index 00000000..01c6460c
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvSidebarPortraitExample.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.SidebarPortrait;
+import com.demcha.examples.support.ExampleDataFactory;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.nio.file.Path;
+
+/**
+ * Renders the v2 Sidebar Portrait CV preset against the shared
+ * grouped skills sample data — pale-beige left sidebar with a
+ * circular portrait photo, contact stack with inline icons, and
+ * education / skills / languages summary, plus the hero name strip
+ * and main career narrative on the right.
+ *
+ *
Output:
+ * {@code examples/target/generated-pdfs/templates/cv/cv-sidebar-portrait-v2.pdf}.
+ */
+public final class CvSidebarPortraitExample {
+
+ private CvSidebarPortraitExample() {
+ }
+
+ public static Path generate() throws Exception {
+ Path outputFile = ExampleOutputPaths.prepare(
+ "templates/cv", "cv-sidebar-portrait-v2.pdf");
+ CvDocument doc = ExampleDataFactory.sampleCvDocumentV2();
+ DocumentTemplate template = SidebarPortrait.create();
+
+ float m = (float) SidebarPortrait.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/SidebarPortrait.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/SidebarPortrait.java
new file mode 100644
index 00000000..3ae13e30
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/SidebarPortrait.java
@@ -0,0 +1,987 @@
+package com.demcha.compose.document.templates.cv.v2.presets;
+
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.api.PageBackgroundFill;
+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.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.components.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.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 "Sidebar Portrait" CV preset.
+ *
+ * Two-column resume with a pale-beige portrait sidebar on the
+ * left (circular photo, contact stack with inline icons, education /
+ * key skills / languages summary) and the main career narrative on
+ * the right (large serif name, professional profile, experience
+ * timeline of bold position + subtitle + description). Visual
+ * signature ported from the v1
+ * {@code SidebarPortraitCvTemplateComposer}: Crimson Text serif for
+ * the hero name, Lato body, restrained grey palette.
+ *
+ * The two-column page chrome is painted by
+ * {@link com.demcha.compose.document.api.DocumentSession#pageBackgrounds},
+ * so the sidebar fill stretches edge-to-edge on every page (including
+ * continuation pages of multi-page CVs) without any preset-side
+ * filler logic. Use {@link Options} to override the sidebar fill,
+ * main fill or accent colour without forking the theme.
+ */
+public final class SidebarPortrait {
+
+ /** Stable template identifier. */
+ public static final String ID = "sidebar-portrait";
+
+ /** Human-readable display name. */
+ public static final String DISPLAY_NAME = "Sidebar Portrait";
+
+ /** Recommended page margin (in points) — 0 so the sidebar bleeds to the edge. */
+ public static final double RECOMMENDED_MARGIN = 0.0;
+
+ /** Ratio of the page width allocated to the left sidebar column. */
+ private static final double SIDEBAR_WIDTH_RATIO = 0.34;
+
+ /** V1 default mid-grey accent used for the divider rule under sidebar headings. */
+ private static final DocumentColor DEFAULT_ACCENT =
+ DocumentColor.rgb(106, 106, 106);
+
+ /**
+ * Inner content width of the sidebar column. Derived from the V1
+ * SidebarPortrait token set (sidebar outer width minus 13pt left +
+ * 13pt right padding); preserves the half-on-sidebar /
+ * half-on-hero portrait geometry against the canonical A4 page.
+ */
+ private static final double SIDEBAR_INNER_WIDTH = 156.4;
+
+ /**
+ * Diameter of the circular portrait photo. V1 SidebarPortrait
+ * token — chosen so the photo's horizontal extent fits inside
+ * {@link #SIDEBAR_INNER_WIDTH} with breathing room either side.
+ */
+ private static final double PHOTO_DIAMETER = 98.0;
+
+ /**
+ * Vertical offset of the hero strip from the top of the main
+ * column. Tuned so the hero strip's vertical centre lines up with
+ * the photo's centre, producing the half-on-sidebar / half-on-hero
+ * portrait effect.
+ *
+ * Paired with {@link #HERO_PADDING_TOP} / {@link #HERO_PADDING_BOTTOM}
+ * — the offset is adjusted whenever the padding changes so the
+ * strip's vertical centre stays on the same axis as the photo.
+ */
+ private static final double HERO_TOP_OFFSET = 59.0;
+
+ /**
+ * Top / bottom padding inside the hero strip. The original V1
+ * design used 8 / 6; the strip now renders 1.4× taller while
+ * keeping the same on-page centre line via the adjusted
+ * {@link #HERO_TOP_OFFSET}.
+ */
+ private static final double HERO_PADDING_TOP = 19.0;
+ private static final double HERO_PADDING_BOTTOM = 17.0;
+
+ /**
+ * Width of the accent divider rule drawn above each sidebar
+ * heading. V1 SidebarPortrait token — kept short so the rule
+ * reads as a tick mark, not a separator line.
+ */
+ private static final double SIDEBAR_HEADER_RULE_WIDTH = 50.0;
+
+ /**
+ * Width of the divider rule under each main-column section title
+ * (Professional Profile / Experience / Projects). V1 token —
+ * sized to match the natural main-column inner width once the 34pt
+ * left + right padding is subtracted from the column's allocated
+ * outer width.
+ */
+ private static final double MAIN_SECTION_RULE_WIDTH = 346.0;
+
+ private static final int EDUCATION_LIMIT = 2;
+ private static final int SKILL_LIMIT = 5;
+ private static final int LANGUAGE_LIMIT = 3;
+ private static final int EXPERIENCE_LIMIT = 2;
+
+ private static final String TEMPLATE_ASSET_ROOT =
+ "/templates/cv/sidebar-portrait/";
+ private static final String CONTACT_ICON_ROOT =
+ TEMPLATE_ASSET_ROOT + "icons/";
+ private static final String PORTRAIT_FILE = "portrait.png";
+ private static final Map ASSET_CACHE =
+ new ConcurrentHashMap<>();
+
+ private static final List EDUCATION_KEYS =
+ List.of("education", "certifications");
+ private static final List SKILL_KEYS =
+ List.of("skills", "technical skills");
+ private static final List LANGUAGE_KEYS =
+ List.of("languages", "additional information", "additional");
+ private static final List SUMMARY_KEYS =
+ List.of("profile", "professional profile", "summary",
+ "professional summary");
+ private static final List EXPERIENCE_KEYS =
+ List.of("experience", "employment", "professional experience",
+ "work");
+ private static final List PROJECT_KEYS =
+ List.of("projects", "project", "selected projects");
+
+ /**
+ * Maximum number of project rows rendered in the main column.
+ *
+ * The side-by-side body is wrapped in a {@code flow.addRow},
+ * which is atomic by engine contract (see {@code RowBuilder}'s
+ * error message: "tables are splittable and would conflict
+ * with the row's atomic pagination"). That means the whole
+ * sidebar + main row has to fit on a single page — content
+ * overflow raises {@code AtomicNodeTooLargeException} instead of
+ * page-breaking. Capping projects keeps the dense canonical
+ * sample data inside the page bound; richer CVs that genuinely
+ * need page-breaking sidebar layouts will need a separate
+ * preset wired against a future splittable-row engine primitive.
+ */
+ private static final int PROJECT_LIMIT = 2;
+
+ private SidebarPortrait() {
+ }
+
+ /**
+ * Builds the preset with its Sidebar Portrait theme and default
+ * options (theme's banner fill for the sidebar, white for the
+ * main column, mid-grey accent rule).
+ */
+ public static DocumentTemplate create() {
+ return create(CvTheme.sidebarPortrait(), Options.defaults());
+ }
+
+ /**
+ * Builds the preset with a caller-supplied theme and default
+ * options.
+ */
+ public static DocumentTemplate create(CvTheme theme) {
+ return create(theme, Options.defaults());
+ }
+
+ /**
+ * Builds the preset with explicit colour options. Use this to
+ * override the sidebar fill, main fill or accent colour without
+ * forking the theme.
+ */
+ public static DocumentTemplate create(CvTheme theme,
+ Options options) {
+ Objects.requireNonNull(theme, "theme");
+ Objects.requireNonNull(options, "options");
+ return new Template(theme, options);
+ }
+
+ /**
+ * Sidebar Portrait customisation knobs. {@code null} fields fall
+ * back to the V1 defaults documented on each accessor.
+ *
+ * @param sidebarFillColor sidebar column fill; {@code null} →
+ * {@code theme.palette().banner()}
+ * @param mainFillColor main column fill; {@code null} →
+ * {@link DocumentColor#WHITE}
+ * @param accentColor divider rule colour above each sidebar
+ * heading; {@code null} → V1 rgb(106,106,106)
+ */
+ public record Options(DocumentColor sidebarFillColor,
+ DocumentColor mainFillColor,
+ DocumentColor accentColor) {
+
+ public static Options defaults() {
+ return new Options(null, null, null);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private DocumentColor sidebarFillColor;
+ private DocumentColor mainFillColor;
+ private DocumentColor accentColor;
+
+ private Builder() {
+ }
+
+ public Builder sidebarFillColor(DocumentColor value) {
+ this.sidebarFillColor = value;
+ return this;
+ }
+
+ public Builder mainFillColor(DocumentColor value) {
+ this.mainFillColor = value;
+ return this;
+ }
+
+ public Builder accentColor(DocumentColor value) {
+ this.accentColor = value;
+ return this;
+ }
+
+ public Options build() {
+ return new Options(sidebarFillColor, mainFillColor,
+ accentColor);
+ }
+ }
+ }
+
+ private static final class Template implements DocumentTemplate {
+
+ private final CvTheme theme;
+ private final DocumentColor sidebarFill;
+ private final DocumentColor mainFill;
+ private final DocumentColor accent;
+
+ Template(CvTheme theme, Options options) {
+ this.theme = theme;
+ this.sidebarFill = options.sidebarFillColor() != null
+ ? options.sidebarFillColor()
+ : theme.palette().banner();
+ this.mainFill = options.mainFillColor() != null
+ ? options.mainFillColor()
+ : DocumentColor.WHITE;
+ this.accent = options.accentColor() != null
+ ? options.accentColor()
+ : DEFAULT_ACCENT;
+ }
+
+ @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);
+
+ // Paint the two-column chrome via pageBackgrounds — the
+ // engine emits both fills on every page automatically, so
+ // overflow content on page 2+ keeps the same visual
+ // structure without any preset-side filler logic.
+ document.pageBackgrounds(List.of(
+ PageBackgroundFill.leftColumn(SIDEBAR_WIDTH_RATIO,
+ sidebarFill),
+ PageBackgroundFill.rightColumn(1.0 - SIDEBAR_WIDTH_RATIO,
+ mainFill)));
+
+ document.dsl()
+ .pageFlow()
+ .name("CvV2SidebarPortraitRoot")
+ .spacing(theme.spacing().pageFlowSpacing())
+ .padding(DocumentInsets.zero())
+ .addRow("CvV2SidebarPortraitBodyRow", row -> row
+ .spacing(0)
+ .weights(SIDEBAR_WIDTH_RATIO,
+ 1.0 - SIDEBAR_WIDTH_RATIO)
+ .addSection("CvV2SidebarPortraitSidebar",
+ section -> addSidebar(section, doc,
+ sections))
+ .addSection("CvV2SidebarPortraitMain",
+ section -> {
+ section.spacing(0)
+ .padding(DocumentInsets.zero());
+ addNameBlock(section, doc.identity());
+ addMain(section, sections);
+ }))
+ .build();
+ }
+
+ // -- Sidebar -------------------------------------------------------
+
+ private void addSidebar(SectionBuilder section, CvDocument doc,
+ List sections) {
+ // Sidebar section deliberately has no fillColor — the
+ // pageBackgrounds emitted in compose() paint the pale fill
+ // edge-to-edge on every page.
+ section.spacing(9)
+ .padding(new DocumentInsets(54, 20, 45.45, 26));
+
+ addPhotoBlock(section);
+ addContactBlock(section, doc.identity());
+
+ CvSection education = SectionLookup.firstMatching(sections,
+ EDUCATION_KEYS);
+ if (hasContent(education)) {
+ addSidebarHeader(section, "Education");
+ addEducationEntries(section, education);
+ }
+
+ CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS);
+ if (hasContent(skills)) {
+ addSidebarHeader(section, "Key Skills");
+ addSkillsList(section, skills);
+ }
+
+ CvSection languages = SectionLookup.firstMatching(sections,
+ LANGUAGE_KEYS);
+ if (hasContent(languages)) {
+ addSidebarHeader(section, "Languages");
+ addLanguageList(section, languages);
+ }
+ }
+
+ private void addPhotoBlock(SectionBuilder section) {
+ double sideInset = Math.max(0.0,
+ (SIDEBAR_INNER_WIDTH - PHOTO_DIAMETER) / 2.0);
+ section.addImage(image -> image
+ .name("CvV2SidebarPortraitPhoto")
+ .source(portraitImage())
+ .size(PHOTO_DIAMETER, PHOTO_DIAMETER)
+ .margin(new DocumentInsets(0, sideInset, 17, sideInset)));
+ }
+
+ /**
+ * Renders the icon + label contact stack in the sidebar.
+ *
+ * Inlined instead of delegating to a shared
+ * {@code ContactLine} variant because none of the existing
+ * widgets carry an inline PNG icon followed by the text label
+ * on the same baseline — every shared variant assumes either
+ * pipe-separated text or a stacked link list with no glyph.
+ * If a second preset ever needs the same icon-driven contact
+ * stack, extract this into
+ * {@code cv/v2/widgets/IconContactLine}.
+ */
+ private void addContactBlock(SectionBuilder section, CvIdentity identity) {
+ List items = contactItems(identity);
+ if (items.isEmpty()) {
+ return;
+ }
+ DocumentTextStyle textStyle = contactStyle();
+ for (ContactItem item : items) {
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(textStyle)
+ .align(TextAlign.LEFT)
+ .lineSpacing(1.35)
+ .margin(DocumentInsets.top(3))
+ .link(item.linkOptions())
+ .rich(rich -> {
+ if (item.iconFile() != null) {
+ rich.image(contactIcon(item.iconFile()),
+ 10.0, 10.0,
+ InlineImageAlignment.CENTER,
+ 0.0, item.linkOptions());
+ rich.style(" ", textStyle);
+ }
+ if (item.linkOptions() != null) {
+ rich.link(item.text(), item.linkOptions());
+ } else {
+ rich.style(item.text(), textStyle);
+ }
+ }));
+ }
+ }
+
+ private void addSidebarHeader(SectionBuilder section, String title) {
+ if (title == null || title.isBlank()) {
+ return;
+ }
+ section.addLine(line -> line
+ .horizontal(SIDEBAR_HEADER_RULE_WIDTH)
+ .color(accent)
+ .thickness(0.75)
+ .margin(new DocumentInsets(12, 0, 7, 0)));
+ section.addParagraph(paragraph -> paragraph
+ .text(spacedUpper(title))
+ .textStyle(sidebarHeaderStyle())
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero()));
+ }
+
+ private void addEducationEntries(SectionBuilder section,
+ CvSection eduSection) {
+ if (!(eduSection instanceof EntriesSection entries)) {
+ return;
+ }
+ DocumentTextStyle headingStyle = sidebarEntryTitleStyle();
+ DocumentTextStyle metaStyle = sidebarEntryMetaStyle();
+
+ List list = entries.entries();
+ for (int i = 0; i < Math.min(list.size(), EDUCATION_LIMIT); i++) {
+ CvEntry entry = list.get(i);
+ section.addParagraph(paragraph -> paragraph
+ .text(MarkdownInline.plainText(entry.title())
+ .toUpperCase(Locale.ROOT))
+ .textStyle(headingStyle)
+ .align(TextAlign.LEFT)
+ .lineSpacing(1.2)
+ .margin(DocumentInsets.top(6)));
+ if (!entry.subtitle().isBlank()) {
+ section.addParagraph(paragraph -> paragraph
+ .text(MarkdownInline.plainText(entry.subtitle()))
+ .textStyle(metaStyle)
+ .align(TextAlign.LEFT)
+ .lineSpacing(1.2)
+ .margin(DocumentInsets.zero()));
+ }
+ if (!entry.date().isBlank()) {
+ section.addParagraph(paragraph -> paragraph
+ .text(MarkdownInline.plainText(entry.date()))
+ .textStyle(metaStyle)
+ .align(TextAlign.LEFT)
+ .lineSpacing(1.2)
+ .margin(DocumentInsets.zero()));
+ }
+ }
+ }
+
+ private void addSkillsList(SectionBuilder section,
+ CvSection skillSection) {
+ if (!(skillSection instanceof SkillsSection skills)) {
+ return;
+ }
+ DocumentTextStyle skillStyle = sidebarSkillStyle();
+ List tokens = skillTokens(skills);
+ for (String token : tokens.stream().limit(SKILL_LIMIT).toList()) {
+ section.addParagraph(paragraph -> paragraph
+ .text(MarkdownInline.plainText(token))
+ .textStyle(skillStyle)
+ .lineSpacing(1.35)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(3)));
+ }
+ }
+
+ private void addLanguageList(SectionBuilder section,
+ CvSection langSection) {
+ DocumentTextStyle nameStyle = sidebarLanguageNameStyle();
+ DocumentTextStyle metaStyle = sidebarLanguageMetaStyle();
+ List items = languageItems(langSection);
+ for (String item : items.stream().limit(LANGUAGE_LIMIT).toList()) {
+ String text = MarkdownInline.plainText(item);
+ int paren = text.indexOf('(');
+ String langName = paren > 0
+ ? text.substring(0, paren).trim()
+ : text.trim();
+ String level = paren > 0
+ ? text.substring(paren).trim()
+ : "";
+ if (langName.isBlank()) {
+ continue;
+ }
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(nameStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(4))
+ .rich(rich -> {
+ rich.style(langName.toUpperCase(Locale.ROOT),
+ nameStyle);
+ if (!level.isBlank()) {
+ rich.style(" " + level, metaStyle);
+ }
+ }));
+ }
+ }
+
+ // -- Main column ---------------------------------------------------
+
+ private void addNameBlock(SectionBuilder section, CvIdentity identity) {
+ String displayName = identity == null
+ ? ""
+ : identity.name().full();
+ String jobTitle = identity == null ? "" : identity.jobTitle();
+ String subline = jobTitle == null || jobTitle.isBlank()
+ ? "Your Professional Title Goes Here"
+ : jobTitle;
+ section.addSection("CvV2SidebarPortraitHero", hero -> hero
+ .fillColor(sidebarFill)
+ .padding(new DocumentInsets(HERO_PADDING_TOP, 34,
+ HERO_PADDING_BOTTOM, 34))
+ .spacing(3)
+ .margin(DocumentInsets.top(HERO_TOP_OFFSET))
+ // Name is rendered inline rather than via
+ // Headline.uppercaseCentered because that widget
+ // calls host.padding(theme.spacing().headlinePadding())
+ // which would overwrite the hero strip's
+ // carefully-tuned HERO_PADDING_TOP / BOTTOM and
+ // break the on-axis alignment with the photo.
+ .addParagraph(paragraph -> paragraph
+ .text(displayName)
+ .textStyle(nameStyle())
+ .align(TextAlign.CENTER)
+ .lineSpacing(1.0)
+ .margin(DocumentInsets.zero()))
+ .addParagraph(paragraph -> paragraph
+ .text(spacedUpper(subline))
+ .textStyle(subtitleStyle())
+ .align(TextAlign.CENTER)
+ .margin(DocumentInsets.zero())));
+ }
+
+ private void addMain(SectionBuilder section, List sections) {
+ section.addSection("CvV2SidebarPortraitContent", content -> {
+ content.spacing(10)
+ .padding(new DocumentInsets(24, 34, 24, 34));
+
+ CvSection profile = SectionLookup.firstMatching(sections,
+ SUMMARY_KEYS);
+ if (hasContent(profile)) {
+ addMainSectionHeader(content, "Professional Profile");
+ addProfileBody(content, profile);
+ }
+
+ CvSection experience = SectionLookup.firstMatching(sections,
+ EXPERIENCE_KEYS);
+ if (hasContent(experience)) {
+ addMainSectionHeader(content, "Experience");
+ addExperienceEntries(content, experience);
+ }
+
+ CvSection projects = SectionLookup.firstMatching(sections,
+ PROJECT_KEYS);
+ if (hasContent(projects)) {
+ addMainSectionHeader(content, "Projects");
+ addProjectsList(content, projects);
+ }
+ });
+ }
+
+ private void addMainSectionHeader(SectionBuilder section, String title) {
+ if (title == null || title.isBlank()) {
+ return;
+ }
+ section.addParagraph(paragraph -> paragraph
+ .text(spacedUpper(title))
+ .textStyle(mainHeaderStyle())
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(8)));
+ section.addLine(line -> line
+ .horizontal(MAIN_SECTION_RULE_WIDTH)
+ .color(theme.palette().rule())
+ .thickness(theme.spacing().accentRuleWidth())
+ .margin(new DocumentInsets(2, 0, 7, 0)));
+ }
+
+ private void addProfileBody(SectionBuilder section,
+ CvSection profileSection) {
+ if (!(profileSection instanceof ParagraphSection paragraphSection)) {
+ return;
+ }
+ DocumentTextStyle base = mainBodyStyle();
+ String body = paragraphSection.body();
+ if (body == null || body.isBlank()) {
+ return;
+ }
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(base)
+ .lineSpacing(1.35)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(2))
+ .rich(rich -> MarkdownInline.appendTrimmed(rich, body, base)));
+ }
+
+ private void addExperienceEntries(SectionBuilder section,
+ CvSection expSection) {
+ if (!(expSection instanceof EntriesSection entries)) {
+ return;
+ }
+ DocumentTextStyle positionStyle = mainEntryTitleStyle();
+ DocumentTextStyle subtitleStyle = mainEntrySubtitleStyle();
+ DocumentTextStyle bodyStyle = mainBodyStyle();
+
+ List list = entries.entries();
+ for (int i = 0; i < Math.min(list.size(), EXPERIENCE_LIMIT); i++) {
+ CvEntry entry = list.get(i);
+ section.addParagraph(paragraph -> paragraph
+ .text(MarkdownInline.plainText(entry.title())
+ .toUpperCase(Locale.ROOT))
+ .textStyle(positionStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(8)));
+
+ String subtitle = composeSubtitle(entry);
+ if (!subtitle.isBlank()) {
+ section.addParagraph(paragraph -> paragraph
+ .text(subtitle)
+ .textStyle(subtitleStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero()));
+ }
+ if (!entry.body().isBlank()) {
+ String description = entry.body();
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(bodyStyle)
+ .lineSpacing(1.35)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(2))
+ .rich(rich -> MarkdownInline.appendTrimmed(rich,
+ description, bodyStyle)));
+ }
+ }
+ }
+
+ private static String composeSubtitle(CvEntry entry) {
+ String sub = MarkdownInline.plainText(entry.subtitle());
+ String date = MarkdownInline.plainText(entry.date());
+ if (sub.isBlank()) {
+ return date;
+ }
+ if (date.isBlank()) {
+ return sub;
+ }
+ return sub + " | " + date;
+ }
+
+ /**
+ * Renders the Projects section in the main column. Same visual
+ * grammar as Profile / Experience — section heading + rule
+ * via {@link #addMainSectionHeader}, then a stacked row per
+ * project where each row carries a bold title, optional
+ * italic stack context parsed by {@link ProjectLabel}, and a
+ * body paragraph. Each project lives as separate paragraphs
+ * inside the same flow, so the engine page-breaks naturally
+ * between projects on multi-page CVs and the pageBackgrounds
+ * keep the sidebar fill repeating on every continuation page.
+ */
+ private void addProjectsList(SectionBuilder section,
+ CvSection projectSection) {
+ if (!(projectSection instanceof RowsSection rows)) {
+ return;
+ }
+ DocumentTextStyle titleStyle = mainProjectTitleStyle();
+ DocumentTextStyle contextStyle = mainProjectContextStyle();
+ DocumentTextStyle bodyStyle = mainBodyStyle();
+
+ List list = rows.rows();
+ for (int i = 0; i < Math.min(list.size(), PROJECT_LIMIT); i++) {
+ CvRow row = list.get(i);
+ ProjectLabel label = ProjectLabel.parse(row.label());
+ String body = MarkdownInline.plainText(row.body());
+ double topMargin = i == 0 ? 4.0 : 8.0;
+
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(titleStyle)
+ .align(TextAlign.LEFT)
+ .lineSpacing(1.2)
+ .margin(DocumentInsets.top(topMargin))
+ .rich(rich -> {
+ rich.style(label.title(), titleStyle);
+ if (!label.stack().isBlank()) {
+ rich.style(" (" + label.stack() + ")",
+ contextStyle);
+ }
+ }));
+ if (!body.isBlank()) {
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(bodyStyle)
+ .lineSpacing(1.35)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(2))
+ .rich(rich -> MarkdownInline.appendTrimmed(rich,
+ body, bodyStyle)));
+ }
+ }
+ }
+
+ // -- Style factories ------------------------------------------------
+
+ private DocumentTextStyle nameStyle() {
+ return CvTextStyles.of(theme.typography().headlineFont(),
+ theme.typography().sizeHeadline(),
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle subtitleStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeEntryDate(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle contactStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeContact(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle sidebarHeaderStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ 10.8,
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle sidebarEntryTitleStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ 8.4,
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle sidebarEntryMetaStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ 7.8,
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().muted());
+ }
+
+ private DocumentTextStyle sidebarSkillStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeEntrySubtitle(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle sidebarLanguageNameStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ 8.1,
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle sidebarLanguageMetaStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ 7.9,
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().muted());
+ }
+
+ private DocumentTextStyle mainHeaderStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeBanner(),
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle mainBodyStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeBody(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle mainEntryTitleStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeEntryTitle(),
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle mainEntrySubtitleStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ 9.2,
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle mainProjectTitleStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeEntryTitle(),
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle mainProjectContextStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeEntryDate(),
+ DocumentTextDecoration.ITALIC,
+ theme.palette().muted());
+ }
+ }
+
+ // -- Static helpers ----------------------------------------------------
+
+ private static boolean hasContent(CvSection section) {
+ return section != null && SectionLookup.hasContent(section);
+ }
+
+ private static List contactItems(CvIdentity identity) {
+ if (identity == null) {
+ return List.of();
+ }
+ List items = new ArrayList<>();
+ addContactItem(items, "phone.png", identity.contact().phone(), null);
+ String email = identity.contact().email();
+ if (!email.isBlank()) {
+ addContactItem(items, "email.png", email,
+ new DocumentLinkOptions("mailto:" + email));
+ }
+ addContactItem(items, "location.png", identity.contact().address(),
+ null);
+ for (CvLink link : identity.links()) {
+ String label = link.label();
+ if (label.isBlank()) {
+ continue;
+ }
+ String url = link.url();
+ addContactItem(items, pickIconFile(label), label,
+ url.isBlank()
+ ? null
+ : new DocumentLinkOptions(url.trim()));
+ }
+ return List.copyOf(items);
+ }
+
+ private static void addContactItem(List items,
+ String iconFile, String text,
+ DocumentLinkOptions linkOptions) {
+ if (text != null && !text.isBlank()) {
+ items.add(new ContactItem(iconFile, text.trim(), linkOptions));
+ }
+ }
+
+ 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 "linkedin.png";
+ }
+
+ private static DocumentImageData contactIcon(String iconFile) {
+ return DocumentImageData.fromBytes(
+ ASSET_CACHE.computeIfAbsent(CONTACT_ICON_ROOT + iconFile,
+ SidebarPortrait::readAssetBytes));
+ }
+
+ private static DocumentImageData portraitImage() {
+ return DocumentImageData.fromBytes(
+ ASSET_CACHE.computeIfAbsent(TEMPLATE_ASSET_ROOT + PORTRAIT_FILE,
+ SidebarPortrait::readAssetBytes));
+ }
+
+ private static byte[] readAssetBytes(String resourcePath) {
+ try (InputStream input = SidebarPortrait.class
+ .getResourceAsStream(resourcePath)) {
+ if (input == null) {
+ throw new IllegalStateException(
+ "Missing sidebar portrait asset: " + resourcePath);
+ }
+ return input.readAllBytes();
+ } catch (IOException e) {
+ throw new UncheckedIOException(
+ "Failed to read sidebar portrait asset: " + resourcePath,
+ e);
+ }
+ }
+
+ private static List skillTokens(SkillsSection skills) {
+ List tokens = new ArrayList<>();
+ for (SkillGroup group : skills.groups()) {
+ String inline = MarkdownInline.plainText(group.skillsInline());
+ for (String token : inline.split(",")) {
+ String clean = token.trim();
+ if (!clean.isBlank()) {
+ tokens.add(clean);
+ }
+ }
+ }
+ return tokens;
+ }
+
+ /**
+ * Extracts language strings out of a section. Accepts either an
+ * explicit {@code RowsSection} with a "Languages: ..." row or any
+ * row whose body looks like an inline list, plus a fallback that
+ * parses {@code SkillsSection.groups()} when languages are stored
+ * as a single group inside the additional-information slot.
+ */
+ private static List languageItems(CvSection section) {
+ if (section == null) {
+ return List.of();
+ }
+ List result = new ArrayList<>();
+ if (section instanceof RowsSection rows) {
+ for (CvRow row : rows.rows()) {
+ String label = MarkdownInline.plainText(row.label()).trim();
+ String body = MarkdownInline.plainText(row.body()).trim();
+ String lower = label.toLowerCase(Locale.ROOT);
+ if (lower.contains("language") && !body.isBlank()) {
+ for (String part : body.split(",")) {
+ String p = part.trim();
+ if (!p.isBlank()) {
+ result.add(p);
+ }
+ }
+ } else if (!label.isBlank()
+ && (body.contains("(") || body.contains("|"))) {
+ result.add(label + " " + body);
+ }
+ }
+ } else if (section instanceof SkillsSection skills) {
+ for (SkillGroup group : skills.groups()) {
+ String inline = MarkdownInline.plainText(group.skillsInline());
+ for (String part : inline.split(",")) {
+ String p = part.trim();
+ if (!p.isBlank()) {
+ result.add(p);
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ private static String spacedUpper(String value) {
+ String upper = (value == null ? "" : 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.isLetterOrDigit(current)
+ && i + 1 < upper.length()
+ && Character.isLetterOrDigit(upper.charAt(i + 1))) {
+ builder.append(' ');
+ } else if (Character.isWhitespace(current)) {
+ builder.append(" ");
+ }
+ }
+ return builder.toString();
+ }
+
+ private record ContactItem(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 0161e10b..56bd2f0c 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
@@ -144,6 +144,23 @@ public static CvPalette editorialBlue() {
DocumentColor.rgb(193, 201, 211));
}
+ /**
+ * Sidebar Portrait palette ported from the v1
+ * {@code SidebarPortraitCvTemplateComposer}: near-black ink for
+ * body text, mid-grey for soft metadata, mid-grey rule lines, and
+ * the pale-beige sidebar background fill. The mid-grey divider
+ * accent and rule colour are reused; main and sidebar tones share
+ * the same {@code ink}/{@code muted} pair because the preset uses
+ * a restrained grey palette throughout.
+ */
+ public static CvPalette sidebarPortrait() {
+ return new CvPalette(
+ DocumentColor.rgb(34, 34, 34), // ink — V1 INK
+ DocumentColor.rgb(85, 85, 85), // muted — V1 SOFT
+ DocumentColor.rgb(178, 178, 178), // rule — V1 RULE
+ DocumentColor.rgb(241, 240, 237)); // banner — V1 SIDEBAR_BG
+ }
+
/**
* Monogram Sidebar palette ported from the v1
* {@code MonogramSidebarCvTemplateComposer}: navy-slate body 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 05ec14d5..f9115695 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,31 @@ public static CvSpacing editorialBlue() {
3.0); // entrySeparation
}
+ /**
+ * Spacing for the Sidebar Portrait preset: zero page-flow gap so
+ * the sidebar fill bleeds edge-to-edge against the
+ * RECOMMENDED_MARGIN=0 page bounds. Banner tokens are unused —
+ * the preset draws its chrome inline (portrait photo, hero strip,
+ * rules).
+ */
+ public static CvSpacing sidebarPortrait() {
+ return new CvSpacing(
+ 0, // pageFlowSpacing
+ 5, // sectionBodySpacing
+ DocumentInsets.zero(), // sectionBodyPadding
+ DocumentInsets.zero(), // headlinePadding
+ DocumentInsets.zero(), // contactPadding
+ 0.0, // bannerCornerRadius (unused)
+ 0.0, // bannerInnerPadding (unused)
+ DocumentInsets.zero(), // bannerMargin (unused)
+ 0.55, // accentRuleWidth (main section rule)
+ 1.0, // paragraphMarginTop
+ 8.0, // entryHeaderRowSpacing
+ 1.0, // entryTitleWeight
+ 0.45, // entryDateWeight
+ 2.0); // entrySeparation
+ }
+
/**
* Spacing for the Monogram Sidebar preset: zero page-flow gap so
* the pale sidebar fill bleeds edge-to-edge against the
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 9009e57b..5d53b6ec 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,24 @@ public static CvTheme editorialBlue() {
CvDecoration.classic());
}
+ /**
+ * The "Sidebar Portrait" look — Crimson Text serif hero, Lato
+ * body, restrained grey palette. Pale-beige left sidebar carries
+ * a circular portrait photo, contact stack, education + key
+ * skills + languages summary; the right column carries a large
+ * serif name (positioned to straddle the sidebar/main boundary
+ * via a hero strip), professional profile, and experience
+ * timeline. Visual signature ported from the v1
+ * {@code SidebarPortraitCvTemplateComposer}.
+ */
+ public static CvTheme sidebarPortrait() {
+ return new CvTheme(
+ CvPalette.sidebarPortrait(),
+ CvTypography.sidebarPortrait(),
+ CvSpacing.sidebarPortrait(),
+ CvDecoration.classic());
+ }
+
/**
* The "Monogram Sidebar" look — Crimson Text display + Lato body,
* pale teal-grey sidebar with a dark monogram ring badge holding
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 3b34292d..538ce668 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
}
+ /**
+ * Crimson Text headline + Lato body scale ported from the v1
+ * {@code SidebarPortraitCvTemplateComposer}: 28pt serif hero name,
+ * 12pt main section title, 10pt body, and 8-9pt sidebar metadata.
+ * The sample data is information-dense, so the body sizes are
+ * compact and the line spacing trends to 1.35 for readability.
+ */
+ public static CvTypography sidebarPortrait() {
+ return new CvTypography(
+ FontName.CRIMSON_TEXT, FontName.LATO,
+ 28.0, // headline (hero name)
+ 8.3, // contact (sidebar contact stack)
+ 12.0, // banner / main section title
+ 10.0, // entry title (experience position)
+ 8.4, // entry date (subtitle slot also)
+ 8.0, // entry subtitle
+ 9.4, // body (profile / experience body)
+ 1.35); // line spacing
+ }
+
/**
* Crimson Text headline + Lato body scale ported from the v1
* {@code MonogramSidebarCvTemplateComposer}: 30pt 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 511a4d0e..a36090a1 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
@@ -126,7 +126,10 @@ private static Stream presets() {
(Supplier>) EngineeringResume::create),
Arguments.of("monogram_sidebar",
MonogramSidebar.RECOMMENDED_MARGIN,
- (Supplier>) MonogramSidebar::create));
+ (Supplier>) MonogramSidebar::create),
+ Arguments.of("sidebar_portrait",
+ SidebarPortrait.RECOMMENDED_MARGIN,
+ (Supplier>) SidebarPortrait::create));
}
/**
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/SidebarPortraitSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/SidebarPortraitSmokeTest.java
new file mode 100644
index 00000000..bb9c19d4
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/SidebarPortraitSmokeTest.java
@@ -0,0 +1,92 @@
+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 Sidebar Portrait preset. Covers the
+ * portrait + contact stack, the multi-section sidebar (education /
+ * skills / languages), and the hero name strip + main column body.
+ */
+class SidebarPortraitSmokeTest {
+
+ @Test
+ void exposes_stable_identity() {
+ DocumentTemplate template = SidebarPortrait.create();
+ assertThat(template.id()).isEqualTo("sidebar-portrait");
+ assertThat(template.displayName()).isEqualTo("Sidebar Portrait");
+ }
+
+ @Test
+ void default_factory_renders_full_document() throws Exception {
+ renderAndAssertNonEmpty(SidebarPortrait.create(), fullDocument());
+ }
+
+ @Test
+ void custom_theme_factory_renders() throws Exception {
+ renderAndAssertNonEmpty(SidebarPortrait.create(CvTheme.sidebarPortrait()),
+ fullDocument());
+ }
+
+ private static void renderAndAssertNonEmpty(
+ DocumentTemplate template,
+ CvDocument doc) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(420, 595)
+ .margin(DocumentInsets.zero())
+ .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/sidebar_portrait-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/sidebar_portrait-page-0.png
new file mode 100644
index 00000000..f66478c3
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/sidebar_portrait-page-0.png differ