Pass {@code null} or an empty list to clear. Fills paint in list
+ * order; later entries paint on top of earlier ones where they
+ * overlap.
+ *
+ * @param fills ordered list of fills, or {@code null}/empty to clear
+ * @return this session
+ * @throws IllegalStateException if this session has already been closed
+ */
+ public DocumentSession pageBackgrounds(List fills) {
+ ensureOpen();
+ this.pageBackgrounds = fills == null ? List.of() : List.copyOf(fills);
+ invalidate();
+ return this;
+ }
+
/**
* Returns a fluent facade for chrome configuration (metadata,
* watermark, protection, header, footer). The facade is a thin
@@ -617,7 +643,7 @@ public LayoutGraph layoutGraph() {
LIFECYCLE_LOG.debug("document.layout.start sessionId={} revision={} roots={}", sessionId, revision, roots.size());
try {
DocumentLayoutPassContext context = new DocumentLayoutPassContext(registry, canvas, measurementResources.fontLibrary(), measurementResources.textMeasurementSystem(), markdown);
- LayoutGraph computed = layoutCache.layout(() -> DocumentPageBackgrounds.apply(compiler.compile(documentGraph(), context, context), pageBackground));
+ LayoutGraph computed = layoutCache.layout(() -> DocumentPageBackgrounds.apply(compiler.compile(documentGraph(), context, context), pageBackgrounds));
LIFECYCLE_LOG.debug("document.layout.end sessionId={} revision={} roots={} pages={} nodes={} fragments={} durationMs={}", sessionId, revision, roots.size(), computed.totalPages(), computed.nodes().size(), computed.fragments().size(), elapsedMillis(startNanos));
return computed;
} catch (RuntimeException ex) {
diff --git a/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java b/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java
new file mode 100644
index 00000000..3d7ae990
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java
@@ -0,0 +1,87 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.document.style.DocumentColor;
+
+import java.util.Objects;
+
+/**
+ * Per-page rectangular background fill, defined as ratios of the
+ * canvas page size so the same fill scales correctly to any page
+ * format. Used by {@link DocumentSession#pageBackgrounds(java.util.List)}
+ * to paint multi-column or partial-page backgrounds that repeat on
+ * every page automatically.
+ *
+ * Use the factory methods for the common cases:
+ *
+ * - {@link #fullPage(DocumentColor)} — entire page (same effect as
+ * the legacy single-color {@link DocumentSession#pageBackground}).
+ * - {@link #leftColumn(double, DocumentColor)} — full-height column
+ * aligned to the left edge.
+ * - {@link #rightColumn(double, DocumentColor)} — full-height
+ * column aligned to the right edge.
+ * - {@link #column(double, double, DocumentColor)} — arbitrary
+ * horizontal slice spanning the full page height.
+ *
+ *
+ * Fills supplied to a session are painted at z=0 (below every other
+ * fragment) in list order, so later entries paint on top of earlier
+ * entries when they overlap. This is the natural way to layer a
+ * narrow accent column over a full-page tint.
+ *
+ * @param xRatio 0.0 = left edge, 1.0 = right edge
+ * @param yRatio 0.0 = top edge, 1.0 = bottom edge
+ * @param widthRatio width as a fraction of the canvas width (0..1]
+ * @param heightRatio height as a fraction of the canvas height (0..1]
+ * @param color fill color (required)
+ */
+public record PageBackgroundFill(double xRatio,
+ double yRatio,
+ double widthRatio,
+ double heightRatio,
+ DocumentColor color) {
+
+ public PageBackgroundFill {
+ Objects.requireNonNull(color, "color");
+ if (xRatio < 0.0 || xRatio > 1.0) {
+ throw new IllegalArgumentException(
+ "xRatio must be in [0,1] but was " + xRatio);
+ }
+ if (yRatio < 0.0 || yRatio > 1.0) {
+ throw new IllegalArgumentException(
+ "yRatio must be in [0,1] but was " + yRatio);
+ }
+ if (widthRatio <= 0.0 || widthRatio > 1.0) {
+ throw new IllegalArgumentException(
+ "widthRatio must be in (0,1] but was " + widthRatio);
+ }
+ if (heightRatio <= 0.0 || heightRatio > 1.0) {
+ throw new IllegalArgumentException(
+ "heightRatio must be in (0,1] but was " + heightRatio);
+ }
+ }
+
+ /** Full-page fill, equivalent to the legacy single-color page background. */
+ public static PageBackgroundFill fullPage(DocumentColor color) {
+ return new PageBackgroundFill(0.0, 0.0, 1.0, 1.0, color);
+ }
+
+ /** Full-height column at the left page edge, with width = ratio of page width. */
+ public static PageBackgroundFill leftColumn(double widthRatio,
+ DocumentColor color) {
+ return new PageBackgroundFill(0.0, 0.0, widthRatio, 1.0, color);
+ }
+
+ /** Full-height column at the right page edge, with width = ratio of page width. */
+ public static PageBackgroundFill rightColumn(double widthRatio,
+ DocumentColor color) {
+ return new PageBackgroundFill(1.0 - widthRatio, 0.0,
+ widthRatio, 1.0, color);
+ }
+
+ /** Full-height column at an arbitrary horizontal offset. */
+ public static PageBackgroundFill column(double xRatio,
+ double widthRatio,
+ DocumentColor color) {
+ return new PageBackgroundFill(xRatio, 0.0, widthRatio, 1.0, color);
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MonogramSidebar.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MonogramSidebar.java
new file mode 100644
index 00000000..f72b70c0
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MonogramSidebar.java
@@ -0,0 +1,880 @@
+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.EllipseBuilder;
+import com.demcha.compose.document.dsl.LayerStackBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+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.LayerAlign;
+import com.demcha.compose.document.node.LayerStackNode;
+import com.demcha.compose.document.node.SpacerNode;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.components.CvTextStyles;
+import com.demcha.compose.document.templates.cv.v2.components.LabelValueRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.MarkdownInline;
+import com.demcha.compose.document.templates.cv.v2.components.ProjectRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.SectionLookup;
+import com.demcha.compose.document.templates.cv.v2.components.TextOrnaments;
+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.CvName;
+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.font.FontName;
+
+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 "Monogram Sidebar" CV preset.
+ *
+ * Two-column resume with a pale teal-grey sidebar carrying a
+ * monogram badge (initials inside a dark ring), centered contact
+ * icons, education and expertise blocks; the right column carries a
+ * two-line letter-spaced headline plus the main career narrative.
+ * Visual signature ported from the v1
+ * {@code MonogramSidebarCvTemplateComposer}: Crimson Text headline,
+ * PT Serif monogram, muted gold accent.
+ *
+ * The page uses a zero margin so the pale sidebar fill bleeds to
+ * the page edge. Two-column page chrome is painted via
+ * {@link com.demcha.compose.document.api.DocumentSession#pageBackgrounds},
+ * which repeats automatically on every page — when content overflows
+ * onto page 2 the sidebar tint and white main area continue without
+ * any preset-side filler logic. The preset draws its visual ornaments
+ * (monogram ring, section rules) inline because none of these visuals
+ * are shared with another v2 preset today.
+ */
+public final class MonogramSidebar {
+
+ /** Stable template identifier. */
+ public static final String ID = "monogram-sidebar";
+
+ /** Human-readable display name. */
+ public static final String DISPLAY_NAME = "Monogram Sidebar";
+
+ /** Recommended page margin (in points) — 0 so the sidebar bleeds to the edge. */
+ public static final double RECOMMENDED_MARGIN = 0.0;
+
+ /** V1 default muted-gold accent — used for the subtitle, dates. */
+ private static final DocumentColor DEFAULT_ACCENT =
+ DocumentColor.rgb(158, 146, 104);
+
+ /** V1 default dark monogram ring + initials colour. */
+ private static final DocumentColor DEFAULT_MONOGRAM_RING =
+ DocumentColor.rgb(54, 62, 74);
+
+ /** V1 dark main-column rule colour (theme rule is sidebar-only). */
+ private static final DocumentColor MAIN_RULE =
+ DocumentColor.rgb(72, 79, 84);
+
+ /** PT Serif used only for the monogram initials inside the ring badge. */
+ private static final FontName MONOGRAM_FONT = FontName.PT_SERIF;
+
+ private static final double MONOGRAM_DIAMETER = 122;
+ private static final double SIDEBAR_RULE_WIDTH = 118;
+ private static final double CONTACT_ICON_SIZE = 22;
+
+ /** Sidebar column width as a fraction of the page width. */
+ private static final double SIDEBAR_WIDTH_RATIO = 0.33;
+
+ private static final double MAIN_SECTION_RULE_WIDTH = 355.0;
+
+ private static final int EDUCATION_LIMIT = 2;
+ private static final int SKILL_LIMIT = 7;
+ private static final int EXPERIENCE_LIMIT = 2;
+ private static final int PROJECT_LIMIT = 3;
+ private static final int ADDITIONAL_LIMIT = 3;
+
+ private static final String CONTACT_ICON_ROOT =
+ "/templates/cv/monogram-sidebar/icons/";
+ private static final Map CONTACT_ICON_CACHE =
+ new ConcurrentHashMap<>();
+
+ private static final List EDUCATION_KEYS =
+ List.of("education", "certifications");
+ private static final List SKILL_KEYS =
+ List.of("skills", "technical skills", "expertise");
+ 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");
+ private static final List ADDITIONAL_KEYS =
+ List.of("additional information", "additional");
+
+ private MonogramSidebar() {
+ }
+
+ /**
+ * Builds the preset with its Monogram Sidebar theme and default
+ * options (theme's banner fill for the sidebar, muted-gold accent,
+ * dark slate monogram ring).
+ */
+ public static DocumentTemplate create() {
+ return create(CvTheme.monogramSidebar(), 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, accent colour, or monogram ring
+ * 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);
+ }
+
+ /**
+ * Monogram Sidebar customisation knobs. {@code null} fields fall
+ * back to the theme palette / V1 defaults documented on each
+ * accessor.
+ *
+ * @param sidebarFillColor sidebar (left column) background fill;
+ * {@code null} → {@code theme.palette().banner()}
+ * @param mainFillColor main (right column) background fill;
+ * {@code null} → {@code theme.palette().mainFill()}
+ * (defaults to {@link DocumentColor#WHITE})
+ * @param accentColor muted-gold accent for subtitle,
+ * education date, experience date;
+ * {@code null} → V1 rgb(158,146,104)
+ * @param monogramRingColor ring stroke + initials colour;
+ * {@code null} → V1 rgb(54,62,74)
+ */
+ public record Options(DocumentColor sidebarFillColor,
+ DocumentColor mainFillColor,
+ DocumentColor accentColor,
+ DocumentColor monogramRingColor) {
+
+ public static Options defaults() {
+ return new Options(null, 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 DocumentColor monogramRingColor;
+
+ 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 Builder monogramRingColor(DocumentColor value) {
+ this.monogramRingColor = value;
+ return this;
+ }
+
+ public Options build() {
+ return new Options(sidebarFillColor, mainFillColor,
+ accentColor, monogramRingColor);
+ }
+ }
+ }
+
+ private static final class Template implements DocumentTemplate {
+
+ private final CvTheme theme;
+ private final DocumentColor sidebarFill;
+ private final DocumentColor mainFill;
+ private final DocumentColor accent;
+ private final DocumentColor monogramRing;
+
+ 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()
+ : theme.palette().mainFill();
+ this.accent = options.accentColor() != null
+ ? options.accentColor()
+ : DEFAULT_ACCENT;
+ this.monogramRing = options.monogramRingColor() != null
+ ? options.monogramRingColor()
+ : DEFAULT_MONOGRAM_RING;
+ }
+
+ @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 pageInnerWidth = document.canvas().innerWidth();
+ double sidebarOuterWidth = pageInnerWidth * 0.33;
+ double sidebarHorizontalPadding = 13.0 * 2.0;
+ double sidebarInnerWidth = Math.max(0.0,
+ sidebarOuterWidth - sidebarHorizontalPadding);
+ double mainOuterWidth = pageInnerWidth - sidebarOuterWidth;
+ // Main section has 20pt left + 18pt right padding (see addMain).
+ // Spacer width must be the content-area width so the inner
+ // content fills exactly the section's allocated outer width
+ // (= mainOuterWidth) — passing the outer width directly
+ // would overflow because outer = content + padding.
+ double mainContentWidth = Math.max(0.0,
+ mainOuterWidth - (20.0 + 18.0));
+ 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("CvV2MonogramSidebarRoot")
+ .spacing(theme.spacing().pageFlowSpacing())
+ .padding(DocumentInsets.zero())
+ .addRow("CvV2MonogramSidebarFrame", row -> row
+ .spacing(0)
+ .weights(SIDEBAR_WIDTH_RATIO,
+ 1.0 - SIDEBAR_WIDTH_RATIO)
+ .addSection("CvV2MonogramSidebarSidebar",
+ section -> addSidebar(section, doc, sections,
+ sidebarInnerWidth))
+ .addSection("CvV2MonogramSidebarMain",
+ section -> addMain(section, doc.identity(),
+ sections, mainContentWidth)))
+ .build();
+ }
+
+ // -- Sidebar -------------------------------------------------------
+
+ private void addSidebar(SectionBuilder section, CvDocument doc,
+ List sections, double innerWidth) {
+ // Sidebar section deliberately has no fillColor — the
+ // pageBackgrounds emitted in compose() paint the pale fill
+ // edge-to-edge on every page, including continuation
+ // pages. Top padding establishes the breathing room above
+ // the monogram badge.
+ section.spacing(8)
+ .padding(new DocumentInsets(36, 13, 0, 13));
+
+ addMonogramBlock(section, initials(doc.identity().name()),
+ innerWidth);
+
+ addSidebarHeader(section, "CONTACT", innerWidth);
+ addContactBlock(section, doc.identity());
+
+ CvSection education = SectionLookup.firstMatching(sections,
+ EDUCATION_KEYS);
+ if (hasContent(education)) {
+ addSidebarHeader(section, education.title(), innerWidth);
+ addEducationEntries(section, education);
+ }
+
+ CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS);
+ if (hasContent(skills)) {
+ addSidebarHeader(section, "EXPERTISE", innerWidth);
+ addSkillsList(section, skills);
+ }
+ }
+
+ private void addMonogramBlock(SectionBuilder section,
+ String initialsText, double innerWidth) {
+ LayerStackNode badge = new LayerStackBuilder()
+ .name("CvV2MonogramSidebarBadge")
+ .back(new EllipseBuilder()
+ .name("CvV2MonogramSidebarRing")
+ .size(MONOGRAM_DIAMETER, MONOGRAM_DIAMETER)
+ .stroke(DocumentStroke.of(monogramRing, 1.25))
+ .build())
+ .layer(new ParagraphBuilder()
+ .name("CvV2MonogramSidebarInitials")
+ .text(initialsText)
+ .textStyle(CvTextStyles.of(MONOGRAM_FONT, 44.0,
+ DocumentTextDecoration.BOLD, monogramRing))
+ .align(TextAlign.LEFT)
+ .build(), LayerAlign.CENTER)
+ .build();
+
+ section.addLayerStack(outer -> outer
+ .name("CvV2MonogramSidebarFrame")
+ .margin(DocumentInsets.bottom(42))
+ .back(new SpacerNode(
+ "CvV2MonogramSidebarSpace",
+ Math.max(MONOGRAM_DIAMETER, innerWidth),
+ MONOGRAM_DIAMETER,
+ DocumentInsets.zero(),
+ DocumentInsets.zero()))
+ .layer(badge, LayerAlign.TOP_CENTER));
+ }
+
+ private void addSidebarHeader(SectionBuilder section, String title,
+ double innerWidth) {
+ if (title == null || title.isBlank()) {
+ return;
+ }
+ section.addParagraph(paragraph -> paragraph
+ .text(TextOrnaments.spacedUpper(title))
+ .textStyle(sidebarHeaderStyle())
+ .align(TextAlign.CENTER)
+ .lineSpacing(1.2)
+ .margin(DocumentInsets.top(6)));
+ double ruleWidth = Math.min(innerWidth, SIDEBAR_RULE_WIDTH);
+ double sideInset = Math.max(0.0, (innerWidth - ruleWidth) / 2.0);
+ section.addLine(line -> line
+ .horizontal(ruleWidth)
+ .color(theme.palette().rule())
+ .thickness(0.45)
+ .margin(new DocumentInsets(1, sideInset, 2, sideInset)));
+ }
+
+ private void addContactBlock(SectionBuilder section, CvIdentity identity) {
+ List items = contactItems(identity);
+ if (items.isEmpty()) {
+ return;
+ }
+ DocumentTextStyle textStyle = sidebarBodyStyle();
+ for (ContactItem item : items) {
+ if (item.iconFile() != null) {
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(textStyle)
+ .align(TextAlign.CENTER)
+ .margin(DocumentInsets.top(4))
+ .rich(rich -> rich.image(
+ contactIcon(item.iconFile()),
+ CONTACT_ICON_SIZE,
+ CONTACT_ICON_SIZE,
+ InlineImageAlignment.CENTER,
+ 0.0,
+ item.linkOptions())));
+ }
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(textStyle)
+ .align(TextAlign.CENTER)
+ .lineSpacing(1.2)
+ .margin(DocumentInsets.zero())
+ .link(item.linkOptions())
+ .rich(rich -> {
+ if (item.linkOptions() != null) {
+ rich.link(item.text(), item.linkOptions());
+ } else {
+ rich.style(item.text(), textStyle);
+ }
+ }));
+ }
+ }
+
+ private void addEducationEntries(SectionBuilder section,
+ CvSection eduSection) {
+ if (!(eduSection instanceof EntriesSection entries)) {
+ return;
+ }
+ DocumentTextStyle headingStyle = sidebarEntryTitleStyle();
+ DocumentTextStyle subStyle = sidebarEntrySubtitleStyle();
+ DocumentTextStyle metaStyle = sidebarEntryDateStyle();
+
+ 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.CENTER)
+ .lineSpacing(1.2)
+ .margin(DocumentInsets.top(6)));
+ if (!entry.subtitle().isBlank()) {
+ section.addParagraph(paragraph -> paragraph
+ .text(MarkdownInline.plainText(entry.subtitle()))
+ .textStyle(subStyle)
+ .align(TextAlign.CENTER)
+ .lineSpacing(1.2)
+ .margin(DocumentInsets.zero()));
+ }
+ if (!entry.date().isBlank()) {
+ section.addParagraph(paragraph -> paragraph
+ .text(MarkdownInline.plainText(entry.date()))
+ .textStyle(metaStyle)
+ .align(TextAlign.CENTER)
+ .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)
+ .align(TextAlign.CENTER)
+ .lineSpacing(1.25)
+ .margin(DocumentInsets.top(1)));
+ }
+ }
+
+ // -- Main column ---------------------------------------------------
+
+ private void addMain(SectionBuilder section, CvIdentity identity,
+ List sections, double anchorWidth) {
+ // No fillColor — the page background defaults to white, so
+ // the right column is naturally white from top to bottom.
+ section.spacing(5)
+ .padding(new DocumentInsets(38, 20, 24, 18));
+
+ addNameBlock(section, identity);
+
+ CvSection profile = SectionLookup.firstMatching(sections,
+ SUMMARY_KEYS);
+ if (hasContent(profile)) {
+ addMainSectionHeader(section,
+ profile.title().isBlank()
+ ? "Professional Profile"
+ : profile.title());
+ addProfileBody(section, profile);
+ }
+
+ CvSection experience = SectionLookup.firstMatching(sections,
+ EXPERIENCE_KEYS);
+ if (hasContent(experience)) {
+ addMainSectionHeader(section,
+ experience.title().isBlank()
+ ? "Experience"
+ : experience.title());
+ addExperienceEntries(section, experience);
+ }
+
+ CvSection projects = SectionLookup.firstMatching(sections,
+ PROJECT_KEYS);
+ if (hasContent(projects)) {
+ addMainSectionHeader(section,
+ projects.title().isBlank()
+ ? "Projects"
+ : projects.title());
+ addProjectsList(section, projects);
+ }
+
+ CvSection additional = SectionLookup.firstMatching(sections,
+ ADDITIONAL_KEYS);
+ if (hasContent(additional)) {
+ addMainSectionHeader(section,
+ additional.title().isBlank()
+ ? "Additional Information"
+ : additional.title());
+ addAdditionalList(section, additional);
+ }
+ }
+
+ private void addProjectsList(SectionBuilder section,
+ CvSection projectsSection) {
+ if (!(projectsSection instanceof RowsSection rows)) {
+ return;
+ }
+ DocumentTextStyle titleStyle = mainEntryTitleStyle();
+ DocumentTextStyle stackStyle = mainEntryDateStyle();
+ DocumentTextStyle bodyStyle = mainBodyStyle();
+ List list = rows.rows();
+ for (int i = 0; i < Math.min(list.size(), PROJECT_LIMIT); i++) {
+ ProjectRenderer.inline(section, list.get(i),
+ titleStyle, stackStyle, bodyStyle,
+ theme.typography().bodyLineSpacing(),
+ DocumentInsets.top(4));
+ }
+ }
+
+ private void addAdditionalList(SectionBuilder section,
+ CvSection addSection) {
+ if (!(addSection instanceof RowsSection rows)) {
+ return;
+ }
+ DocumentTextStyle labelStyle = mainEntryTitleStyle();
+ DocumentTextStyle valueStyle = mainBodyStyle();
+ List list = rows.rows();
+ for (int i = 0; i < Math.min(list.size(), ADDITIONAL_LIMIT); i++) {
+ CvRow row = list.get(i);
+ LabelValueRenderer.render(section, row.label(), row.body(),
+ labelStyle, valueStyle,
+ theme.typography().bodyLineSpacing(),
+ DocumentInsets.top(3));
+ }
+ }
+
+ private void addNameBlock(SectionBuilder section, CvIdentity identity) {
+ CvName name = identity == null ? CvName.of("", "") : identity.name();
+ List parts = new ArrayList<>();
+ if (!name.first().isBlank()) {
+ parts.add(name.first());
+ }
+ if (!name.last().isBlank()) {
+ parts.add(name.last());
+ }
+ if (parts.isEmpty()) {
+ parts.add("");
+ }
+ String jobTitle = identity == null ? "" : identity.jobTitle();
+ String subline = jobTitle == null || jobTitle.isBlank()
+ ? "Your Professional Title"
+ : jobTitle;
+ DocumentTextStyle nameStyle = nameStyle();
+ DocumentTextStyle titleStyle = subtitleStyle();
+
+ for (int index = 0; index < parts.size(); index++) {
+ String part = parts.get(index);
+ DocumentInsets margin = index == parts.size() - 1
+ ? DocumentInsets.zero()
+ : DocumentInsets.bottom(6);
+ section.addParagraph(paragraph -> paragraph
+ .text(TextOrnaments.spacedUpper(part))
+ .textStyle(nameStyle)
+ .align(TextAlign.CENTER)
+ .lineSpacing(1.0)
+ .margin(margin));
+ }
+ section.addParagraph(paragraph -> paragraph
+ .text(TextOrnaments.spacedUpper(subline))
+ .textStyle(titleStyle)
+ .align(TextAlign.CENTER)
+ .margin(new DocumentInsets(12, 0, 22, 0)));
+ }
+
+ private void addMainSectionHeader(SectionBuilder section, String title) {
+ if (title == null || title.isBlank()) {
+ return;
+ }
+ section.addParagraph(paragraph -> paragraph
+ .text(TextOrnaments.spacedUpper(title))
+ .textStyle(mainHeaderStyle())
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(6)));
+ section.addLine(line -> line
+ .horizontal(MAIN_SECTION_RULE_WIDTH)
+ .color(MAIN_RULE)
+ .thickness(theme.spacing().accentRuleWidth())
+ .margin(new DocumentInsets(1, 0, 4, 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(theme.typography().bodyLineSpacing())
+ .align(TextAlign.LEFT)
+ .margin(new DocumentInsets(4, 0, 12, 0))
+ .rich(rich -> MarkdownInline.appendTrimmed(rich, body, base)));
+ }
+
+ private void addExperienceEntries(SectionBuilder section,
+ CvSection expSection) {
+ if (!(expSection instanceof EntriesSection entries)) {
+ return;
+ }
+ DocumentTextStyle positionStyle = mainEntryTitleStyle();
+ DocumentTextStyle dateStyle = mainEntryDateStyle();
+ 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)
+ .lineSpacing(1.15)
+ .margin(DocumentInsets.top(5)));
+ if (!entry.date().isBlank()) {
+ section.addParagraph(paragraph -> paragraph
+ .text(TextOrnaments.spacedUpper(
+ MarkdownInline.plainText(entry.date())))
+ .textStyle(dateStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero()));
+ }
+ if (!entry.body().isBlank()) {
+ String description = entry.body();
+ section.addParagraph(paragraph -> paragraph
+ .textStyle(bodyStyle)
+ .lineSpacing(theme.typography().bodyLineSpacing())
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.top(1))
+ .rich(rich -> MarkdownInline.appendTrimmed(rich,
+ description, bodyStyle)));
+ }
+ }
+ }
+
+ // -- Style factories ----------------------------------------------
+
+ private DocumentTextStyle nameStyle() {
+ return CvTextStyles.of(theme.typography().headlineFont(),
+ theme.typography().sizeHeadline(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle subtitleStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeContact(),
+ DocumentTextDecoration.BOLD,
+ accent);
+ }
+
+ private DocumentTextStyle sidebarHeaderStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ 8.0,
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle sidebarBodyStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeContact(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().muted());
+ }
+
+ private DocumentTextStyle sidebarEntryTitleStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ 7.6,
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle sidebarEntrySubtitleStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeContact(),
+ DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ }
+
+ private DocumentTextStyle sidebarEntryDateStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ 7.2,
+ DocumentTextDecoration.DEFAULT,
+ accent);
+ }
+
+ private DocumentTextStyle sidebarSkillStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeContact(),
+ 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 mainEntryDateStyle() {
+ return CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeEntryDate(),
+ DocumentTextDecoration.BOLD,
+ accent);
+ }
+ }
+
+ // -- Static helpers ----------------------------------------------------
+
+ private static boolean hasContent(CvSection section) {
+ return section != null && SectionLookup.hasContent(section);
+ }
+
+ private static String initials(CvName name) {
+ if (name == null) {
+ return "";
+ }
+ StringBuilder builder = new StringBuilder();
+ appendInitial(builder, name.first());
+ appendInitial(builder, name.last());
+ return builder.toString();
+ }
+
+ private static void appendInitial(StringBuilder builder, String value) {
+ if (builder.length() >= 2 || value == null) {
+ return;
+ }
+ String trimmed = value.trim();
+ if (!trimmed.isEmpty() && Character.isLetter(trimmed.charAt(0))) {
+ builder.append(Character.toUpperCase(trimmed.charAt(0)));
+ }
+ }
+
+ 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("github")) {
+ return "github.png";
+ }
+ if (normalized.contains("linkedin")) {
+ return "linkedin.png";
+ }
+ return "linkedin.png";
+ }
+
+ private static DocumentImageData contactIcon(String iconFile) {
+ return DocumentImageData.fromBytes(
+ CONTACT_ICON_CACHE.computeIfAbsent(CONTACT_ICON_ROOT + iconFile,
+ MonogramSidebar::readIconBytes));
+ }
+
+ private static byte[] readIconBytes(String resourcePath) {
+ try (InputStream input = MonogramSidebar.class
+ .getResourceAsStream(resourcePath)) {
+ if (input == null) {
+ throw new IllegalStateException(
+ "Missing monogram sidebar icon: " + resourcePath);
+ }
+ return input.readAllBytes();
+ } catch (IOException e) {
+ throw new UncheckedIOException(
+ "Failed to read monogram sidebar icon: " + 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;
+ }
+
+ 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 d60c55f1..0161e10b 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
@@ -7,22 +7,43 @@
/**
* Colour tokens for a {@link CvTheme}.
*
- * @param ink primary text colour — headlines, body, entry titles
- * @param muted secondary text colour — italic subtitles (employer,
- * institution)
- * @param rule thin horizontal rules + the contact-line pipe glyph
- * @param banner pale fill behind section title banners
+ * @param ink primary text colour — headlines, body, entry titles
+ * @param muted secondary text colour — italic subtitles (employer,
+ * institution)
+ * @param rule thin horizontal rules + the contact-line pipe glyph
+ * @param banner pale fill behind section title banners
+ * @param mainFill main content area background fill — used by presets
+ * with a two-column layout (sidebar + main) to paint
+ * the right column on every page. Defaults to
+ * {@link DocumentColor#WHITE} for palettes that do not
+ * need a custom main-area fill.
*/
public record CvPalette(DocumentColor ink,
DocumentColor muted,
DocumentColor rule,
- DocumentColor banner) {
+ DocumentColor banner,
+ DocumentColor mainFill) {
public CvPalette {
Objects.requireNonNull(ink, "ink");
Objects.requireNonNull(muted, "muted");
Objects.requireNonNull(rule, "rule");
Objects.requireNonNull(banner, "banner");
+ Objects.requireNonNull(mainFill, "mainFill");
+ }
+
+ /**
+ * Backward-compatible 4-arg constructor that defaults
+ * {@code mainFill} to {@link DocumentColor#WHITE}. Retained so
+ * factories defined before the {@code mainFill} token landed keep
+ * compiling and behaving identically (none of them rendered a
+ * coloured main area).
+ */
+ public CvPalette(DocumentColor ink,
+ DocumentColor muted,
+ DocumentColor rule,
+ DocumentColor banner) {
+ this(ink, muted, rule, banner, DocumentColor.WHITE);
}
/**
@@ -123,6 +144,22 @@ public static CvPalette editorialBlue() {
DocumentColor.rgb(193, 201, 211));
}
+ /**
+ * Monogram Sidebar palette ported from the v1
+ * {@code MonogramSidebarCvTemplateComposer}: navy-slate body ink,
+ * soft grey for sidebar metadata, mid-grey sidebar rule, and the
+ * pale teal-grey sidebar background fill. The dark monogram ring
+ * and muted-gold accent stay preset-local because no other v2
+ * preset shares them today.
+ */
+ public static CvPalette monogramSidebar() {
+ return new CvPalette(
+ DocumentColor.rgb(37, 45, 58), // ink — V1 INK
+ DocumentColor.rgb(112, 119, 125), // muted — V1 SOFT
+ DocumentColor.rgb(138, 146, 148), // rule — V1 SIDEBAR_RULE
+ DocumentColor.rgb(226, 235, 235)); // banner — V1 SIDEBAR_BG
+ }
+
/**
* Engineering Resume palette ported from the v1
* {@code TechLeadCvTemplateComposer}: body-slate ink, muted 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 ca44a98e..05ec14d5 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 Monogram Sidebar preset: zero page-flow gap so
+ * the pale sidebar fill bleeds edge-to-edge against the
+ * RECOMMENDED_MARGIN=0 page bounds. Banner tokens are unused —
+ * the preset draws its visual chrome inline (monogram badge, rules,
+ * filler shape).
+ */
+ public static CvSpacing monogramSidebar() {
+ 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 Engineering Resume preset: navy command header
* + 2-column body (navy skill rail / white evidence cards), with
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 81649c11..9009e57b 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,23 @@ public static CvTheme editorialBlue() {
CvDecoration.classic());
}
+ /**
+ * The "Monogram Sidebar" look — Crimson Text display + Lato body,
+ * pale teal-grey sidebar with a dark monogram ring badge holding
+ * the subject's initials, centered icon-driven contact stack,
+ * education and expertise blocks, plus a two-line spaced-caps
+ * headline and main career narrative on the right. Visual
+ * signature ported from the v1
+ * {@code MonogramSidebarCvTemplateComposer}.
+ */
+ public static CvTheme monogramSidebar() {
+ return new CvTheme(
+ CvPalette.monogramSidebar(),
+ CvTypography.monogramSidebar(),
+ CvSpacing.monogramSidebar(),
+ CvDecoration.classic());
+ }
+
/**
* The "Engineering Resume" look — Barlow display + Lato body, deep
* navy command header with cyan-green contact links, dark 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 a35cc4ce..3b34292d 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,27 @@ public static CvTypography editorialBlue() {
1.45); // line spacing
}
+ /**
+ * Crimson Text headline + Lato body scale ported from the v1
+ * {@code MonogramSidebarCvTemplateComposer}: 30pt spaced-caps
+ * name in the main column, 9pt section title rule, and 7.2-7.8pt
+ * body sizes across the pale-teal sidebar and the right column.
+ * The PT-Serif monogram font is preset-local because no other v2
+ * preset uses it for a circle-ring badge.
+ */
+ public static CvTypography monogramSidebar() {
+ return new CvTypography(
+ FontName.CRIMSON_TEXT, FontName.LATO,
+ 30.0, // headline (spaced-caps name lines)
+ 7.4, // contact (sidebar contact stack)
+ 9.0, // banner / main section title
+ 7.8, // entry title (experience position)
+ 7.4, // entry date (accent gold)
+ 7.4, // entry subtitle
+ 7.5, // body (profile + experience description)
+ 1.35); // line spacing
+ }
+
/**
* Barlow headline + Lato body scale ported from the v1
* {@code TechLeadCvTemplateComposer}: 24.5pt UPPERCASE masthead,
diff --git a/src/main/resources/templates/cv/monogram-sidebar/icons/email.png b/src/main/resources/templates/cv/monogram-sidebar/icons/email.png
index d5680b73..c5f270a5 100644
Binary files a/src/main/resources/templates/cv/monogram-sidebar/icons/email.png and b/src/main/resources/templates/cv/monogram-sidebar/icons/email.png differ
diff --git a/src/main/resources/templates/cv/monogram-sidebar/icons/github.png b/src/main/resources/templates/cv/monogram-sidebar/icons/github.png
index ddc1a210..db77f96f 100644
Binary files a/src/main/resources/templates/cv/monogram-sidebar/icons/github.png and b/src/main/resources/templates/cv/monogram-sidebar/icons/github.png differ
diff --git a/src/main/resources/templates/cv/monogram-sidebar/icons/linkedin.png b/src/main/resources/templates/cv/monogram-sidebar/icons/linkedin.png
index 5b2612c7..40b9aff2 100644
Binary files a/src/main/resources/templates/cv/monogram-sidebar/icons/linkedin.png and b/src/main/resources/templates/cv/monogram-sidebar/icons/linkedin.png differ
diff --git a/src/main/resources/templates/cv/monogram-sidebar/icons/location.png b/src/main/resources/templates/cv/monogram-sidebar/icons/location.png
index 48d2152f..4d11dd9a 100644
Binary files a/src/main/resources/templates/cv/monogram-sidebar/icons/location.png and b/src/main/resources/templates/cv/monogram-sidebar/icons/location.png differ
diff --git a/src/main/resources/templates/cv/monogram-sidebar/icons/phone.png b/src/main/resources/templates/cv/monogram-sidebar/icons/phone.png
index a6cf509e..d1c7ef1f 100644
Binary files a/src/main/resources/templates/cv/monogram-sidebar/icons/phone.png and b/src/main/resources/templates/cv/monogram-sidebar/icons/phone.png differ
diff --git a/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java b/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java
index ea600441..6cb8c6ee 100644
--- a/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java
+++ b/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java
@@ -137,6 +137,125 @@ void emptyDocumentEmitsNoBackgroundEvenWhenColorIsSet() {
}
}
+ // -- New multi-rect pageBackgrounds(List) API ------------------------
+
+ @Test
+ void pageBackgroundsListEmitsOneFragmentPerFillPerPage() {
+ DocumentColor sidebar = DocumentColor.of(Color.LIGHT_GRAY);
+ DocumentColor main = DocumentColor.WHITE;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(400, 300)
+ .margin(DocumentInsets.zero())
+ .pageBackgrounds(List.of(
+ PageBackgroundFill.leftColumn(0.33, sidebar),
+ PageBackgroundFill.rightColumn(0.67, main)))
+ .create()) {
+
+ session.add(new SpacerNode("Block", 200, 80,
+ DocumentInsets.zero(), DocumentInsets.zero()));
+ LayoutGraph graph = session.layoutGraph();
+
+ assertThat(graph.totalPages()).isEqualTo(1);
+ List bg = graph.fragments().stream()
+ .filter(this::isPageBackgroundFragment)
+ .toList();
+ assertThat(bg).hasSize(2);
+ // Painted in list order at z=0
+ assertThat(bg.get(0).x()).isCloseTo(0.0, within(EPS));
+ assertThat(bg.get(0).width()).isCloseTo(400.0 * 0.33, within(EPS));
+ assertThat(bg.get(1).x()).isCloseTo(400.0 * 0.33, within(EPS));
+ assertThat(bg.get(1).width()).isCloseTo(400.0 * 0.67, within(EPS));
+ for (PlacedFragment f : bg) {
+ assertThat(f.y()).isCloseTo(0.0, within(EPS));
+ assertThat(f.height()).isCloseTo(300.0, within(EPS));
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ void pageBackgroundFullPageMatchesLegacySingleColor() {
+ DocumentColor cream = DocumentColor.of(new Color(250, 245, 235));
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(400, 300)
+ .margin(DocumentInsets.zero())
+ .pageBackgrounds(List.of(PageBackgroundFill.fullPage(cream)))
+ .create()) {
+
+ session.add(new SpacerNode("Block", 200, 80,
+ DocumentInsets.zero(), DocumentInsets.zero()));
+ LayoutGraph graph = session.layoutGraph();
+
+ List bg = graph.fragments().stream()
+ .filter(this::isPageBackgroundFragment)
+ .toList();
+ assertThat(bg).hasSize(1);
+ assertThat(bg.get(0).x()).isCloseTo(0.0, within(EPS));
+ assertThat(bg.get(0).y()).isCloseTo(0.0, within(EPS));
+ assertThat(bg.get(0).width()).isCloseTo(400.0, within(EPS));
+ assertThat(bg.get(0).height()).isCloseTo(300.0, within(EPS));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ void pageBackgroundsEmptyListClearsBackground() {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(400, 300)
+ .margin(DocumentInsets.zero())
+ .pageBackground(DocumentColor.of(Color.RED))
+ .pageBackgrounds(List.of())
+ .create()) {
+
+ session.add(new SpacerNode("Block", 200, 80,
+ DocumentInsets.zero(), DocumentInsets.zero()));
+ LayoutGraph graph = session.layoutGraph();
+
+ assertThat(graph.fragments())
+ .noneMatch(this::isPageBackgroundFragment);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ void pageBackgroundFillRejectsOutOfRangeRatios() {
+ DocumentColor c = DocumentColor.WHITE;
+ org.junit.jupiter.api.Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new PageBackgroundFill(-0.1, 0, 0.5, 1, c));
+ org.junit.jupiter.api.Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new PageBackgroundFill(0, 1.1, 0.5, 1, c));
+ org.junit.jupiter.api.Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new PageBackgroundFill(0, 0, 0, 1, c));
+ org.junit.jupiter.api.Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new PageBackgroundFill(0, 0, 1.1, 1, c));
+ org.junit.jupiter.api.Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new PageBackgroundFill(0, 0, 0.5, 0, c));
+ org.junit.jupiter.api.Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new PageBackgroundFill(0, 0, 0.5, 1, null));
+ }
+
+ @Test
+ void pageBackgroundFillFactoryHelpersComputeRectsCorrectly() {
+ DocumentColor c = DocumentColor.WHITE;
+ assertThat(PageBackgroundFill.fullPage(c))
+ .isEqualTo(new PageBackgroundFill(0.0, 0.0, 1.0, 1.0, c));
+ assertThat(PageBackgroundFill.leftColumn(0.3, c))
+ .isEqualTo(new PageBackgroundFill(0.0, 0.0, 0.3, 1.0, c));
+ assertThat(PageBackgroundFill.rightColumn(0.4, c))
+ .isEqualTo(new PageBackgroundFill(0.6, 0.0, 0.4, 1.0, c));
+ assertThat(PageBackgroundFill.column(0.25, 0.5, c))
+ .isEqualTo(new PageBackgroundFill(0.25, 0.0, 0.5, 1.0, c));
+ }
+
private boolean isPageBackgroundFragment(PlacedFragment fragment) {
return fragment.payload() instanceof ShapeFragmentPayload payload
&& payload.fillColor() != null
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 d761360a..511a4d0e 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
@@ -123,7 +123,10 @@ private static Stream presets() {
(Supplier>) TimelineMinimal::create),
Arguments.of("engineering_resume",
EngineeringResume.RECOMMENDED_MARGIN,
- (Supplier>) EngineeringResume::create));
+ (Supplier>) EngineeringResume::create),
+ Arguments.of("monogram_sidebar",
+ MonogramSidebar.RECOMMENDED_MARGIN,
+ (Supplier>) MonogramSidebar::create));
}
/**
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MonogramSidebarSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MonogramSidebarSmokeTest.java
new file mode 100644
index 00000000..72e581f0
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MonogramSidebarSmokeTest.java
@@ -0,0 +1,127 @@
+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.layout.LayoutGraph;
+import com.demcha.compose.document.layout.PlacedFragment;
+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 Monogram Sidebar preset. Covers the badge
+ * initials extraction, contact stack icon resolution, and the
+ * Profile + Experience main-column composition.
+ */
+class MonogramSidebarSmokeTest {
+
+ @Test
+ void exposes_stable_identity() {
+ DocumentTemplate template = MonogramSidebar.create();
+ assertThat(template.id()).isEqualTo("monogram-sidebar");
+ assertThat(template.displayName()).isEqualTo("Monogram Sidebar");
+ }
+
+ @Test
+ void default_factory_renders_full_document() throws Exception {
+ renderAndAssertNonEmpty(MonogramSidebar.create(), fullDocument());
+ }
+
+ @Test
+ void custom_theme_factory_renders() throws Exception {
+ renderAndAssertNonEmpty(MonogramSidebar.create(CvTheme.monogramSidebar()),
+ fullDocument());
+ }
+
+ /**
+ * Asserts the two-column page chrome is emitted via
+ * {@code pageBackgrounds(List)} — protects the
+ * engine contract that both the sidebar fill and main fill are
+ * splice-painted on every page, so multi-page content
+ * automatically inherits the same visual structure without any
+ * preset-side filler logic.
+ */
+ @Test
+ void emits_two_column_page_chrome_on_every_page() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(595, 842)
+ .margin(DocumentInsets.zero())
+ .create()) {
+ MonogramSidebar.create().compose(session, fullDocument());
+ LayoutGraph layout = session.layoutGraph();
+ assertThat(layout.totalPages()).isGreaterThanOrEqualTo(1);
+ for (int page = 0; page < layout.totalPages(); page++) {
+ int pageBgFragments = 0;
+ for (PlacedFragment frag : layout.fragments()) {
+ if (frag.pageIndex() == page
+ && frag.path().startsWith("@page-background[")) {
+ pageBgFragments++;
+ }
+ }
+ assertThat(pageBgFragments)
+ .as("page %d must paint both sidebar + main fills",
+ page)
+ .isEqualTo(2);
+ }
+ }
+ }
+
+ 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/monogram_sidebar-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png
new file mode 100644
index 00000000..3540def7
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png differ