Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<CvSection> 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<CvSection> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <strong>between</strong> 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,
Expand All @@ -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");
Expand All @@ -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.
*/
Expand All @@ -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
}

/**
Expand All @@ -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)
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>two stacked
* lines</strong> 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.
*
* <p>Row layout:</p>
* <ul>
* <li><strong>Row 1</strong> — address {@code |} phone</li>
* <li><strong>Row 2</strong> — email {@code |} link₁ {@code |} link₂ … (all clickable)</li>
* </ul>
*
* <p>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.</p>
*
* @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.
Expand Down
Loading