diff --git a/.gitignore b/.gitignore index 6aa90592..d12ce4a8 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,8 @@ build/ docs/private/ .llm-wiki/ + +# Visual regression test diagnostic artifacts — produced when a parity +# test fails, useful for review locally, not part of repo state. +**/visual-baselines/**/*.actual.png +**/visual-baselines/**/*.diff.png diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionDispatcher.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionDispatcher.java index 870f3abe..28a7d8dc 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionDispatcher.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionDispatcher.java @@ -36,12 +36,28 @@ public static void renderBody(SectionBuilder host, CvSection section, CvTheme th if (section instanceof ParagraphSection p) { ParagraphRenderer.render(host, p.body(), theme); } else if (section instanceof RowsSection r) { - for (CvRow row : r.rows()) { - RowRenderer.render(host, row, r.style(), theme); + // Multi-line stacked rows (Projects-style) get a spacer + // between items so consecutive entries don't visually + // collapse into a wall of text. Single-line styles (PLAIN, + // BULLETED) already breathe via paragraphMarginTop. + boolean stackedNeedsSeparator = + r.style() == com.demcha.compose.document.templates.cv.v2.data.RowStyle.BULLETED_STACKED; + for (int i = 0; i < r.rows().size(); i++) { + if (i > 0 && stackedNeedsSeparator) { + host.spacer(0, theme.spacing().entrySeparation()); + } + RowRenderer.render(host, r.rows().get(i), r.style(), theme); } } else if (section instanceof EntriesSection e) { - for (CvEntry entry : e.entries()) { - EntryRenderer.render(host, entry, theme); + // Timeline entries (Education, Experience) get a spacer + // between items — each entry is a multi-line block + // (title + subtitle + body) and without a gap the + // boundary between consecutive entries becomes invisible. + for (int i = 0; i < e.entries().size(); i++) { + if (i > 0) { + host.spacer(0, theme.spacing().entrySeparation()); + } + EntryRenderer.render(host, e.entries().get(i), theme); } } else { throw new IllegalStateException( diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java index b4690452..1e1346b6 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java @@ -2,22 +2,17 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; -import com.demcha.compose.document.dsl.SectionBuilder; -import com.demcha.compose.document.node.DocumentLinkOptions; -import com.demcha.compose.document.node.TextAlign; import com.demcha.compose.document.style.DocumentColor; -import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentTextDecoration; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.templates.api.DocumentTemplate; import com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher; -import com.demcha.compose.document.templates.cv.v2.data.CvContact; 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.CvLink; import com.demcha.compose.document.templates.cv.v2.data.CvSection; import com.demcha.compose.document.templates.cv.v2.data.Slot; import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; +import com.demcha.compose.document.templates.cv.v2.widgets.ContactLine; +import com.demcha.compose.document.templates.cv.v2.widgets.Headline; import com.demcha.compose.document.templates.cv.v2.widgets.SectionHeader; import com.demcha.compose.font.FontName; @@ -120,91 +115,60 @@ public void compose(DocumentSession document, CvDocument doc) { Objects.requireNonNull(document, "document"); Objects.requireNonNull(doc, "doc"); - PageFlowBuilder pageFlow = document.dsl() - .pageFlow() - .name("CvV2ModernRoot") - .spacing(theme.spacing().pageFlowSpacing()) - .addSection("Header", section -> - renderHeader(section, doc.identity())) - .addSection("Contact", section -> - renderContact(section, doc.identity())); - - // Single-column preset — only MAIN slot. - List sections = doc.sectionsIn(Slot.MAIN); - for (int i = 0; i < sections.size(); i++) { - final CvSection sec = sections.get(i); - final int idx = i; - pageFlow.addSection("Title_" + idx, host -> - SectionHeader.flat(host, sec.title(), SECTION_TITLE_COLOR, theme)); - pageFlow.addSection("Body_" + idx, host -> - SectionDispatcher.renderBody(host, sec, theme)); - } - - pageFlow.build(); - } - - /** - * Big slate-blue display name, right-aligned. No spaced caps. - */ - private void renderHeader(SectionBuilder section, CvIdentity identity) { + // Preset-specific styles — built once, fed to widgets. + // These cannot live in the theme because their colours are + // unique to this preset (no other preset uses slate-blue + // for the name or royal-blue for links). DocumentTextStyle nameStyle = DocumentTextStyle.builder() .fontName(FontName.HELVETICA_BOLD) .size(theme.typography().sizeHeadline()) .decoration(DocumentTextDecoration.BOLD) .color(NAME_COLOR) .build(); - - section.padding(DocumentInsets.zero()) - .addParagraph(p -> p - .text(identity.name().full()) - .textStyle(nameStyle) - .align(TextAlign.RIGHT) - .margin(DocumentInsets.zero())); - } - - /** - * Right-aligned pipe-separated contact + links. Links rendered - * underlined in royal blue; contact strings in body grey. - */ - private void renderContact(SectionBuilder section, CvIdentity identity) { - DocumentTextStyle bodyStyle = DocumentTextStyle.builder() + DocumentTextStyle contactBodyStyle = DocumentTextStyle.builder() .fontName(FontName.HELVETICA) .size(theme.typography().sizeContact()) .color(theme.palette().ink()) .build(); - DocumentTextStyle linkStyle = DocumentTextStyle.builder() + DocumentTextStyle contactLinkStyle = DocumentTextStyle.builder() .fontName(FontName.HELVETICA) .size(theme.typography().sizeContact()) .decoration(DocumentTextDecoration.UNDERLINE) .color(LINK_COLOR) .build(); - DocumentTextStyle separatorStyle = DocumentTextStyle.builder() + DocumentTextStyle contactSeparatorStyle = DocumentTextStyle.builder() .fontName(FontName.HELVETICA) .size(theme.typography().sizeContact()) .color(theme.palette().rule()) .build(); - CvContact c = identity.contact(); - section.padding(theme.spacing().contactPadding()) - .accentBottom(theme.palette().rule(), - theme.spacing().accentRuleWidth()) - .addParagraph(p -> p - .textStyle(bodyStyle) - .align(TextAlign.RIGHT) - .margin(DocumentInsets.zero()) - .rich(rich -> { - rich.style(c.address(), bodyStyle); - rich.style(" | ", separatorStyle); - rich.style(c.phone(), bodyStyle); - rich.style(" | ", separatorStyle); - rich.link(c.email(), - new DocumentLinkOptions("mailto:" + c.email())); - for (CvLink l : identity.links()) { - rich.style(" | ", separatorStyle); - rich.style(l.label(), linkStyle); - } - })); - } + PageFlowBuilder pageFlow = document.dsl() + .pageFlow() + .name("CvV2ModernRoot") + .spacing(theme.spacing().pageFlowSpacing()) + .addSection("Header", section -> + Headline.rightAligned(section, doc.identity().name(), + theme, nameStyle)) + .addSection("Contact", section -> { + section.accentBottom(theme.palette().rule(), + theme.spacing().accentRuleWidth()); + ContactLine.twoRowRightAligned(section, doc.identity(), + theme, contactBodyStyle, contactLinkStyle, + contactSeparatorStyle); + }); + // Single-column preset — only MAIN slot. + List sections = doc.sectionsIn(Slot.MAIN); + for (int i = 0; i < sections.size(); i++) { + final CvSection sec = sections.get(i); + final int idx = i; + pageFlow.addSection("Title_" + idx, host -> + SectionHeader.flat(host, sec.title(), SECTION_TITLE_COLOR, theme)); + pageFlow.addSection("Body_" + idx, host -> + SectionDispatcher.renderBody(host, sec, theme)); + } + + pageFlow.build(); + } } } 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 10881856..97288def 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 @@ -30,6 +30,15 @@ * title column and date column * @param entryTitleWeight flex weight of the entry title column * @param entryDateWeight flex weight of the entry date column + * @param entrySeparation vertical spacer (in points) inserted + * between consecutive + * entries in an {@code EntriesSection} + * and between consecutive rows of a + * {@code RowsSection} in + * {@code BULLETED_STACKED} style — so + * the reader can tell where one entry + * ends and the next begins. Not applied + * before the first entry in a section. */ public record CvSpacing( double pageFlowSpacing, @@ -44,7 +53,8 @@ public record CvSpacing( double paragraphMarginTop, double entryHeaderRowSpacing, double entryTitleWeight, - double entryDateWeight) { + double entryDateWeight, + double entrySeparation) { public CvSpacing { Objects.requireNonNull(sectionBodyPadding, "sectionBodyPadding"); @@ -53,6 +63,39 @@ public record CvSpacing( Objects.requireNonNull(bannerMargin, "bannerMargin"); } + /** + * Backward-compatible 13-arg constructor — fills + * {@link #entrySeparation} with the canonical default + * ({@code 6.0}) so callers built before this field was added keep + * compiling and rendering the same density as before, plus an + * automatic improvement: a small gap between consecutive entries. + * + * @deprecated since {@code entrySeparation} was introduced. + * Supply it explicitly via the 14-arg canonical + * constructor or via {@link #classic()} / + * {@link #modernProfessional()}. + */ + @Deprecated + public CvSpacing(double pageFlowSpacing, + double sectionBodySpacing, + DocumentInsets sectionBodyPadding, + DocumentInsets headlinePadding, + DocumentInsets contactPadding, + double bannerCornerRadius, + double bannerInnerPadding, + DocumentInsets bannerMargin, + double accentRuleWidth, + double paragraphMarginTop, + double entryHeaderRowSpacing, + double entryTitleWeight, + double entryDateWeight) { + this(pageFlowSpacing, sectionBodySpacing, sectionBodyPadding, + headlinePadding, contactPadding, bannerCornerRadius, + bannerInnerPadding, bannerMargin, accentRuleWidth, + paragraphMarginTop, entryHeaderRowSpacing, + entryTitleWeight, entryDateWeight, 3.0); + } + /** * The classic spacing used by the original Boxed Sections preset. */ @@ -70,7 +113,8 @@ public static CvSpacing classic() { 2.0, // paragraphMarginTop 8.0, // entryHeaderRowSpacing 1.0, // entryTitleWeight - 0.45); // entryDateWeight + 0.45, // entryDateWeight + 3.0); // entrySeparation } /** @@ -84,7 +128,7 @@ public static CvSpacing modernProfessional() { return new CvSpacing( 4, // pageFlowSpacing 3, // sectionBodySpacing - new DocumentInsets(2, 0, 0, 0), // sectionBodyPadding + new DocumentInsets(2, 0, 0, 12), // sectionBodyPadding (left=12 → body indents from blue section title) new DocumentInsets(0, 0, 0, 0), // headlinePadding new DocumentInsets(0, 0, 6, 0), // contactPadding 0.0, // bannerCornerRadius (unused) @@ -94,6 +138,7 @@ public static CvSpacing modernProfessional() { 2.0, // paragraphMarginTop 10.0, // entryHeaderRowSpacing 1.0, // entryTitleWeight - 0.45); // entryDateWeight + 0.45, // entryDateWeight + 2.5); // entrySeparation } } diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ContactLine.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ContactLine.java index 467d9283..aa7b37eb 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ContactLine.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ContactLine.java @@ -50,13 +50,79 @@ public static void centered(SectionBuilder host, CvIdentity identity, CvTheme th /** * Right-aligned pipe-separated contact row. Order: address → * phone → email → links — address-first reads as the location - * label authors usually put first in this style. Visual - * signature of {@code ModernProfessional}. + * label authors usually put first in this style. */ public static void rightAligned(SectionBuilder host, CvIdentity identity, CvTheme theme) { render(host, identity, theme, TextAlign.RIGHT, Order.ADDRESS_FIRST); } + /** + * Right-aligned contact split across two stacked + * lines with explicit text-style overrides. Used by + * {@code ModernProfessional} for its 2-row contact header where + * email + links sit on a dedicated second row and use a custom + * link colour the theme doesn't carry. + * + *

