diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvMonogramSidebarExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvMonogramSidebarExample.java new file mode 100644 index 00000000..a50ea1f5 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvMonogramSidebarExample.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.MonogramSidebar; +import com.demcha.examples.support.ExampleDataFactory; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Renders the v2 Monogram Sidebar CV preset against the shared + * grouped skills sample data — pale teal-grey sidebar with a dark + * monogram ring badge, centered icon-driven contact stack, education + * and expertise blocks, plus a two-line spaced-caps headline and the + * main career narrative on the right. + * + *

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

+ */ +public final class CvMonogramSidebarExample { + + private CvMonogramSidebarExample() { + } + + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare( + "templates/cv", "cv-monogram-sidebar-v2.pdf"); + CvDocument doc = ExampleDataFactory.sampleCvDocumentV2(); + DocumentTemplate template = MonogramSidebar.create(); + + float m = (float) MonogramSidebar.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/GraphCompose.java b/src/main/java/com/demcha/compose/GraphCompose.java index 5f33b5c5..eaa03d13 100644 --- a/src/main/java/com/demcha/compose/GraphCompose.java +++ b/src/main/java/com/demcha/compose/GraphCompose.java @@ -135,6 +135,7 @@ public static final class DocumentBuilder { private boolean markdown = true; private boolean guideLines; private com.demcha.compose.document.style.DocumentColor pageBackground; + private java.util.List pageBackgrounds; private final List customFontFamilies = new ArrayList<>(); private DocumentBuilder(Path outputFile) { @@ -243,6 +244,22 @@ public DocumentBuilder pageBackground(java.awt.Color color) { : com.demcha.compose.document.style.DocumentColor.of(color)); } + /** + * Configures one or more rectangular page background fills applied + * behind every fragment on every page. See + * {@link com.demcha.compose.document.api.PageBackgroundFill} and + * {@link com.demcha.compose.document.api.DocumentSession#pageBackgrounds} + * for the full semantics. + * + * @param fills ordered fills, or {@code null}/empty to clear + * @return this builder + */ + public DocumentBuilder pageBackgrounds( + java.util.List fills) { + this.pageBackgrounds = fills; + return this; + } + /** * Registers a document-local font family available to text measurement and * PDF rendering. @@ -363,7 +380,14 @@ public DocumentSession create() { List.copyOf(customFontFamilies), markdown, guideLines); - if (pageBackground != null) { + if (pageBackgrounds != null) { + // Explicit pageBackgrounds() call wins — even an empty + // list is an intentional clear that should override any + // earlier pageBackground(color) on the same builder. + if (!pageBackgrounds.isEmpty()) { + session.pageBackgrounds(pageBackgrounds); + } + } else if (pageBackground != null) { session.pageBackground(pageBackground); } return session; diff --git a/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java b/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java index 5ec4fbc4..5c964690 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java @@ -3,53 +3,64 @@ import com.demcha.compose.document.layout.LayoutGraph; import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload; -import com.demcha.compose.document.style.DocumentColor; import java.util.ArrayList; import java.util.List; /** - * Splices a session-wide page background fill into a compiled - * {@link LayoutGraph}. Extracted from {@link DocumentSession} as part - * of the Phase E.3 slim. + * Splices session-wide page background fills into a compiled + * {@link LayoutGraph}. Supports any number of partial-page rectangular + * fills per page (multi-column backgrounds, accent stripes, etc.). * - *

Pages get one extra {@link PlacedFragment} at the bottom of their - * z-order so every other fragment paints on top of the background fill. - * If the session has no page background or the layout has no pages, - * the original graph is returned unchanged.

+ *

Background fragments are placed at the very bottom of the page + * z-order (z=0), so every other fragment paints on top of them. Within + * a single page, fills are emitted in list order — later entries paint + * over earlier entries where they overlap. If the layout has no pages + * or no fills were configured, the original graph is returned + * unchanged.

*/ final class DocumentPageBackgrounds { private DocumentPageBackgrounds() { } /** - * Returns a copy of {@code base} with a page-background fragment - * spliced into every page, or {@code base} unchanged when there is - * nothing to do. + * Multi-rect form. Emits one fragment per fill per page, with + * coordinates computed from the fill's ratios and the canvas size. * - * @param base freshly compiled layout graph - * @param color session-wide background color, or {@code null} + * @param base freshly compiled layout graph + * @param fills ordered list of page-background fills (each painted + * on every page); {@code null}/empty leaves {@code base} + * unchanged * @return a layout graph with background fragments, or {@code base} */ - static LayoutGraph apply(LayoutGraph base, DocumentColor color) { - if (color == null || base.totalPages() == 0) { + static LayoutGraph apply(LayoutGraph base, List fills) { + if (fills == null || fills.isEmpty() || base.totalPages() == 0) { return base; } - List combined = new ArrayList<>(base.fragments().size() + base.totalPages()); + double pageWidth = base.canvas().width(); + double pageHeight = base.canvas().height(); + int extra = base.totalPages() * fills.size(); + List combined = + new ArrayList<>(base.fragments().size() + extra); for (int page = 0; page < base.totalPages(); page++) { - combined.add(new PlacedFragment( - "@page-background[" + page + "]", - 0, - page, - 0.0, - 0.0, - base.canvas().width(), - base.canvas().height(), - com.demcha.compose.engine.components.style.Margin.zero(), - com.demcha.compose.engine.components.style.Padding.zero(), - new ShapeFragmentPayload(color.color(), null, 0.0, null, null, null))); + for (int i = 0; i < fills.size(); i++) { + PageBackgroundFill fill = fills.get(i); + combined.add(new PlacedFragment( + "@page-background[" + page + "][" + i + "]", + 0, + page, + fill.xRatio() * pageWidth, + fill.yRatio() * pageHeight, + fill.widthRatio() * pageWidth, + fill.heightRatio() * pageHeight, + com.demcha.compose.engine.components.style.Margin.zero(), + com.demcha.compose.engine.components.style.Padding.zero(), + new ShapeFragmentPayload(fill.color().color(), + null, 0.0, null, null, null))); + } } combined.addAll(base.fragments()); - return new LayoutGraph(base.canvas(), base.totalPages(), base.nodes(), combined); + return new LayoutGraph(base.canvas(), base.totalPages(), + base.nodes(), combined); } } diff --git a/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/src/main/java/com/demcha/compose/document/api/DocumentSession.java index 798b7ba4..beb33139 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentSession.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentSession.java @@ -75,7 +75,7 @@ public final class DocumentSession implements AutoCloseable { private LayoutCanvas canvas; private boolean markdown; private boolean guideLines; - private DocumentColor pageBackground; + private List pageBackgrounds = List.of(); private final DocumentChromeOptions chromeOptions = new DocumentChromeOptions(); @@ -321,7 +321,9 @@ public DocumentSession guideLines(boolean enabled) { */ public DocumentSession pageBackground(DocumentColor color) { ensureOpen(); - this.pageBackground = color; + this.pageBackgrounds = color == null + ? List.of() + : List.of(PageBackgroundFill.fullPage(color)); invalidate(); return this; } @@ -336,6 +338,30 @@ public DocumentSession pageBackground(Color color) { return pageBackground(color == null ? null : DocumentColor.of(color)); } + /** + * Configures one or more rectangular background fills applied behind + * every fragment on every page. Each fill is defined as a fraction of + * the canvas (see {@link PageBackgroundFill}), so the layout works + * across page sizes. Use this for multi-column page chrome — a pale + * sidebar column plus a white main column, accent stripes, etc. — + * that should repeat automatically when content paginates onto a new + * page. + * + *

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