Row layout:

+ * + * + *

Email and every {@link CvLink} are rendered as proper PDF + * hyperlinks (mailto: for the email, the link's URL for each + * label) — not just styled text.

+ * + * @param bodyStyleOverride style for the non-link items + * (address, phone); {@code null} → + * {@code theme.contactStyle()} + * @param linkStyleOverride style for email + every link; + * {@code null} → + * {@code theme.contactStyle()} + * @param separatorStyleOverride style for the {@code " | "} pipe + * separator; {@code null} → + * {@code theme.contactSeparatorStyle()} + */ + public static void twoRowRightAligned(SectionBuilder host, CvIdentity identity, + CvTheme theme, + DocumentTextStyle bodyStyleOverride, + DocumentTextStyle linkStyleOverride, + DocumentTextStyle separatorStyleOverride) { + DocumentTextStyle bodyStyle = bodyStyleOverride != null + ? bodyStyleOverride : theme.contactStyle(); + DocumentTextStyle linkStyle = linkStyleOverride != null + ? linkStyleOverride : theme.contactStyle(); + DocumentTextStyle separatorStyle = separatorStyleOverride != null + ? separatorStyleOverride : theme.contactSeparatorStyle(); + + CvContact c = identity.contact(); + host.spacing(0).padding(theme.spacing().contactPadding()) + // Row 1 — address + phone. + .addParagraph(p -> p + .textStyle(bodyStyle) + .align(TextAlign.RIGHT) + .margin(DocumentInsets.zero()) + .rich(rich -> { + rich.style(c.address(), bodyStyle); + rich.style(" | ", separatorStyle); + rich.style(c.phone(), bodyStyle); + })) + // Row 2 — email + every link, all clickable. + .addParagraph(p -> p + .textStyle(bodyStyle) + .align(TextAlign.RIGHT) + .margin(DocumentInsets.zero()) + .rich(rich -> { + rich.with(c.email(), linkStyle, + new DocumentLinkOptions("mailto:" + c.email())); + for (CvLink l : identity.links()) { + rich.style(" | ", separatorStyle); + rich.with(l.label(), linkStyle, + new DocumentLinkOptions(l.url())); + } + })); + } + /** * Lower-level entry. Pick the alignment and the field order * explicitly. diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java index 7e4ac350..8a0c622a 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java @@ -49,11 +49,27 @@ public static void spacedCentered(SectionBuilder host, CvName name, CvTheme them } /** - * Right-aligned plain bold headline. Visual signature of - * {@code ModernProfessional}. + * Right-aligned plain headline using the theme's default + * {@link CvTheme#headlineStyle() headline style}. Visual + * signature of corporate / modern presets that don't need a + * custom display colour. */ public static void rightAligned(SectionBuilder host, CvName name, CvTheme theme) { - render(host, name, theme, TextAlign.RIGHT, false); + rightAligned(host, name, theme, null); + } + + /** + * Right-aligned headline with an explicit {@link DocumentTextStyle} + * override — the preset hands the widget exactly the font / size + * / colour it wants. Used by {@code ModernProfessional} to apply + * its preset-specific slate-blue display colour. + * + * @param styleOverride text style for the headline; pass {@code null} + * to fall back to {@code theme.headlineStyle()} + */ + public static void rightAligned(SectionBuilder host, CvName name, CvTheme theme, + DocumentTextStyle styleOverride) { + render(host, name, theme, TextAlign.RIGHT, false, styleOverride); } /** @@ -71,7 +87,23 @@ public static void rightAligned(SectionBuilder host, CvName name, CvTheme theme) */ public static void render(SectionBuilder host, CvName name, CvTheme theme, TextAlign alignment, boolean spacedCaps) { - DocumentTextStyle style = theme.headlineStyle(); + render(host, name, theme, alignment, spacedCaps, null); + } + + /** + * Lower-level entry with explicit style override. Same shape as + * the 5-arg {@link #render(SectionBuilder, CvName, CvTheme, TextAlign, boolean)} + * but lets the caller supply a custom {@link DocumentTextStyle}. + * + * @param styleOverride explicit style; pass {@code null} to fall + * back to {@code theme.headlineStyle()} + */ + public static void render(SectionBuilder host, CvName name, CvTheme theme, + TextAlign alignment, boolean spacedCaps, + DocumentTextStyle styleOverride) { + DocumentTextStyle style = styleOverride != null + ? styleOverride + : theme.headlineStyle(); String text = spacedCaps ? TextOrnaments.spacedUpper(name.full()) : name.full(); diff --git a/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png index 5fd9a913..5b7f57fd 100644 Binary files a/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png and b/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png index e618177b..d73e1232 100644 Binary files a/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png and b/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png index 212c150b..66fd6ba9 100644 Binary files a/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png and b/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png index 9ed44abe..e266c334 100644 Binary files a/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png and b/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png index c030b73f..a9c0a81a 100644 Binary files a/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png and b/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png index ea36dfc5..d48cbc50 100644 Binary files a/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png and b/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png differ