diff --git a/docs/templates/v2-layered/authoring-presets.md b/docs/templates/v2-layered/authoring-presets.md
index fda329c5..b932bda4 100644
--- a/docs/templates/v2-layered/authoring-presets.md
+++ b/docs/templates/v2-layered/authoring-presets.md
@@ -60,9 +60,11 @@ visual decision you can read like a recipe.
## The widget catalog
-Today, four widget classes live in
+The CV widget classes live in
`com.demcha.compose.document.templates.cv.v2.widgets`. Each has a
-small set of named variants.
+small set of named variants. Generic widgets that can be reused by
+CVs, proposals, invoices, and cover letters live one package higher
+in `com.demcha.compose.document.templates.widgets`.
### `Headline` — top-of-document name
@@ -102,6 +104,24 @@ small set of named variants.
| `SectionHeader.flat(host, title, color, theme)` | Large bold title in a given colour, no panel |
| `SectionHeader.flatSpacedCaps(host, title, color, theme, titleStyle)` | Small left spaced-caps title in a soft colour, no panel |
| `SectionHeader.tickLabel(host, title, theme, color, tickWidth[, titleStyle])` | Short accent tick above compact uppercase label |
+| `SectionHeader.upperRule(host, title, theme, titleStyle, ruleColor, ruleWidth)` | Uppercase label with short rule below |
+| `SectionHeader.spacedCapsRule(host, title, theme, titleStyle, ruleColor, ruleWidth, ruleThickness, ruleMargin)` | Spaced-caps label with short rule below |
+
+### Higher-order CV widgets
+
+| Widget | Visual |
+|---|---|
+| `Masthead.centered(host, identity, theme, style)` | Centred editorial identity block: name, optional title, metadata, link row |
+| `FlowSectionHeader.banner(...)` / `FlowSectionHeader.label(...)` | Page-flow-level headers where the surrounding rules are outside the body section |
+| `ProfileBand.render(...)` | Tinted/ruled summary block with markdown-aware body text |
+| `SectionModule.tick(...)` / `SectionModule.upperRule(...)` | Named rail/card module that combines a section-header variant with caller-supplied body content |
+
+### Shared document widgets
+
+| Widget | Visual |
+|---|---|
+| `TableWidget.fixed(...)` / `TableWidget.grid(...)` | Configurable tables/grids with borders, fills, zebra rows, padding, typography, and column count |
+| `CardWidget.render(...)` | Reusable card/container shell with spacing, padding, fill, stroke, and corner radius |
The separator glyph used by `ContactLine`, the bullet glyph used by
`RowRenderer`, and other character-level choices come from
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md b/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md
index c5c0026e..f4d591de 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md
@@ -363,6 +363,14 @@ change ` | ` to ` · ` or anything else.
| `SectionHeader.flat(host, title, color, theme)` | large bold title in a given colour, no panel | ModernProfessional |
| `SectionHeader.flatSpacedCaps(host, title, color, theme, titleStyle)` | small spaced-caps title in a soft colour, no panel | CenteredHeadline, ClassicSerif |
| `SectionHeader.tickLabel(host, title, theme, color, tickWidth[, titleStyle])` | short accent tick above compact uppercase label | CompactMono |
+| `SectionHeader.upperRule(host, title, theme, titleStyle, ruleColor, ruleWidth)` | uppercase label with short rule below | NordicClean |
+| `SectionHeader.spacedCapsRule(host, title, theme, titleStyle, ruleColor, ruleWidth, ruleThickness, ruleMargin)` | spaced-caps label with short rule below | ClassicSerif |
+
+Use `FlowSectionHeader` when the rule/title treatment belongs to the
+page flow rather than inside an existing body section. `BlueBanner`
+uses its filled-banner variant; `EditorialBlue` uses its ruled-label
+variant. Use `SectionModule` when a rail/card module is simply
+`SectionHeader` plus caller-supplied content.
Note that `flat` and `flatSpacedCaps` take a `DocumentColor`
argument — the section title colour is the preset's signature
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/CvTextStyles.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/CvTextStyles.java
new file mode 100644
index 00000000..e11571f6
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/CvTextStyles.java
@@ -0,0 +1,26 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+
+/**
+ * Small factory for preset-local text styles.
+ */
+public final class CvTextStyles {
+ private CvTextStyles() {
+ }
+
+ public static DocumentTextStyle of(FontName font,
+ double size,
+ DocumentTextDecoration decoration,
+ DocumentColor color) {
+ return DocumentTextStyle.builder()
+ .fontName(font)
+ .size(size)
+ .decoration(decoration)
+ .color(color)
+ .build();
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/EntryCompactRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/EntryCompactRenderer.java
new file mode 100644
index 00000000..ded0eb85
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/EntryCompactRenderer.java
@@ -0,0 +1,178 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.cv.v2.data.CvEntry;
+
+import java.util.Locale;
+
+/**
+ * Compact entry renderer for editorial/card/rail presets where title,
+ * subtitle, and date are packed tighter than the canonical
+ * two-column {@link EntryRenderer}.
+ */
+public final class EntryCompactRenderer {
+ private EntryCompactRenderer() {
+ }
+
+ public static void twoColumnTitleDateBody(SectionBuilder host,
+ CvEntry entry,
+ String rowName,
+ DocumentTextStyle titleStyle,
+ DocumentTextStyle dateStyle,
+ DocumentTextStyle subtitleStyle,
+ DocumentTextStyle bodyStyle,
+ double rowSpacing,
+ double titleWeight,
+ double dateWeight,
+ DocumentInsets subtitleMargin,
+ DocumentInsets bodyMargin,
+ double bodyLineSpacing,
+ boolean uppercaseTitle) {
+ host.addRow(rowName, row -> row
+ .spacing(rowSpacing)
+ .weights(titleWeight, dateWeight)
+ .addSection("Title", titleColumn -> titleColumn
+ .padding(DocumentInsets.zero())
+ .addParagraph(paragraph -> paragraph
+ .text(formattedTitle(entry.title(),
+ uppercaseTitle))
+ .textStyle(titleStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero())))
+ .addSection("Date", dateColumn -> dateColumn
+ .padding(DocumentInsets.zero())
+ .addParagraph(paragraph -> paragraph
+ .text(MarkdownInline.plainText(entry.date()))
+ .textStyle(dateStyle)
+ .align(TextAlign.RIGHT)
+ .margin(DocumentInsets.zero()))));
+
+ if (!entry.subtitle().isBlank()) {
+ host.addParagraph(paragraph -> paragraph
+ .text(MarkdownInline.plainText(entry.subtitle()))
+ .textStyle(subtitleStyle)
+ .align(TextAlign.LEFT)
+ .margin(subtitleMargin));
+ }
+ RichParagraphRenderer.render(host, entry.body(), bodyStyle,
+ bodyLineSpacing, bodyMargin);
+ }
+
+ public static void slashMeta(SectionBuilder host,
+ CvEntry entry,
+ DocumentTextStyle titleStyle,
+ DocumentTextStyle metaStyle,
+ double lineSpacing,
+ DocumentInsets margin) {
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(titleStyle)
+ .lineSpacing(lineSpacing)
+ .align(TextAlign.LEFT)
+ .margin(margin)
+ .rich(rich -> {
+ rich.style(MarkdownInline.plainText(entry.title()),
+ titleStyle);
+ MarkdownInline.appendPlainIfPresent(rich, " / ",
+ entry.subtitle(), metaStyle);
+ MarkdownInline.appendPlainIfPresent(rich, " / ",
+ entry.date(), metaStyle);
+ }));
+ }
+
+ public static void slashSubtitleDate(SectionBuilder host,
+ CvEntry entry,
+ DocumentTextStyle titleStyle,
+ DocumentTextStyle subtitleStyle,
+ DocumentTextStyle dateStyle,
+ double lineSpacing,
+ DocumentInsets margin) {
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(titleStyle)
+ .lineSpacing(lineSpacing)
+ .align(TextAlign.LEFT)
+ .margin(margin)
+ .rich(rich -> {
+ rich.style(MarkdownInline.plainText(entry.title()),
+ titleStyle);
+ MarkdownInline.appendPlainIfPresent(rich, " / ",
+ entry.subtitle(), subtitleStyle);
+ MarkdownInline.appendPlainIfPresent(rich, " / ",
+ entry.date(), dateStyle);
+ }));
+ }
+
+ public static void titleDateBody(SectionBuilder host,
+ CvEntry entry,
+ DocumentTextStyle titleStyle,
+ DocumentTextStyle dateStyle,
+ DocumentTextStyle subtitleStyle,
+ DocumentTextStyle bodyStyle,
+ String datePrefix,
+ double headerLineSpacing,
+ DocumentInsets headerMargin,
+ DocumentInsets subtitleMargin,
+ DocumentInsets bodyMargin,
+ double bodyLineSpacing,
+ boolean uppercaseTitle) {
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(titleStyle)
+ .lineSpacing(headerLineSpacing)
+ .align(TextAlign.LEFT)
+ .margin(headerMargin)
+ .rich(rich -> {
+ rich.style(formattedTitle(entry.title(), uppercaseTitle),
+ titleStyle);
+ if (!entry.date().isBlank()) {
+ rich.style(datePrefix, titleStyle);
+ rich.style(MarkdownInline.plainText(entry.date()),
+ dateStyle);
+ }
+ }));
+ if (!entry.subtitle().isBlank()) {
+ host.addParagraph(paragraph -> paragraph
+ .text(MarkdownInline.plainText(entry.subtitle()))
+ .textStyle(subtitleStyle)
+ .align(TextAlign.LEFT)
+ .margin(subtitleMargin));
+ }
+ RichParagraphRenderer.render(host, entry.body(), bodyStyle,
+ bodyLineSpacing, bodyMargin);
+ }
+
+ public static void titleSubtitleDateBody(SectionBuilder host,
+ CvEntry entry,
+ DocumentTextStyle titleStyle,
+ DocumentTextStyle subtitleStyle,
+ DocumentTextStyle dateStyle,
+ DocumentTextStyle bodyStyle,
+ String subtitlePrefix,
+ String datePrefix,
+ double headerLineSpacing,
+ DocumentInsets headerMargin,
+ DocumentInsets bodyMargin,
+ double bodyLineSpacing) {
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(titleStyle)
+ .lineSpacing(headerLineSpacing)
+ .align(TextAlign.LEFT)
+ .margin(headerMargin)
+ .rich(rich -> {
+ rich.style(MarkdownInline.plainText(entry.title()),
+ titleStyle);
+ MarkdownInline.appendPlainIfPresent(rich, subtitlePrefix,
+ entry.subtitle(), subtitleStyle);
+ MarkdownInline.appendPlainIfPresent(rich, datePrefix,
+ entry.date(), dateStyle);
+ }));
+ RichParagraphRenderer.render(host, entry.body(), bodyStyle,
+ bodyLineSpacing, bodyMargin);
+ }
+
+ private static String formattedTitle(String title, boolean uppercase) {
+ String clean = MarkdownInline.plainText(title);
+ return uppercase ? clean.toUpperCase(Locale.ROOT) : clean;
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/LabelValueRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/LabelValueRenderer.java
new file mode 100644
index 00000000..3f9a7386
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/LabelValueRenderer.java
@@ -0,0 +1,43 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+
+/**
+ * Renders compact "Label: rich markdown value" rows used by CV detail sections.
+ */
+public final class LabelValueRenderer {
+ private LabelValueRenderer() {
+ }
+
+ public static void render(SectionBuilder host,
+ String label,
+ String value,
+ DocumentTextStyle labelStyle,
+ DocumentTextStyle valueStyle,
+ double lineSpacing,
+ DocumentInsets margin) {
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(valueStyle)
+ .lineSpacing(lineSpacing)
+ .align(TextAlign.LEFT)
+ .margin(margin)
+ .rich(rich -> {
+ rich.style(normalizedLabel(label) + ":", labelStyle);
+ if (value != null && !value.isBlank()) {
+ rich.style(" ", valueStyle);
+ MarkdownInline.append(rich, value, valueStyle);
+ }
+ }));
+ }
+
+ static String normalizedLabel(String label) {
+ String value = MarkdownInline.plainText(label).trim();
+ while (value.endsWith(":")) {
+ value = value.substring(0, value.length() - 1).trim();
+ }
+ return value;
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInline.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInline.java
index d613f32c..87b54bdb 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInline.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInline.java
@@ -42,4 +42,30 @@ public static void append(RichText rich, String text,
rich.style(textRun.text(), runStyle);
}
}
+
+ public static void appendTrimmed(RichText rich, String text,
+ DocumentTextStyle baseStyle) {
+ append(rich, text == null ? "" : text.trim(), baseStyle);
+ }
+
+ public static void appendPlainIfPresent(RichText rich, String prefix,
+ String value,
+ DocumentTextStyle style) {
+ String clean = plainText(value);
+ if (!clean.isBlank()) {
+ rich.style(prefix + clean, style);
+ }
+ }
+
+ public static String plainText(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value
+ .replace("**", "")
+ .replace("__", "")
+ .replace("`", "")
+ .replace("*", "")
+ .replace("_", "");
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabel.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabel.java
new file mode 100644
index 00000000..0b236288
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabel.java
@@ -0,0 +1,23 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+/**
+ * Splits legacy project labels like "GraphCompose (Java, PDFBox)" into display title and stack.
+ */
+public record ProjectLabel(String title, String stack) {
+ public ProjectLabel {
+ title = title == null ? "" : title;
+ stack = stack == null ? "" : stack;
+ }
+
+ public static ProjectLabel parse(String value) {
+ String clean = MarkdownInline.plainText(value).trim();
+ int stackOpen = clean.lastIndexOf('(');
+ if (stackOpen > 0 && clean.endsWith(")")) {
+ return new ProjectLabel(
+ clean.substring(0, stackOpen).trim(),
+ clean.substring(stackOpen + 1, clean.length() - 1).trim()
+ );
+ }
+ return new ProjectLabel(clean, "");
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectRenderer.java
new file mode 100644
index 00000000..0ad6c478
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectRenderer.java
@@ -0,0 +1,86 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.cv.v2.data.CvRow;
+
+/**
+ * Renders project rows that carry a title and optional technology
+ * stack in the legacy "Project (Stack)" label shape.
+ */
+public final class ProjectRenderer {
+ private ProjectRenderer() {
+ }
+
+ public static void inline(SectionBuilder host,
+ CvRow row,
+ DocumentTextStyle titleStyle,
+ DocumentTextStyle stackStyle,
+ DocumentTextStyle bodyStyle,
+ double lineSpacing,
+ DocumentInsets margin) {
+ ProjectLabel label = ProjectLabel.parse(row.label());
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(bodyStyle)
+ .lineSpacing(lineSpacing)
+ .align(TextAlign.LEFT)
+ .margin(margin)
+ .rich(rich -> {
+ rich.style(label.title(), titleStyle);
+ if (!label.stack().isBlank()) {
+ rich.style(" (" + label.stack() + ")", stackStyle);
+ }
+ if (!row.body().isBlank()) {
+ rich.style(" - ", bodyStyle);
+ MarkdownInline.appendTrimmed(rich, row.body(), bodyStyle);
+ }
+ }));
+ }
+
+ public static void plainInline(SectionBuilder host,
+ CvRow row,
+ DocumentTextStyle labelStyle,
+ DocumentTextStyle bodyStyle,
+ double lineSpacing,
+ DocumentInsets margin,
+ String delimiter) {
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(bodyStyle)
+ .lineSpacing(lineSpacing)
+ .align(TextAlign.LEFT)
+ .margin(margin)
+ .rich(rich -> {
+ rich.style(MarkdownInline.plainText(row.label()),
+ labelStyle);
+ if (!row.body().isBlank()) {
+ rich.style(delimiter, bodyStyle);
+ MarkdownInline.appendTrimmed(rich, row.body(), bodyStyle);
+ }
+ }));
+ }
+
+ public static void titleThenBody(SectionBuilder host,
+ CvRow row,
+ DocumentTextStyle titleStyle,
+ DocumentTextStyle stackStyle,
+ DocumentTextStyle bodyStyle,
+ double bodyLineSpacing,
+ DocumentInsets titleMargin,
+ DocumentInsets bodyMargin) {
+ ProjectLabel label = ProjectLabel.parse(row.label());
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(titleStyle)
+ .align(TextAlign.LEFT)
+ .margin(titleMargin)
+ .rich(rich -> {
+ rich.style(label.title(), titleStyle);
+ if (!label.stack().isBlank()) {
+ rich.style(" (" + label.stack() + ")", stackStyle);
+ }
+ }));
+ RichParagraphRenderer.render(host, row.body(), bodyStyle,
+ bodyLineSpacing, bodyMargin);
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/RichParagraphRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/RichParagraphRenderer.java
new file mode 100644
index 00000000..8e39de00
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/RichParagraphRenderer.java
@@ -0,0 +1,40 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+
+/**
+ * Reusable rich paragraph primitive for CV presets that need explicit
+ * style, line spacing, and margin while still honouring inline markdown.
+ */
+public final class RichParagraphRenderer {
+ private RichParagraphRenderer() {
+ }
+
+ public static void render(SectionBuilder host,
+ String text,
+ DocumentTextStyle style,
+ double lineSpacing,
+ DocumentInsets margin) {
+ render(host, text, style, lineSpacing, margin, TextAlign.LEFT);
+ }
+
+ public static void render(SectionBuilder host,
+ String text,
+ DocumentTextStyle style,
+ double lineSpacing,
+ DocumentInsets margin,
+ TextAlign align) {
+ if (text == null || text.isBlank()) {
+ return;
+ }
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(style)
+ .lineSpacing(lineSpacing)
+ .align(align)
+ .margin(margin)
+ .rich(rich -> MarkdownInline.appendTrimmed(rich, text, style)));
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionLookup.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionLookup.java
new file mode 100644
index 00000000..befa05ac
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionLookup.java
@@ -0,0 +1,61 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+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.SkillsSection;
+
+import java.util.List;
+
+/**
+ * Shared section title matching for CV presets that still receive free-form section names.
+ */
+public final class SectionLookup {
+ private SectionLookup() {
+ }
+
+ public static CvSection firstMatching(List sections,
+ List keys) {
+ if (sections == null || keys == null) {
+ return null;
+ }
+
+ for (CvSection section : sections) {
+ if (section == null) {
+ continue;
+ }
+ String normalizedTitle = normalize(section.title());
+ for (String key : keys) {
+ if (normalizedTitle.contains(normalize(key))) {
+ return section;
+ }
+ }
+ }
+ return null;
+ }
+
+ public static boolean hasContent(CvSection section) {
+ if (section instanceof ParagraphSection paragraph) {
+ return paragraph.body() != null && !paragraph.body().isBlank();
+ }
+ if (section instanceof EntriesSection entries) {
+ return entries.entries() != null && !entries.entries().isEmpty();
+ }
+ if (section instanceof RowsSection rows) {
+ return rows.rows() != null && !rows.rows().isEmpty();
+ }
+ if (section instanceof SkillsSection skills) {
+ return skills.groups() != null && !skills.groups().isEmpty();
+ }
+ return false;
+ }
+
+ public static boolean titleContains(String title, String key) {
+ return normalize(title).contains(normalize(key));
+ }
+
+ public static String normalize(String value) {
+ return value == null ? "" : value.toLowerCase().replaceAll("[^a-z0-9]+", "");
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillLineRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillLineRenderer.java
new file mode 100644
index 00000000..a92d65da
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillLineRenderer.java
@@ -0,0 +1,42 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.cv.v2.data.SkillGroup;
+
+import java.util.List;
+
+/**
+ * Compact single-line renderer for one skill category.
+ */
+public final class SkillLineRenderer {
+
+ private SkillLineRenderer() {
+ }
+
+ public static void limitedInline(SectionBuilder host,
+ SkillGroup group,
+ int limit,
+ DocumentTextStyle labelStyle,
+ DocumentTextStyle valueStyle,
+ double lineSpacing,
+ DocumentInsets margin,
+ String labelSuffix) {
+ List skills = group.skills().stream().limit(limit).toList();
+ if (skills.isEmpty()) {
+ return;
+ }
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(valueStyle)
+ .lineSpacing(lineSpacing)
+ .align(TextAlign.LEFT)
+ .margin(margin)
+ .rich(rich -> {
+ rich.style(MarkdownInline.plainText(group.category())
+ + labelSuffix, labelStyle);
+ rich.style(String.join(", ", skills), valueStyle);
+ }));
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillTableRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillTableRenderer.java
new file mode 100644
index 00000000..896635d5
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillTableRenderer.java
@@ -0,0 +1,43 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.templates.cv.v2.data.SkillGroup;
+import com.demcha.compose.document.templates.widgets.TableWidget;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Renders CV skill groups as a flat table/grid without exposing the
+ * preset to table plumbing or category parsing.
+ */
+public final class SkillTableRenderer {
+
+ private SkillTableRenderer() {
+ }
+
+ public static void grid(SectionBuilder host,
+ List groups,
+ double width,
+ TableWidget.Style style,
+ String bulletPrefix) {
+ if (groups == null || groups.isEmpty()) {
+ return;
+ }
+ TableWidget.grid(host, flatten(groups, bulletPrefix), width, style);
+ }
+
+ private static List flatten(List groups,
+ String bulletPrefix) {
+ String prefix = bulletPrefix == null ? "" : bulletPrefix;
+ List out = new ArrayList<>();
+ for (SkillGroup group : groups) {
+ for (String skill : group.skills()) {
+ if (!skill.isBlank()) {
+ out.add(prefix + skill);
+ }
+ }
+ }
+ return out;
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/package-info.java
index b5e616d4..19bd0e10 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/package-info.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/package-info.java
@@ -12,7 +12,10 @@
* store theme as a static field — theme is always an argument,
* so the same component renders any theme without reflection
* or singletons;
- * parse data — that is the spec's job;
+ * own business parsing — specs own the semantic model; shared
+ * adapters may normalize legacy free-form labels or bridge
+ * markdown into rich text, but presets should not duplicate
+ * local parsers;
* read magic numbers — every value reads from the theme.
*
*
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java
index 35af79cc..c1fee506 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java
@@ -3,15 +3,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.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.MarkdownInline;
+import com.demcha.compose.document.templates.cv.v2.components.CvTextStyles;
+import com.demcha.compose.document.templates.cv.v2.components.EntryCompactRenderer;
import com.demcha.compose.document.templates.cv.v2.components.ParagraphRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.ProjectRenderer;
import com.demcha.compose.document.templates.cv.v2.components.RowRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.SectionLookup;
import com.demcha.compose.document.templates.cv.v2.components.SkillsRenderer;
import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
import com.demcha.compose.document.templates.cv.v2.data.CvEntry;
@@ -25,13 +27,11 @@
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.FlowSectionHeader;
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;
import java.util.ArrayList;
import java.util.List;
-import java.util.Locale;
import java.util.Objects;
/**
@@ -118,12 +118,11 @@ public void compose(DocumentSession document, CvDocument doc) {
Objects.requireNonNull(document, "document");
Objects.requireNonNull(doc, "doc");
- DocumentTextStyle bannerTitleStyle = DocumentTextStyle.builder()
- .fontName(theme.typography().bodyFont())
- .size(theme.typography().sizeBanner())
- .decoration(DocumentTextDecoration.BOLD)
- .color(BANNER_TEXT)
- .build();
+ DocumentTextStyle bannerTitleStyle = CvTextStyles.of(
+ theme.typography().bodyFont(),
+ theme.typography().sizeBanner(),
+ DocumentTextDecoration.BOLD,
+ BANNER_TEXT);
PageFlowBuilder pageFlow = document.dsl()
.pageFlow()
@@ -139,33 +138,18 @@ public void compose(DocumentSession document, CvDocument doc) {
for (int i = 0; i < sections.size(); i++) {
final CvSection sec = sections.get(i);
final int idx = i;
- addBannerRule(pageFlow, "BlueBannerRuleTop_" + idx, theme, 3, 1);
- pageFlow.addSection("BlueBannerTitle_" + idx, host ->
- SectionHeader.fullWidthBanner(host, sec.title(),
- theme, bannerTitleStyle));
- addBannerRule(pageFlow, "BlueBannerRuleBottom_" + idx, theme, 1, 1);
+ FlowSectionHeader.banner(pageFlow, "BlueBannerTitle_" + idx,
+ sec.title(), BANNER_RULE_WIDTH, theme, bannerTitleStyle,
+ new DocumentInsets(3, BANNER_RULE_HORIZONTAL_INSET,
+ 1, BANNER_RULE_HORIZONTAL_INSET),
+ new DocumentInsets(1, BANNER_RULE_HORIZONTAL_INSET,
+ 1, BANNER_RULE_HORIZONTAL_INSET));
pageFlow.addSection("BlueBannerBody_" + idx, host ->
renderBody(host, sec, theme));
}
pageFlow.build();
}
-
- private static void addBannerRule(PageFlowBuilder pageFlow, String name,
- CvTheme theme,
- double topMargin,
- double bottomMargin) {
- pageFlow.addLine(line -> line
- .name(name)
- .horizontal(BANNER_RULE_WIDTH)
- .color(theme.palette().rule())
- .thickness(theme.spacing().accentRuleWidth())
- .margin(new DocumentInsets(
- topMargin,
- BANNER_RULE_HORIZONTAL_INSET,
- bottomMargin,
- BANNER_RULE_HORIZONTAL_INSET)));
- }
}
private static void renderBody(SectionBuilder host,
@@ -207,110 +191,42 @@ private static void renderRows(SectionBuilder host,
private static void renderPlainProjectRow(SectionBuilder host,
CvRow row,
CvTheme theme) {
- String label = row.label().trim();
- String body = row.body().trim();
- DocumentTextStyle labelStyle = theme.entryTitleStyle();
- DocumentTextStyle bodyStyle = theme.bodyStyle();
-
- host.addParagraph(p -> p
- .textStyle(bodyStyle)
- .lineSpacing(theme.typography().bodyLineSpacing())
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.top((float) theme.spacing().paragraphMarginTop()))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(label), labelStyle);
- if (!body.isBlank()) {
- rich.style(" - ", bodyStyle);
- MarkdownInline.append(rich, body, bodyStyle);
- }
- }));
+ ProjectRenderer.plainInline(host, row, theme.entryTitleStyle(),
+ theme.bodyStyle(), theme.typography().bodyLineSpacing(),
+ DocumentInsets.top((float) theme.spacing().paragraphMarginTop()),
+ " - ");
}
private static void renderEntry(SectionBuilder section,
CvEntry entry,
CvTheme theme) {
DocumentTextStyle titleStyle = theme.entryTitleStyle();
- DocumentTextStyle dateStyle = style(theme.typography().bodyFont(),
+ DocumentTextStyle dateStyle = CvTextStyles.of(theme.typography().bodyFont(),
theme.typography().sizeEntryDate(),
DocumentTextDecoration.BOLD,
theme.palette().ink());
- DocumentTextStyle subtitleStyle = style(theme.typography().bodyFont(),
+ DocumentTextStyle subtitleStyle = CvTextStyles.of(theme.typography().bodyFont(),
theme.typography().sizeEntrySubtitle(),
DocumentTextDecoration.DEFAULT,
theme.palette().ink());
- DocumentTextStyle bodyStyle = theme.bodyStyle();
-
- section.addRow("BlueBannerEntryHeader", row -> row
- .spacing(theme.spacing().entryHeaderRowSpacing())
- .weights(theme.spacing().entryTitleWeight(),
- theme.spacing().entryDateWeight())
- .addSection("Title", titleColumn -> titleColumn
- .padding(DocumentInsets.zero())
- .addParagraph(p -> p
- .text(stripBasicMarkdown(entry.title())
- .toUpperCase(Locale.ROOT))
- .textStyle(titleStyle)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.zero())))
- .addSection("Date", dateColumn -> dateColumn
- .padding(DocumentInsets.zero())
- .addParagraph(p -> p
- .text(stripBasicMarkdown(entry.date()))
- .textStyle(dateStyle)
- .align(TextAlign.RIGHT)
- .margin(DocumentInsets.zero()))));
-
- if (!entry.subtitle().isBlank()) {
- section.addParagraph(p -> p
- .text(stripBasicMarkdown(entry.subtitle()))
- .textStyle(subtitleStyle)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.zero()));
- }
-
- if (!entry.body().isBlank()) {
- renderBodyParagraph(section, entry.body(), bodyStyle,
- theme.typography().bodyLineSpacing(),
- DocumentInsets.top((float) theme.spacing().paragraphMarginTop()));
- }
- }
-
- private static void renderBodyParagraph(SectionBuilder host,
- String text,
- DocumentTextStyle style,
- double lineSpacing,
- DocumentInsets margin) {
- if (text == null || text.isBlank()) {
- return;
- }
- host.addParagraph(p -> p
- .textStyle(style)
- .lineSpacing(lineSpacing)
- .align(TextAlign.LEFT)
- .margin(margin)
- .rich(rich -> MarkdownInline.append(rich, text.trim(), style)));
- }
- private static DocumentTextStyle style(FontName font,
- double size,
- DocumentTextDecoration decoration,
- DocumentColor color) {
- return DocumentTextStyle.builder()
- .fontName(font)
- .size(size)
- .decoration(decoration)
- .color(color)
- .build();
+ EntryCompactRenderer.twoColumnTitleDateBody(section, entry,
+ "BlueBannerEntryHeader", titleStyle, dateStyle, subtitleStyle,
+ theme.bodyStyle(), theme.spacing().entryHeaderRowSpacing(),
+ theme.spacing().entryTitleWeight(),
+ theme.spacing().entryDateWeight(), DocumentInsets.zero(),
+ DocumentInsets.top((float) theme.spacing().paragraphMarginTop()),
+ theme.typography().bodyLineSpacing(), true);
}
private static List orderedSections(CvDocument doc) {
List sections = doc.sectionsIn(Slot.MAIN);
List ordered = new ArrayList<>();
- addIfPresent(ordered, findSection(sections, SUMMARY_KEYS));
- addIfPresent(ordered, findSection(sections, EXPERIENCE_KEYS));
- addIfPresent(ordered, findSection(sections, EDUCATION_KEYS));
- addIfPresent(ordered, findSection(sections, SKILL_KEYS));
- addIfPresent(ordered, findSection(sections, ADDITIONAL_KEYS));
+ addIfPresent(ordered, SectionLookup.firstMatching(sections, SUMMARY_KEYS));
+ addIfPresent(ordered, SectionLookup.firstMatching(sections, EXPERIENCE_KEYS));
+ addIfPresent(ordered, SectionLookup.firstMatching(sections, EDUCATION_KEYS));
+ addIfPresent(ordered, SectionLookup.firstMatching(sections, SKILL_KEYS));
+ addIfPresent(ordered, SectionLookup.firstMatching(sections, ADDITIONAL_KEYS));
for (CvSection section : sections) {
addIfPresent(ordered, section);
}
@@ -322,41 +238,4 @@ private static void addIfPresent(List sections, CvSection section) {
sections.add(section);
}
}
-
- private static CvSection findSection(List sections,
- List keys) {
- for (CvSection section : sections) {
- String normalizedTitle = normalize(section.title());
- for (String key : keys) {
- if (normalizedTitle.contains(normalize(key))) {
- return section;
- }
- }
- }
- return null;
- }
-
- private static String stripBasicMarkdown(String value) {
- if (value == null) {
- return "";
- }
- return value
- .replace("**", "")
- .replace("__", "")
- .replace("`", "")
- .replace("*", "")
- .replace("_", "");
- }
-
- private static String normalize(String value) {
- String safe = value == null ? "" : value;
- StringBuilder builder = new StringBuilder(safe.length());
- for (int i = 0; i < safe.length(); i++) {
- char current = Character.toLowerCase(safe.charAt(i));
- if (Character.isLetterOrDigit(current)) {
- builder.append(current);
- }
- }
- return builder.toString();
- }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/CenteredHeadline.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/CenteredHeadline.java
index 0c2d4589..21d175d5 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/CenteredHeadline.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/CenteredHeadline.java
@@ -3,12 +3,11 @@
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.TextAlign;
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.MarkdownInline;
+import com.demcha.compose.document.templates.cv.v2.components.ProjectRenderer;
import com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher;
import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
import com.demcha.compose.document.templates.cv.v2.data.CvRow;
@@ -182,22 +181,11 @@ private void renderBody(SectionBuilder host, CvSection sec) {
}
private void renderStackedProject(SectionBuilder host, CvRow row) {
- DocumentTextStyle titleStyle = theme.bodyBoldStyle();
- DocumentTextStyle bodyStyle = theme.bodyStyle();
- host.addParagraph(p -> p
- .text(row.label())
- .textStyle(titleStyle)
- .align(TextAlign.LEFT)
- .lineSpacing(theme.typography().bodyLineSpacing())
- .margin(DocumentInsets.top((float) theme.spacing().paragraphMarginTop())));
- if (!row.body().isBlank()) {
- host.addParagraph(p -> p
- .textStyle(bodyStyle)
- .align(TextAlign.LEFT)
- .lineSpacing(theme.typography().bodyLineSpacing())
- .margin(DocumentInsets.zero())
- .rich(rich -> MarkdownInline.append(rich, row.body(), bodyStyle)));
- }
+ ProjectRenderer.titleThenBody(host, row, theme.bodyBoldStyle(),
+ theme.bodyBoldStyle(), theme.bodyStyle(),
+ theme.typography().bodyLineSpacing(),
+ DocumentInsets.top((float) theme.spacing().paragraphMarginTop()),
+ DocumentInsets.zero());
}
/**
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ClassicSerif.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ClassicSerif.java
index 2ffcfd7e..35d97ca1 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ClassicSerif.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ClassicSerif.java
@@ -2,7 +2,6 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.PageFlowBuilder;
-import com.demcha.compose.document.dsl.RichText;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.style.DocumentColor;
@@ -10,7 +9,12 @@
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.MarkdownInline;
+import com.demcha.compose.document.templates.cv.v2.components.CvTextStyles;
+import com.demcha.compose.document.templates.cv.v2.components.EntryCompactRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.LabelValueRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.ProjectRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.RichParagraphRenderer;
+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;
@@ -26,7 +30,6 @@
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;
import java.util.List;
import java.util.Objects;
@@ -121,16 +124,16 @@ public void compose(DocumentSession document, CvDocument doc) {
.spacing(theme.spacing().pageFlowSpacing());
addHeader(flow, doc, width);
- addSummary(flow, findSection(sections, SUMMARY_KEYS));
- addCoverSkillsModule(flow, findSection(sections, SKILL_KEYS));
+ addSummary(flow, SectionLookup.firstMatching(sections, SUMMARY_KEYS));
+ addCoverSkillsModule(flow, SectionLookup.firstMatching(sections, SKILL_KEYS));
addLinearModule(flow, "Experience",
- findSection(sections, EXPERIENCE_KEYS));
+ SectionLookup.firstMatching(sections, EXPERIENCE_KEYS));
addLinearModule(flow, "Projects",
- findSection(sections, PROJECT_KEYS));
+ SectionLookup.firstMatching(sections, PROJECT_KEYS));
addLinearModule(flow, "Education",
- findSection(sections, EDUCATION_KEYS));
+ SectionLookup.firstMatching(sections, EDUCATION_KEYS));
addLinearModule(flow, "Additional",
- findSection(sections, ADDITIONAL_KEYS));
+ SectionLookup.firstMatching(sections, ADDITIONAL_KEYS));
flow.build();
}
@@ -164,36 +167,25 @@ private void addSummary(PageFlowBuilder flow, CvSection section) {
.accentTop(ACCENT, 1.15)
.accentBottom(theme.palette().rule(), 0.45);
addCenteredTitle(host, "Professional Profile");
- host.addParagraph(paragraph -> paragraph
- .textStyle(style(theme.typography().bodyFont(), 9.8,
+ RichParagraphRenderer.render(host, summary.body(),
+ CvTextStyles.of(theme.typography().bodyFont(), 9.8,
DocumentTextDecoration.DEFAULT,
- theme.palette().ink()))
- .lineSpacing(1.55)
- .align(TextAlign.CENTER)
- .margin(DocumentInsets.zero())
- .rich(rich -> appendMarkdown(rich, summary.body(),
- style(theme.typography().bodyFont(), 9.8,
- DocumentTextDecoration.DEFAULT,
- theme.palette().ink()))));
+ theme.palette().ink()),
+ 1.55, DocumentInsets.zero(), TextAlign.CENTER);
});
}
private void addCoverSkillsModule(PageFlowBuilder flow, CvSection section) {
- if (section == null || !hasContent(section)) {
+ if (section == null || !SectionLookup.hasContent(section)) {
return;
}
flow.addSection("CvV2ClassicSerifCoreSkills", host -> {
host.spacing(theme.spacing().sectionBodySpacing())
.padding(new DocumentInsets(0, 0, 2, 0));
- SectionHeader.flatSpacedCaps(host, "Core Skills", ACCENT,
- theme, titleStyle());
- host.addLine(line -> line
- .name("CvV2ClassicSerifCoreSkillsRule")
- .horizontal(72)
- .color(ACCENT)
- .thickness(1.0)
- .margin(new DocumentInsets(0, 0, 2, 0)));
+ SectionHeader.spacedCapsRule(host, "Core Skills", theme,
+ titleStyle(), ACCENT, 72, 1.0,
+ new DocumentInsets(0, 0, 2, 0));
renderCoverSkillsBody(host, section);
});
}
@@ -212,28 +204,24 @@ private void renderCoverSkillsBody(SectionBuilder host,
private void addLinearModule(PageFlowBuilder flow, String title,
CvSection section) {
- if (section == null || !hasContent(section)) {
+ if (section == null || !SectionLookup.hasContent(section)) {
return;
}
- flow.addSection("CvV2ClassicSerif" + normalize(title), host -> {
+ flow.addSection("CvV2ClassicSerif" + SectionLookup.normalize(title), host -> {
host.spacing(theme.spacing().sectionBodySpacing())
.padding(new DocumentInsets(0, 0, 2, 0));
- SectionHeader.flatSpacedCaps(host, title, ACCENT, theme,
- titleStyle());
- host.addLine(line -> line
- .name("CvV2ClassicSerif" + normalize(title) + "Rule")
- .horizontal(72)
- .color(ACCENT)
- .thickness(1.0)
- .margin(new DocumentInsets(0, 0, 2, 0)));
+ SectionHeader.spacedCapsRule(host, title, theme,
+ titleStyle(), ACCENT, 72, 1.0,
+ new DocumentInsets(0, 0, 2, 0));
renderDetailBody(host, section);
});
}
private void renderDetailBody(SectionBuilder host, CvSection section) {
if (section instanceof ParagraphSection paragraph) {
- renderBodyParagraph(host, paragraph.body(), theme.bodyStyle(),
+ RichParagraphRenderer.render(host, paragraph.body(),
+ theme.bodyStyle(),
theme.typography().bodyLineSpacing(),
DocumentInsets.top(theme.spacing().paragraphMarginTop()));
} else if (section instanceof EntriesSection entries) {
@@ -262,42 +250,24 @@ private void renderEntries(SectionBuilder host, EntriesSection entries) {
}
private void renderEntry(SectionBuilder host, CvEntry entry) {
- host.addRow("CvV2ClassicSerifEntryHeader", row -> row
- .spacing(theme.spacing().entryHeaderRowSpacing())
- .weights(theme.spacing().entryTitleWeight(),
- theme.spacing().entryDateWeight())
- .addSection("Title", titleColumn -> titleColumn
- .padding(DocumentInsets.zero())
- .addParagraph(paragraph -> paragraph
- .text(stripBasicMarkdown(entry.title()))
- .textStyle(theme.entryTitleStyle())
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.zero())))
- .addSection("Date", dateColumn -> dateColumn
- .padding(DocumentInsets.zero())
- .addParagraph(paragraph -> paragraph
- .text(stripBasicMarkdown(entry.date()))
- .textStyle(theme.entryDateStyle())
- .align(TextAlign.RIGHT)
- .margin(DocumentInsets.zero()))));
-
- if (!entry.subtitle().isBlank()) {
- host.addParagraph(paragraph -> paragraph
- .text(stripBasicMarkdown(entry.subtitle()))
- .textStyle(theme.entrySubtitleStyle())
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.zero()));
- }
- renderBodyParagraph(host, entry.body(),
- style(theme.typography().bodyFont(), 8.8,
+ EntryCompactRenderer.twoColumnTitleDateBody(host, entry,
+ "CvV2ClassicSerifEntryHeader",
+ theme.entryTitleStyle(), theme.entryDateStyle(),
+ theme.entrySubtitleStyle(),
+ CvTextStyles.of(theme.typography().bodyFont(), 8.8,
DocumentTextDecoration.DEFAULT,
theme.palette().ink()),
+ theme.spacing().entryHeaderRowSpacing(),
+ theme.spacing().entryTitleWeight(),
+ theme.spacing().entryDateWeight(),
+ DocumentInsets.zero(),
+ DocumentInsets.top(theme.spacing().paragraphMarginTop()),
theme.typography().bodyLineSpacing(),
- DocumentInsets.top(theme.spacing().paragraphMarginTop()));
+ false);
}
private void renderRows(SectionBuilder host, RowsSection rows) {
- boolean projects = normalize(rows.title()).contains("project");
+ boolean projects = SectionLookup.titleContains(rows.title(), "project");
for (int i = 0; i < rows.rows().size(); i++) {
if (i > 0) {
host.spacer(0, theme.spacing().entrySeparation());
@@ -311,60 +281,30 @@ private void renderRows(SectionBuilder host, RowsSection rows) {
}
private void renderProject(SectionBuilder host, CvRow row) {
- TitleAndStack title = splitTitleAndStack(row.label());
- DocumentTextStyle stackStyle = style(theme.typography().bodyFont(),
- 8.7, DocumentTextDecoration.ITALIC, theme.palette().muted());
-
- host.addParagraph(paragraph -> paragraph
- .textStyle(theme.entryTitleStyle())
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.top(theme.spacing().paragraphMarginTop()))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(title.title()),
- theme.entryTitleStyle());
- if (!title.stack().isBlank()) {
- rich.style(" (" + stripBasicMarkdown(title.stack())
- + ")", stackStyle);
- }
- }));
- renderBodyParagraph(host, row.body(),
- style(theme.typography().bodyFont(), 8.8,
+ ProjectRenderer.titleThenBody(host, row, theme.entryTitleStyle(),
+ CvTextStyles.of(theme.typography().bodyFont(), 8.7,
+ DocumentTextDecoration.ITALIC,
+ theme.palette().muted()),
+ CvTextStyles.of(theme.typography().bodyFont(), 8.8,
DocumentTextDecoration.DEFAULT,
theme.palette().ink()),
theme.typography().bodyLineSpacing(),
+ DocumentInsets.top(theme.spacing().paragraphMarginTop()),
DocumentInsets.zero());
}
private void renderKeyValue(SectionBuilder host, CvRow row) {
- host.addParagraph(paragraph -> paragraph
- .textStyle(theme.bodyStyle())
- .lineSpacing(theme.typography().bodyLineSpacing())
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.top(theme.spacing().paragraphMarginTop()))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(row.label()) + ":",
- theme.bodyBoldStyle());
- if (!row.body().isBlank()) {
- rich.style(" ", theme.bodyStyle());
- appendMarkdown(rich, row.body(), theme.bodyStyle());
- }
- }));
+ LabelValueRenderer.render(host, row.label(), row.body(),
+ theme.bodyBoldStyle(), theme.bodyStyle(),
+ theme.typography().bodyLineSpacing(),
+ DocumentInsets.top(theme.spacing().paragraphMarginTop()));
}
private void renderTightKeyValue(SectionBuilder host, CvRow row) {
- host.addParagraph(paragraph -> paragraph
- .textStyle(theme.bodyStyle())
- .lineSpacing(theme.typography().bodyLineSpacing())
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.zero())
- .rich(rich -> {
- rich.style(stripBasicMarkdown(row.label()) + ":",
- theme.bodyBoldStyle());
- if (!row.body().isBlank()) {
- rich.style(" ", theme.bodyStyle());
- appendMarkdown(rich, row.body(), theme.bodyStyle());
- }
- }));
+ LabelValueRenderer.render(host, row.label(), row.body(),
+ theme.bodyBoldStyle(), theme.bodyStyle(),
+ theme.typography().bodyLineSpacing(),
+ DocumentInsets.zero());
}
private void addCenteredTitle(SectionBuilder host, String title) {
@@ -375,131 +315,32 @@ private void addCenteredTitle(SectionBuilder host, String title) {
.margin(DocumentInsets.zero()));
}
- private void renderBodyParagraph(SectionBuilder host, String text,
- DocumentTextStyle bodyStyle,
- double lineSpacing,
- DocumentInsets margin) {
- if (text == null || text.isBlank()) {
- return;
- }
- host.addParagraph(paragraph -> paragraph
- .textStyle(bodyStyle)
- .lineSpacing(lineSpacing)
- .align(TextAlign.LEFT)
- .margin(margin)
- .rich(rich -> appendMarkdown(rich, text.trim(), bodyStyle)));
- }
-
private DocumentTextStyle titleStyle() {
- return style(theme.typography().headlineFont(),
+ return CvTextStyles.of(theme.typography().headlineFont(),
theme.typography().sizeBanner(),
DocumentTextDecoration.BOLD,
ACCENT);
}
private DocumentTextStyle contactMetaStyle() {
- return style(theme.typography().bodyFont(),
+ return CvTextStyles.of(theme.typography().bodyFont(),
theme.typography().sizeContact(),
DocumentTextDecoration.DEFAULT,
theme.palette().muted());
}
private DocumentTextStyle contactLinkStyle() {
- return style(theme.typography().bodyFont(),
+ return CvTextStyles.of(theme.typography().bodyFont(),
theme.typography().sizeContact(),
DocumentTextDecoration.UNDERLINE,
ACCENT);
}
private DocumentTextStyle contactSeparatorStyle() {
- return style(theme.typography().bodyFont(),
+ return CvTextStyles.of(theme.typography().bodyFont(),
theme.typography().sizeContact(),
DocumentTextDecoration.DEFAULT,
theme.palette().rule());
}
}
-
- private static void appendMarkdown(RichText rich, String text,
- DocumentTextStyle baseStyle) {
- MarkdownInline.append(rich, text, baseStyle);
- }
-
- private static CvSection findSection(List sections,
- List keys) {
- for (CvSection section : sections) {
- String normalizedTitle = normalize(section.title());
- for (String key : keys) {
- if (normalizedTitle.contains(normalize(key))) {
- return section;
- }
- }
- }
- return null;
- }
-
- private static boolean hasContent(CvSection section) {
- if (section instanceof ParagraphSection p) {
- return !p.body().isBlank();
- }
- if (section instanceof EntriesSection e) {
- return !e.entries().isEmpty();
- }
- if (section instanceof RowsSection r) {
- return !r.rows().isEmpty();
- }
- if (section instanceof SkillsSection s) {
- return !s.groups().isEmpty();
- }
- return false;
- }
-
- private static DocumentTextStyle style(FontName font, double size,
- DocumentTextDecoration decoration,
- DocumentColor color) {
- return DocumentTextStyle.builder()
- .fontName(font)
- .size(size)
- .decoration(decoration)
- .color(color)
- .build();
- }
-
- private static TitleAndStack splitTitleAndStack(String value) {
- String title = value == null ? "" : value.trim();
- String stack = "";
- int open = title.indexOf('(');
- int close = title.lastIndexOf(')');
- if (open > 0 && close > open) {
- stack = title.substring(open + 1, close).trim();
- title = title.substring(0, open).trim();
- }
- return new TitleAndStack(title, stack);
- }
-
- private static String stripBasicMarkdown(String value) {
- if (value == null) {
- return "";
- }
- return value
- .replace("**", "")
- .replace("__", "")
- .replace("`", "")
- .replace("*", "")
- .replace("_", "");
- }
-
- private static String normalize(String value) {
- String safe = value == null ? "" : value;
- StringBuilder builder = new StringBuilder(safe.length());
- for (int i = 0; i < safe.length(); i++) {
- char current = Character.toLowerCase(safe.charAt(i));
- if (Character.isLetterOrDigit(current)) {
- builder.append(current);
- }
- }
- return builder.toString();
- }
-
- private record TitleAndStack(String title, String stack) {
- }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/CompactMono.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/CompactMono.java
index abe11586..d58bf4fd 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/CompactMono.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/CompactMono.java
@@ -2,16 +2,20 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.PageFlowBuilder;
-import com.demcha.compose.document.dsl.RichText;
import com.demcha.compose.document.dsl.SectionBuilder;
-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.MarkdownInline;
+import com.demcha.compose.document.templates.cv.v2.components.CvTextStyles;
+import com.demcha.compose.document.templates.cv.v2.components.EntryCompactRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.LabelValueRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.ProjectRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.RichParagraphRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.SectionLookup;
+import com.demcha.compose.document.templates.cv.v2.components.SkillLineRenderer;
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.CvRow;
@@ -26,11 +30,12 @@
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;
+import com.demcha.compose.document.templates.cv.v2.widgets.SectionModule;
+import com.demcha.compose.document.templates.widgets.CardWidget;
import java.util.List;
-import java.util.Locale;
import java.util.Objects;
+import java.util.function.Consumer;
/**
* v2 port of the "Compact Mono" CV preset.
@@ -174,16 +179,16 @@ private void addRail(SectionBuilder rail, List sections) {
.padding(new DocumentInsets(11, 11, 13, 11))
.fillColor(theme.palette().banner())
.accentLeft(ACCENT, 3.0);
- addRailSkills(rail, findSection(sections, SKILL_KEYS));
- addRailEducation(rail, findSection(sections, EDUCATION_KEYS));
- addRailAdditional(rail, findSection(sections, ADDITIONAL_KEYS));
+ addRailSkills(rail, SectionLookup.firstMatching(sections, SKILL_KEYS));
+ addRailEducation(rail, SectionLookup.firstMatching(sections, EDUCATION_KEYS));
+ addRailAdditional(rail, SectionLookup.firstMatching(sections, ADDITIONAL_KEYS));
}
private void addMain(SectionBuilder main, List sections) {
main.spacing(8);
- addProfile(main, findSection(sections, SUMMARY_KEYS));
- addExperience(main, findSection(sections, EXPERIENCE_KEYS));
- addProjects(main, findSection(sections, PROJECT_KEYS));
+ addProfile(main, SectionLookup.firstMatching(sections, SUMMARY_KEYS));
+ addExperience(main, SectionLookup.firstMatching(sections, EXPERIENCE_KEYS));
+ addProjects(main, SectionLookup.firstMatching(sections, PROJECT_KEYS));
}
private void addRailSkills(SectionBuilder parent, CvSection section) {
@@ -192,8 +197,8 @@ private void addRailSkills(SectionBuilder parent, CvSection section) {
return;
}
- parent.addSection("CvV2CompactMonoSkills", host -> {
- moduleLabel(host, "Skills", 22);
+ SectionModule.tick(parent, "CvV2CompactMonoSkills", "Skills",
+ theme, ACCENT, 22, moduleLabelStyle(), host -> {
for (SkillGroup group : skills.groups()) {
addSkillLine(host, group);
}
@@ -206,22 +211,14 @@ private void addRailEducation(SectionBuilder parent, CvSection section) {
return;
}
- parent.addSection("CvV2CompactMonoEducation", host -> {
- moduleLabel(host, "Education", 22);
+ SectionModule.tick(parent, "CvV2CompactMonoEducation", "Education",
+ theme, ACCENT, 22, moduleLabelStyle(), host -> {
for (CvEntry entry : education.entries()) {
- host.addParagraph(paragraph -> paragraph
- .textStyle(bodyStyle(7.25, theme.palette().ink()))
- .lineSpacing(1.02)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.bottom(2.0))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(entry.title()),
- bodyBoldStyle(7.25));
- appendIfPresent(rich, " | ", entry.subtitle(),
- bodyStyle(7.15, theme.palette().ink()));
- appendIfPresent(rich, " | ", entry.date(),
- bodyStyle(7.0, theme.palette().muted()));
- }));
+ EntryCompactRenderer.slashSubtitleDate(host, entry,
+ bodyBoldStyle(7.25),
+ bodyStyle(7.15, theme.palette().ink()),
+ bodyStyle(7.0, theme.palette().muted()),
+ 1.02, DocumentInsets.bottom(2.0));
}
});
}
@@ -231,8 +228,8 @@ private void addRailAdditional(SectionBuilder parent, CvSection section) {
return;
}
- parent.addSection("CvV2CompactMonoAdditional", host -> {
- moduleLabel(host, "Additional", 22);
+ SectionModule.tick(parent, "CvV2CompactMonoAdditional", "Additional",
+ theme, ACCENT, 22, moduleLabelStyle(), host -> {
for (CvRow row : rows.rows()) {
addRailLabelValue(host, row.label(), row.body());
}
@@ -245,13 +242,9 @@ private void addProfile(SectionBuilder parent, CvSection section) {
return;
}
addCard(parent, "CvV2CompactMonoProfile", "Profile", card ->
- card.addParagraph(paragraph -> paragraph
- .textStyle(bodyStyle(8.05, theme.palette().ink()))
- .lineSpacing(1.18)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.zero())
- .rich(rich -> appendMarkdown(rich, profile.body(),
- bodyStyle(8.05, theme.palette().ink())))));
+ RichParagraphRenderer.render(card, profile.body(),
+ bodyStyle(8.05, theme.palette().ink()),
+ 1.18, DocumentInsets.zero()));
}
private void addExperience(SectionBuilder parent, CvSection section) {
@@ -279,15 +272,16 @@ private void addProjects(SectionBuilder parent, CvSection section) {
}
private void addCard(SectionBuilder parent, String name, String title,
- SectionAction content) {
- parent.addSection(name, card -> {
- card.spacing(4)
- .padding(new DocumentInsets(9, 10, 10, 11))
- .fillColor(CARD_FILL)
- .stroke(DocumentStroke.of(theme.palette().rule(), 0.35))
- .cornerRadius(3);
+ Consumer content) {
+ CardWidget.render(parent, name, CardWidget.Style.builder()
+ .spacing(4)
+ .padding(new DocumentInsets(9, 10, 10, 11))
+ .fillColor(CARD_FILL)
+ .stroke(DocumentStroke.of(theme.palette().rule(), 0.35))
+ .cornerRadius(3)
+ .build(), card -> {
moduleLabel(card, title, 24);
- content.run(card);
+ content.accept(card);
});
}
@@ -298,230 +292,86 @@ private void moduleLabel(SectionBuilder host, String title, double tickWidth) {
private void addRailLabelValue(SectionBuilder host, String label,
String value) {
- host.addParagraph(paragraph -> paragraph
- .textStyle(bodyStyle(7.2, theme.palette().ink()))
- .lineSpacing(1.05)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.bottom(2.0))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(label) + ":",
- bodyBoldStyle(7.2));
- if (value != null && !value.isBlank()) {
- rich.style(" ", bodyStyle(7.2,
- theme.palette().muted()));
- appendMarkdown(rich, value,
- bodyStyle(7.2, theme.palette().muted()));
- }
- }));
+ LabelValueRenderer.render(host, label, value, bodyBoldStyle(7.2),
+ bodyStyle(7.2, theme.palette().muted()), 1.05,
+ DocumentInsets.bottom(2.0));
}
private void addSkillLine(SectionBuilder host, SkillGroup group) {
- List picked = group.skills().stream().limit(4).toList();
- if (picked.isEmpty()) {
- return;
- }
- DocumentTextStyle labelStyle = bodyBoldStyle(7.55);
- DocumentTextStyle valueStyle = bodyStyle(7.45,
- theme.palette().ink());
- host.addParagraph(paragraph -> paragraph
- .textStyle(valueStyle)
- .lineSpacing(0.98)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.bottom(2.0))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(group.category()) + " ",
- labelStyle);
- rich.style(String.join(", ", picked), valueStyle);
- }));
+ SkillLineRenderer.limitedInline(host, group, 4,
+ bodyBoldStyle(7.55),
+ bodyStyle(7.45, theme.palette().ink()),
+ 0.98, DocumentInsets.bottom(2.0), " ");
}
private void addExperienceEntry(SectionBuilder host, CvEntry entry) {
- host.addParagraph(paragraph -> paragraph
- .textStyle(bodyStyle(8.0, theme.palette().ink()))
- .lineSpacing(1.08)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.bottom(1.2))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(entry.title()),
- bodyBoldStyle(8.0));
- if (!entry.subtitle().isBlank()) {
- rich.style(", " + stripBasicMarkdown(
- entry.subtitle()),
- bodyStyle(8.0, theme.palette().ink()));
- }
- if (!entry.date().isBlank()) {
- rich.style(" | " + stripBasicMarkdown(
- entry.date()),
- bodyStyle(7.75, theme.palette().muted()));
- }
- }));
- if (!entry.body().isBlank()) {
- host.addParagraph(paragraph -> paragraph
- .textStyle(bodyStyle(7.95, theme.palette().ink()))
- .lineSpacing(1.14)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.bottom(
- theme.spacing().entrySeparation()))
- .rich(rich -> appendMarkdown(rich, entry.body(),
- bodyStyle(7.95, theme.palette().ink()))));
- }
+ EntryCompactRenderer.titleSubtitleDateBody(host, entry,
+ bodyBoldStyle(8.0),
+ bodyStyle(8.0, theme.palette().ink()),
+ bodyStyle(7.75, theme.palette().muted()),
+ bodyStyle(7.95, theme.palette().ink()),
+ ", ", " | ", 1.08,
+ DocumentInsets.bottom(1.2),
+ DocumentInsets.bottom(theme.spacing().entrySeparation()),
+ 1.14);
}
private void addProject(SectionBuilder host, CvRow row) {
- TitleAndStack title = splitTitleAndStack(row.label());
- DocumentTextStyle projectTitle = bodyBoldStyle(8.0);
- DocumentTextStyle stackStyle = italicStyle(7.75,
- theme.palette().muted());
- DocumentTextStyle body = bodyStyle(7.95, theme.palette().ink());
-
- host.addParagraph(paragraph -> paragraph
- .textStyle(body)
- .lineSpacing(1.12)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.bottom(3.0))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(title.title()),
- projectTitle);
- if (!title.stack().isBlank()) {
- rich.style(" (" + stripBasicMarkdown(title.stack())
- + ")", stackStyle);
- }
- if (!row.body().isBlank()) {
- rich.style(" - ", body);
- appendMarkdown(rich, row.body(), body);
- }
- }));
+ ProjectRenderer.inline(host, row, bodyBoldStyle(8.0),
+ italicStyle(7.75, theme.palette().muted()),
+ bodyStyle(7.95, theme.palette().ink()),
+ 1.12, DocumentInsets.bottom(3.0));
}
private DocumentTextStyle headerNameStyle() {
- return style(theme.typography().headlineFont(),
+ return CvTextStyles.of(theme.typography().headlineFont(),
theme.typography().sizeHeadline(),
DocumentTextDecoration.BOLD,
DocumentColor.rgb(255, 255, 255));
}
private DocumentTextStyle headerMetaStyle() {
- return style(theme.typography().bodyFont(),
+ return CvTextStyles.of(theme.typography().bodyFont(),
theme.typography().sizeContact(),
DocumentTextDecoration.DEFAULT,
HEADER_SOFT);
}
private DocumentTextStyle headerLinkStyle() {
- return style(theme.typography().bodyFont(),
+ return CvTextStyles.of(theme.typography().bodyFont(),
theme.typography().sizeContact(),
DocumentTextDecoration.UNDERLINE,
LINK_CYAN);
}
private DocumentTextStyle headerSeparatorStyle() {
- return style(theme.typography().bodyFont(),
+ return CvTextStyles.of(theme.typography().bodyFont(),
theme.typography().sizeContact(),
DocumentTextDecoration.DEFAULT,
SEPARATOR_GRAY);
}
private DocumentTextStyle moduleLabelStyle() {
- return style(theme.typography().headlineFont(),
+ return CvTextStyles.of(theme.typography().headlineFont(),
theme.typography().sizeBanner(),
DocumentTextDecoration.BOLD,
ACCENT);
}
private DocumentTextStyle bodyStyle(double size, DocumentColor color) {
- return style(theme.typography().bodyFont(), size,
+ return CvTextStyles.of(theme.typography().bodyFont(), size,
DocumentTextDecoration.DEFAULT, color);
}
private DocumentTextStyle bodyBoldStyle(double size) {
- return style(theme.typography().bodyFont(), size,
+ return CvTextStyles.of(theme.typography().bodyFont(), size,
DocumentTextDecoration.BOLD, theme.palette().ink());
}
private DocumentTextStyle italicStyle(double size, DocumentColor color) {
- return style(theme.typography().bodyFont(), size,
+ return CvTextStyles.of(theme.typography().bodyFont(), size,
DocumentTextDecoration.ITALIC, color);
}
}
- private static void appendIfPresent(RichText rich, String prefix,
- String value,
- DocumentTextStyle style) {
- String clean = stripBasicMarkdown(value);
- if (!clean.isBlank()) {
- rich.style(prefix + clean, style);
- }
- }
-
- private static void appendMarkdown(RichText rich, String text,
- DocumentTextStyle baseStyle) {
- MarkdownInline.append(rich, text == null ? "" : text.trim(), baseStyle);
- }
-
- private static CvSection findSection(List sections,
- List keys) {
- for (CvSection section : sections) {
- String normalizedTitle = normalize(section.title());
- for (String key : keys) {
- if (normalizedTitle.contains(normalize(key))) {
- return section;
- }
- }
- }
- return null;
- }
-
- private static TitleAndStack splitTitleAndStack(String value) {
- String clean = stripBasicMarkdown(value).trim();
- int open = clean.lastIndexOf('(');
- if (open > 0 && clean.endsWith(")")) {
- return new TitleAndStack(clean.substring(0, open).trim(),
- clean.substring(open + 1, clean.length() - 1).trim());
- }
- return new TitleAndStack(clean, "");
- }
-
- private static String stripBasicMarkdown(String value) {
- return safe(value)
- .replace("**", "")
- .replace("__", "")
- .replace("`", "")
- .replace("*", "")
- .replace("_", "");
- }
-
- private static String normalize(String value) {
- StringBuilder builder = new StringBuilder();
- String safeValue = safe(value);
- for (int index = 0; index < safeValue.length(); index++) {
- char current = Character.toLowerCase(safeValue.charAt(index));
- if (Character.isLetterOrDigit(current)) {
- builder.append(current);
- }
- }
- return builder.toString();
- }
-
- private static String safe(String value) {
- return value == null ? "" : value;
- }
-
- private static DocumentTextStyle style(FontName font, double size,
- DocumentTextDecoration decoration,
- DocumentColor color) {
- return DocumentTextStyle.builder()
- .fontName(font)
- .size(size)
- .decoration(decoration)
- .color(color)
- .build();
- }
-
- private record TitleAndStack(String title, String stack) {
- }
-
- @FunctionalInterface
- private interface SectionAction {
- void run(SectionBuilder section);
- }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/EditorialBlue.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/EditorialBlue.java
index d5000f1a..fb54ee09 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/EditorialBlue.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/EditorialBlue.java
@@ -2,20 +2,22 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.PageFlowBuilder;
-import com.demcha.compose.document.dsl.RichText;
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.MarkdownInline;
+import com.demcha.compose.document.templates.cv.v2.components.CvTextStyles;
+import com.demcha.compose.document.templates.cv.v2.components.EntryCompactRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.LabelValueRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.ProjectRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.RichParagraphRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.SectionLookup;
+import com.demcha.compose.document.templates.cv.v2.components.SkillTableRenderer;
import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
import com.demcha.compose.document.templates.cv.v2.data.CvEntry;
-import com.demcha.compose.document.templates.cv.v2.data.CvIdentity;
-import com.demcha.compose.document.templates.cv.v2.data.CvLink;
import com.demcha.compose.document.templates.cv.v2.data.CvRow;
import com.demcha.compose.document.templates.cv.v2.data.CvSection;
import com.demcha.compose.document.templates.cv.v2.data.EntriesSection;
@@ -25,11 +27,11 @@
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.document.templates.cv.v2.widgets.Headline;
+import com.demcha.compose.document.templates.cv.v2.widgets.FlowSectionHeader;
+import com.demcha.compose.document.templates.cv.v2.widgets.Masthead;
import com.demcha.compose.document.templates.widgets.TableWidget;
import com.demcha.compose.font.FontName;
-import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@@ -115,16 +117,21 @@ public void compose(DocumentSession document, CvDocument doc) {
.name("CvV2EditorialBlueRoot")
.spacing(theme.spacing().pageFlowSpacing());
- addHeader(pageFlow, doc.identity());
+ pageFlow.addSection("CvV2EditorialBlueHeader", section ->
+ Masthead.centered(section, doc.identity(), theme,
+ mastheadStyle()));
List sections = doc.sectionsIn(Slot.MAIN).stream()
- .filter(this::hasContent)
+ .filter(SectionLookup::hasContent)
.toList();
for (int i = 0; i < sections.size(); i++) {
CvSection section = sections.get(i);
String name = "CvV2EditorialBlue_" + i;
- sectionHeader(pageFlow, name + "_Title",
- displayTitle(section.title()), width, true);
+ FlowSectionHeader.label(pageFlow, name + "_Title",
+ displayTitle(section.title()), width, theme,
+ sectionTitleStyle(), new DocumentInsets(8, 0, 0, 0),
+ new DocumentInsets(7, 0, 5, 0),
+ DocumentInsets.zero(), true);
pageFlow.addSection(name + "_Body",
host -> renderSectionBody(host, section, width));
}
@@ -133,111 +140,6 @@ public void compose(DocumentSession document, CvDocument doc) {
pageFlow.build();
}
- private void addHeader(PageFlowBuilder pageFlow, CvIdentity identity) {
- pageFlow.addSection("CvV2EditorialBlueHeader", section -> {
- DocumentTextStyle nameStyle = style(FontName.HELVETICA_BOLD,
- theme.typography().sizeHeadline(),
- DocumentTextDecoration.BOLD, NAME_COLOR);
- Headline.uppercaseCentered(section, identity.name(),
- theme, nameStyle);
-
- if (!identity.jobTitle().isBlank()) {
- section.addParagraph(paragraph -> paragraph
- .text(identity.jobTitle())
- .textStyle(style(FontName.HELVETICA,
- 10.0, DocumentTextDecoration.DEFAULT,
- theme.palette().ink()))
- .align(TextAlign.CENTER)
- .margin(DocumentInsets.top(1)));
- }
-
- String meta = joinDash(identity.contact().phone(),
- identity.contact().address());
- if (!meta.isBlank()) {
- section.addParagraph(paragraph -> paragraph
- .text(meta)
- .textStyle(theme.contactStyle())
- .align(TextAlign.CENTER)
- .margin(DocumentInsets.top(1)));
- }
-
- addLinkRow(section, identity);
- });
- }
-
- private void addLinkRow(SectionBuilder section, CvIdentity identity) {
- List parts = new ArrayList<>();
- String email = identity.contact().email();
- if (!email.isBlank()) {
- parts.add(new ContactPart(email,
- new DocumentLinkOptions("mailto:" + email)));
- }
- for (CvLink link : identity.links()) {
- if (!link.label().isBlank()) {
- parts.add(new ContactPart(link.label(),
- link.url().isBlank()
- ? null
- : new DocumentLinkOptions(link.url())));
- }
- }
- if (parts.isEmpty()) {
- return;
- }
-
- DocumentTextStyle base = theme.contactStyle();
- DocumentTextStyle linkStyle = style(FontName.HELVETICA,
- theme.typography().sizeContact(),
- DocumentTextDecoration.UNDERLINE,
- theme.palette().rule());
- section.addParagraph(paragraph -> paragraph
- .textStyle(base)
- .align(TextAlign.CENTER)
- .margin(DocumentInsets.top(1))
- .rich(rich -> {
- for (int i = 0; i < parts.size(); i++) {
- ContactPart part = parts.get(i);
- if (part.link() == null) {
- rich.style(part.text(), base);
- } else {
- rich.with(part.text(), linkStyle, part.link());
- }
- if (i < parts.size() - 1) {
- rich.style(theme.decoration().contactSeparator(), base);
- }
- }
- }));
- }
-
- private void sectionHeader(PageFlowBuilder pageFlow, String name,
- String title, double width,
- boolean withTopRule) {
- if (withTopRule) {
- pageFlow.addLine(line -> line
- .name(name + "RuleTop")
- .horizontal(width)
- .color(theme.palette().rule())
- .thickness(theme.spacing().accentRuleWidth())
- .margin(new DocumentInsets(8, 0, 0, 0)));
- }
- pageFlow.addSection(name, section -> section
- .spacing(0)
- .padding(new DocumentInsets(7, 0, 5, 0))
- .addParagraph(paragraph -> paragraph
- .text(title)
- .textStyle(style(FontName.HELVETICA_BOLD,
- theme.typography().sizeBanner(),
- DocumentTextDecoration.BOLD,
- theme.palette().rule()))
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.zero())));
- pageFlow.addLine(line -> line
- .name(name + "RuleBottom")
- .horizontal(width)
- .color(theme.palette().rule())
- .thickness(theme.spacing().accentRuleWidth())
- .margin(DocumentInsets.zero()));
- }
-
private void renderSectionBody(SectionBuilder section, CvSection cvSection,
double width) {
section.spacing(theme.spacing().sectionBodySpacing())
@@ -255,7 +157,9 @@ private void renderSectionBody(SectionBuilder section, CvSection cvSection,
}
private void renderEntries(SectionBuilder section, EntriesSection entries) {
- boolean education = isEducation(entries.title());
+ String normalized = SectionLookup.normalize(entries.title());
+ boolean education = normalized.contains("education")
+ || normalized.contains("certification");
for (int i = 0; i < entries.entries().size(); i++) {
if (i > 0) {
section.spacer(0, theme.spacing().entrySeparation());
@@ -269,69 +173,35 @@ private void renderEntries(SectionBuilder section, EntriesSection entries) {
}
private void renderExperienceEntry(SectionBuilder section, CvEntry entry) {
- DocumentTextStyle titleStyle = style(FontName.HELVETICA_BOLD,
+ DocumentTextStyle titleStyle = CvTextStyles.of(FontName.HELVETICA_BOLD,
11.0, DocumentTextDecoration.BOLD, NAME_COLOR);
- DocumentTextStyle dateStyle = style(FontName.HELVETICA_BOLD,
+ DocumentTextStyle dateStyle = CvTextStyles.of(FontName.HELVETICA_BOLD,
11.0, DocumentTextDecoration.BOLD, theme.palette().rule());
- DocumentTextStyle subtitleStyle = style(FontName.HELVETICA,
+ DocumentTextStyle subtitleStyle = CvTextStyles.of(FontName.HELVETICA,
9.4, DocumentTextDecoration.ITALIC, theme.palette().ink());
- section.addParagraph(paragraph -> paragraph
- .textStyle(titleStyle)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.top(1))
- .rich(rich -> {
- rich.style(entry.title(), titleStyle);
- if (!entry.date().isBlank()) {
- rich.style(" ", titleStyle);
- rich.style(entry.date(), dateStyle);
- }
- }));
- if (!entry.subtitle().isBlank()) {
- section.addParagraph(paragraph -> paragraph
- .text(entry.subtitle())
- .textStyle(subtitleStyle)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.zero()));
- }
- if (!entry.body().isBlank()) {
- renderParagraph(section, entry.body(), 1.5);
- }
+ EntryCompactRenderer.titleDateBody(section, entry, titleStyle,
+ dateStyle, subtitleStyle, theme.bodyStyle(), " ",
+ 1.0, DocumentInsets.top(1), DocumentInsets.zero(),
+ DocumentInsets.top(1), 1.5, false);
}
private void renderEducationEntry(SectionBuilder section, CvEntry entry) {
- DocumentTextStyle titleStyle = style(FontName.HELVETICA_BOLD,
+ DocumentTextStyle titleStyle = CvTextStyles.of(FontName.HELVETICA_BOLD,
10.6, DocumentTextDecoration.BOLD, NAME_COLOR);
- DocumentTextStyle dateStyle = style(FontName.HELVETICA_BOLD,
+ DocumentTextStyle dateStyle = CvTextStyles.of(FontName.HELVETICA_BOLD,
10.0, DocumentTextDecoration.BOLD, theme.palette().rule());
- DocumentTextStyle subtitleStyle = style(FontName.HELVETICA,
+ DocumentTextStyle subtitleStyle = CvTextStyles.of(FontName.HELVETICA,
9.2, DocumentTextDecoration.ITALIC, theme.palette().ink());
- section.addParagraph(paragraph -> paragraph
- .textStyle(titleStyle)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.top(1))
- .rich(rich -> {
- rich.style(entry.title(), titleStyle);
- if (!entry.date().isBlank()) {
- rich.style(" ", titleStyle);
- rich.style(entry.date(), dateStyle);
- }
- }));
- if (!entry.subtitle().isBlank()) {
- section.addParagraph(paragraph -> paragraph
- .text(entry.subtitle())
- .textStyle(subtitleStyle)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.zero()));
- }
- if (!entry.body().isBlank()) {
- renderParagraph(section, entry.body(), 1.4);
- }
+ EntryCompactRenderer.titleDateBody(section, entry, titleStyle,
+ dateStyle, subtitleStyle, theme.bodyStyle(), " ",
+ 1.0, DocumentInsets.top(1), DocumentInsets.zero(),
+ DocumentInsets.top(1), 1.4, false);
}
private void renderRows(SectionBuilder section, RowsSection rows) {
- if (isProjects(rows.title())) {
+ if (SectionLookup.titleContains(rows.title(), "project")) {
for (int i = 0; i < rows.rows().size(); i++) {
if (i > 0) {
section.spacer(0, theme.spacing().entrySeparation());
@@ -347,43 +217,21 @@ private void renderRows(SectionBuilder section, RowsSection rows) {
}
private void renderProject(SectionBuilder section, CvRow row) {
- TitleAndStack title = splitTitleAndStack(row.label());
- DocumentTextStyle titleStyle = style(FontName.HELVETICA_BOLD,
+ DocumentTextStyle titleStyle = CvTextStyles.of(FontName.HELVETICA_BOLD,
10.6, DocumentTextDecoration.BOLD, NAME_COLOR);
- DocumentTextStyle stackStyle = style(FontName.HELVETICA,
+ DocumentTextStyle stackStyle = CvTextStyles.of(FontName.HELVETICA,
9.3, DocumentTextDecoration.ITALIC, theme.palette().rule());
-
- section.addParagraph(paragraph -> paragraph
- .textStyle(titleStyle)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.top(1))
- .rich(rich -> {
- rich.style(title.title(), titleStyle);
- if (!title.stack().isBlank()) {
- rich.style(" (" + title.stack() + ")", stackStyle);
- }
- }));
- if (!row.body().isBlank()) {
- renderParagraph(section, row.body(), 1.45);
- }
+ ProjectRenderer.titleThenBody(section, row, titleStyle, stackStyle,
+ theme.bodyStyle(), 1.45, DocumentInsets.top(1),
+ DocumentInsets.top(1));
}
private void renderKeyValue(SectionBuilder section, CvRow row) {
- DocumentTextStyle keyStyle = style(FontName.HELVETICA_BOLD,
+ DocumentTextStyle keyStyle = CvTextStyles.of(FontName.HELVETICA_BOLD,
theme.typography().sizeBody(),
DocumentTextDecoration.BOLD, NAME_COLOR);
- section.addParagraph(paragraph -> paragraph
- .textStyle(theme.bodyStyle())
- .lineSpacing(1.4)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.top(1))
- .rich(rich -> {
- rich.style(row.label() + ":", keyStyle);
- if (!row.body().isBlank()) {
- rich.style(" ", theme.bodyStyle());
- appendMarkdown(rich, row.body(), theme.bodyStyle());
- }
- }));
+ LabelValueRenderer.render(section, row.label(), row.body(),
+ keyStyle, theme.bodyStyle(), 1.4, DocumentInsets.top(1));
}
private void renderParagraph(SectionBuilder section, String text,
@@ -392,12 +240,8 @@ private void renderParagraph(SectionBuilder section, String text,
if (value.isBlank()) {
return;
}
- section.addParagraph(paragraph -> paragraph
- .textStyle(theme.bodyStyle())
- .lineSpacing(lineSpacing)
- .align(TextAlign.LEFT)
- .margin(DocumentInsets.top(1))
- .rich(rich -> appendMarkdown(rich, value, theme.bodyStyle())));
+ RichParagraphRenderer.render(section, value, theme.bodyStyle(),
+ lineSpacing, DocumentInsets.top(1));
}
private void renderSkills(SectionBuilder section, List groups,
@@ -405,7 +249,7 @@ private void renderSkills(SectionBuilder section, List groups,
if (groups.isEmpty()) {
return;
}
- DocumentTextStyle cellStyle = style(FontName.HELVETICA,
+ DocumentTextStyle cellStyle = CvTextStyles.of(FontName.HELVETICA,
8.6, DocumentTextDecoration.DEFAULT, theme.palette().ink());
TableWidget.Style tableStyle = TableWidget.Style.builder()
.name("CvV2EditorialBlueSkillsTable")
@@ -416,17 +260,7 @@ private void renderSkills(SectionBuilder section, List groups,
.widthAdjustment(1.0)
.build();
- TableWidget.grid(section, flattenSkills(groups), width, tableStyle);
- }
-
- private List flattenSkills(List groups) {
- List out = new ArrayList<>();
- for (SkillGroup group : groups) {
- for (String skill : group.skills()) {
- out.add("• " + skill);
- }
- }
- return out;
+ SkillTableRenderer.grid(section, groups, width, tableStyle, "• ");
}
private void addFooter(PageFlowBuilder pageFlow, double width) {
@@ -440,31 +274,15 @@ private void addFooter(PageFlowBuilder pageFlow, double width) {
.padding(new DocumentInsets(2, 0, 0, 0))
.addParagraph(paragraph -> paragraph
.text("References available upon request.")
- .textStyle(style(FontName.HELVETICA,
+ .textStyle(CvTextStyles.of(FontName.HELVETICA,
8.4, DocumentTextDecoration.ITALIC,
theme.palette().muted()))
.align(TextAlign.CENTER)
.margin(DocumentInsets.top(2))));
}
- private boolean hasContent(CvSection section) {
- if (section instanceof ParagraphSection p) {
- return !p.body().isBlank();
- }
- if (section instanceof SkillsSection s) {
- return !s.groups().isEmpty();
- }
- if (section instanceof EntriesSection e) {
- return !e.entries().isEmpty();
- }
- if (section instanceof RowsSection r) {
- return !r.rows().isEmpty();
- }
- return false;
- }
-
private String displayTitle(String title) {
- String normalized = normalize(title);
+ String normalized = SectionLookup.normalize(title);
if (normalized.contains("summary") || normalized.contains("profile")) {
return "PROFESSIONAL PROFILE";
}
@@ -488,74 +306,33 @@ private String displayTitle(String title) {
return title.toUpperCase(Locale.ROOT);
}
- private boolean isEducation(String title) {
- String normalized = normalize(title);
- return normalized.contains("education")
- || normalized.contains("certification");
- }
-
- private boolean isProjects(String title) {
- return normalize(title).contains("project");
- }
- }
-
- private static void appendMarkdown(RichText rich, String text,
- DocumentTextStyle baseStyle) {
- MarkdownInline.append(rich, text, baseStyle);
- }
-
- private static DocumentTextStyle style(FontName font, double size,
- DocumentTextDecoration decoration,
- DocumentColor color) {
- return DocumentTextStyle.builder()
- .fontName(font)
- .size(size)
- .decoration(decoration)
- .color(color)
- .build();
- }
-
- private static String joinDash(String... parts) {
- StringBuilder sb = new StringBuilder();
- for (String part : parts) {
- if (part == null || part.isBlank()) {
- continue;
- }
- if (sb.length() > 0) {
- sb.append(" - ");
- }
- sb.append(part.trim());
- }
- return sb.toString();
- }
-
- private static TitleAndStack splitTitleAndStack(String value) {
- String title = value == null ? "" : value.trim();
- String stack = "";
- int open = title.indexOf('(');
- int close = title.lastIndexOf(')');
- if (open > 0 && close > open) {
- stack = title.substring(open + 1, close).trim();
- title = title.substring(0, open).trim();
+ private DocumentTextStyle sectionTitleStyle() {
+ return CvTextStyles.of(FontName.HELVETICA_BOLD,
+ theme.typography().sizeBanner(),
+ DocumentTextDecoration.BOLD,
+ theme.palette().rule());
}
- return new TitleAndStack(title, stack);
- }
- private static String normalize(String value) {
- String safe = value == null ? "" : value;
- StringBuilder out = new StringBuilder(safe.length());
- for (int i = 0; i < safe.length(); i++) {
- char current = Character.toLowerCase(safe.charAt(i));
- if (Character.isLetterOrDigit(current)) {
- out.append(current);
- }
+ private Masthead.Style mastheadStyle() {
+ DocumentTextStyle nameStyle = CvTextStyles.of(FontName.HELVETICA_BOLD,
+ theme.typography().sizeHeadline(),
+ DocumentTextDecoration.BOLD, NAME_COLOR);
+ DocumentTextStyle titleStyle = CvTextStyles.of(FontName.HELVETICA,
+ 10.0, DocumentTextDecoration.DEFAULT,
+ theme.palette().ink());
+ DocumentTextStyle linkStyle = CvTextStyles.of(FontName.HELVETICA,
+ theme.typography().sizeContact(),
+ DocumentTextDecoration.UNDERLINE,
+ theme.palette().rule());
+ return Masthead.Style.builder()
+ .nameStyle(nameStyle)
+ .titleStyle(titleStyle)
+ .metaStyle(theme.contactStyle())
+ .linkStyle(linkStyle)
+ .separatorStyle(theme.contactStyle())
+ .lineMargin(DocumentInsets.top(1))
+ .build();
}
- return out.toString();
- }
-
- private record ContactPart(String text, DocumentLinkOptions link) {
- }
- private record TitleAndStack(String title, String stack) {
}
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/NordicClean.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/NordicClean.java
index 39df1470..4e64e879 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/NordicClean.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/NordicClean.java
@@ -2,9 +2,7 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.PageFlowBuilder;
-import com.demcha.compose.document.dsl.RichText;
import com.demcha.compose.document.dsl.SectionBuilder;
-import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentCornerRadius;
import com.demcha.compose.document.style.DocumentInsets;
@@ -12,7 +10,12 @@
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.EntryCompactRenderer;
+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.data.CvDocument;
import com.demcha.compose.document.templates.cv.v2.data.CvEntry;
import com.demcha.compose.document.templates.cv.v2.data.CvRow;
@@ -26,7 +29,8 @@
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.font.FontName;
+import com.demcha.compose.document.templates.cv.v2.widgets.ProfileBand;
+import com.demcha.compose.document.templates.cv.v2.widgets.SectionModule;
import java.util.List;
import java.util.Objects;
@@ -248,7 +252,7 @@ public void compose(DocumentSession document, CvDocument doc) {
.spacing(theme.spacing().pageFlowSpacing());
addHeader(flow, doc);
- addProfile(flow, findSection(sections, SUMMARY_KEYS));
+ addProfile(flow, SectionLookup.firstMatching(sections, SUMMARY_KEYS));
addBody(flow, sections);
flow.build();
}
@@ -271,10 +275,10 @@ private void addHeader(PageFlowBuilder flow, CvDocument doc) {
.margin(DocumentInsets.zero()));
if (!doc.identity().jobTitle().isBlank()) {
identity.addParagraph(paragraph -> paragraph
- .text(stripBasicMarkdown(
+ .text(MarkdownInline.plainText(
doc.identity().jobTitle())
.toUpperCase(java.util.Locale.ROOT))
- .textStyle(style(theme.typography().bodyFont(),
+ .textStyle(CvTextStyles.of(theme.typography().bodyFont(),
7.7,
DocumentTextDecoration.BOLD,
theme.palette().muted()))
@@ -293,23 +297,17 @@ private void addProfile(PageFlowBuilder flow, CvSection section) {
return;
}
- flow.addSection("CvV2NordicCleanProfile", host -> host
- .spacing(4)
- .padding(new DocumentInsets(8, 10, 8, 10))
- .fillColor(options.resolvedProfileFill(theme))
- .accentLeft(options.accentColor(), 3.0)
- .cornerRadius(DocumentCornerRadius.right(4))
- .addParagraph(paragraph -> paragraph
- .text("PROFILE")
- .textStyle(sectionTitleStyle())
- .margin(DocumentInsets.zero()))
- .addParagraph(paragraph -> paragraph
- .textStyle(bodyStyle(7.85, theme.palette().ink()))
- .lineSpacing(1.25)
- .margin(DocumentInsets.zero())
- .rich(rich -> appendMarkdown(rich,
- profile.body(),
- bodyStyle(7.85, theme.palette().ink())))));
+ ProfileBand.render(flow, "CvV2NordicCleanProfile", "PROFILE",
+ profile.body(), ProfileBand.Style.builder()
+ .spacing(4)
+ .padding(new DocumentInsets(8, 10, 8, 10))
+ .fillColor(options.resolvedProfileFill(theme))
+ .accentLeft(options.accentColor(), 3.0)
+ .cornerRadius(DocumentCornerRadius.right(4))
+ .titleStyle(sectionTitleStyle())
+ .bodyStyle(bodyStyle(7.85, theme.palette().ink()))
+ .bodyLineSpacing(1.25)
+ .build());
}
private void addBody(PageFlowBuilder flow, List sections) {
@@ -337,15 +335,15 @@ private void addRail(SectionBuilder rail, List sections) {
.fillColor(options.railFillColor())
.stroke(DocumentStroke.of(theme.palette().rule(), 0.35))
.cornerRadius(4);
- addSkills(rail, findSection(sections, SKILL_KEYS));
- addEducation(rail, findSection(sections, EDUCATION_KEYS));
- addAdditional(rail, findSection(sections, ADDITIONAL_KEYS));
+ addSkills(rail, SectionLookup.firstMatching(sections, SKILL_KEYS));
+ addEducation(rail, SectionLookup.firstMatching(sections, EDUCATION_KEYS));
+ addAdditional(rail, SectionLookup.firstMatching(sections, ADDITIONAL_KEYS));
}
private void addMain(SectionBuilder main, List sections) {
main.spacing(9);
- addExperience(main, findSection(sections, EXPERIENCE_KEYS));
- addProjects(main, findSection(sections, PROJECT_KEYS));
+ addExperience(main, SectionLookup.firstMatching(sections, EXPERIENCE_KEYS));
+ addProjects(main, SectionLookup.firstMatching(sections, PROJECT_KEYS));
}
private void addSkills(SectionBuilder parent, CvSection section) {
@@ -354,8 +352,9 @@ private void addSkills(SectionBuilder parent, CvSection section) {
return;
}
- parent.addSection("CvV2NordicCleanSkills", host -> {
- addHeading(host, "Skills", 82);
+ SectionModule.upperRule(parent, "CvV2NordicCleanSkills", "Skills",
+ theme, sectionTitleStyle(), options.accentColor(), 82,
+ host -> {
for (SkillGroup group : skills.groups()) {
addLabelValueLine(host, group.category(),
group.skillsInline(), 7.15, 1.05);
@@ -369,34 +368,17 @@ private void addEducation(SectionBuilder parent, CvSection section) {
return;
}
- parent.addSection("CvV2NordicCleanEducation", host -> {
- addHeading(host, "Education", 82);
+ SectionModule.upperRule(parent, "CvV2NordicCleanEducation",
+ "Education", theme, sectionTitleStyle(),
+ options.accentColor(), 82, host -> {
for (CvEntry entry : education.entries()) {
- host.addParagraph(paragraph -> paragraph
- .textStyle(bodyStyle(7.05, theme.palette().ink()))
- .lineSpacing(1.05)
- .margin(DocumentInsets.bottom(2))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(entry.title()),
- style(theme.typography().bodyFont(),
- 7.05,
- DocumentTextDecoration.BOLD,
- theme.palette().ink()));
- if (!entry.subtitle().isBlank()) {
- rich.style(" / "
- + stripBasicMarkdown(
- entry.subtitle()),
- bodyStyle(7.05,
- theme.palette().muted()));
- }
- if (!entry.date().isBlank()) {
- rich.style(" / "
- + stripBasicMarkdown(
- entry.date()),
- bodyStyle(6.85,
- theme.palette().muted()));
- }
- }));
+ EntryCompactRenderer.slashSubtitleDate(host, entry,
+ CvTextStyles.of(theme.typography().bodyFont(), 7.05,
+ DocumentTextDecoration.BOLD,
+ theme.palette().ink()),
+ bodyStyle(7.05, theme.palette().muted()),
+ bodyStyle(6.85, theme.palette().muted()),
+ 1.05, DocumentInsets.bottom(2));
}
});
}
@@ -406,8 +388,9 @@ private void addAdditional(SectionBuilder parent, CvSection section) {
return;
}
- parent.addSection("CvV2NordicCleanAdditional", host -> {
- addHeading(host, "Additional", 82);
+ SectionModule.upperRule(parent, "CvV2NordicCleanAdditional",
+ "Additional", theme, sectionTitleStyle(),
+ options.accentColor(), 82, host -> {
for (CvRow row : rows.rows()) {
addLabelValueLine(host, row.label(), row.body(),
7.1, 1.05);
@@ -421,8 +404,9 @@ private void addExperience(SectionBuilder parent, CvSection section) {
return;
}
- parent.addSection("CvV2NordicCleanExperience", host -> {
- addHeading(host, "Experience", 130);
+ SectionModule.upperRule(parent, "CvV2NordicCleanExperience",
+ "Experience", theme, sectionTitleStyle(),
+ options.accentColor(), 130, host -> {
for (CvEntry entry : entries.entries()) {
addWorkEntry(host, entry);
}
@@ -435,8 +419,9 @@ private void addProjects(SectionBuilder parent, CvSection section) {
return;
}
- parent.addSection("CvV2NordicCleanProjects", host -> {
- addHeading(host, "Selected Projects", 130);
+ SectionModule.upperRule(parent, "CvV2NordicCleanProjects",
+ "Selected Projects", theme, sectionTitleStyle(),
+ options.accentColor(), 130, host -> {
for (CvRow row : projects.rows()) {
addProject(host, row);
}
@@ -444,196 +429,75 @@ private void addProjects(SectionBuilder parent, CvSection section) {
}
private void addWorkEntry(SectionBuilder host, CvEntry entry) {
- host.addParagraph(paragraph -> paragraph
- .textStyle(theme.entryTitleStyle())
- .margin(DocumentInsets.zero())
- .rich(rich -> {
- rich.style(stripBasicMarkdown(entry.title()),
- theme.entryTitleStyle());
- if (!entry.date().isBlank()) {
- rich.style(" / " + stripBasicMarkdown(entry.date()),
- style(theme.typography().bodyFont(),
- theme.typography().sizeEntryDate(),
- DocumentTextDecoration.BOLD,
- options.accentColor()));
- }
- }));
- if (!entry.subtitle().isBlank()) {
- host.addParagraph(paragraph -> paragraph
- .text(stripBasicMarkdown(entry.subtitle()))
- .textStyle(theme.entrySubtitleStyle())
- .margin(DocumentInsets.zero()));
- }
- if (!entry.body().isBlank()) {
- host.addParagraph(paragraph -> paragraph
- .textStyle(theme.bodyStyle())
- .lineSpacing(theme.typography().bodyLineSpacing())
- .margin(DocumentInsets.bottom(5))
- .rich(rich -> appendMarkdown(rich, entry.body(),
- theme.bodyStyle())));
- }
+ EntryCompactRenderer.titleDateBody(host, entry,
+ theme.entryTitleStyle(),
+ CvTextStyles.of(theme.typography().bodyFont(),
+ theme.typography().sizeEntryDate(),
+ DocumentTextDecoration.BOLD,
+ options.accentColor()),
+ theme.entrySubtitleStyle(),
+ theme.bodyStyle(),
+ " / ",
+ 1.0,
+ DocumentInsets.zero(),
+ DocumentInsets.zero(),
+ DocumentInsets.bottom(5),
+ theme.typography().bodyLineSpacing(),
+ false);
}
private void addProject(SectionBuilder host, CvRow row) {
- TitleAndStack title = splitTitleAndStack(row.label());
- DocumentTextStyle projectTitle = style(theme.typography().bodyFont(),
- 7.35, DocumentTextDecoration.BOLD, theme.palette().ink());
- DocumentTextStyle context = bodyStyle(6.95, theme.palette().muted());
- DocumentTextStyle body = bodyStyle(7.2, theme.palette().ink());
-
- host.addParagraph(paragraph -> paragraph
- .textStyle(body)
- .lineSpacing(1.08)
- .margin(DocumentInsets.bottom(3))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(title.title()),
- projectTitle);
- if (!title.stack().isBlank()) {
- rich.style(" (" + stripBasicMarkdown(title.stack())
- + ")", context);
- }
- if (!row.body().isBlank()) {
- rich.style(" - ", body);
- appendMarkdown(rich, row.body(), body);
- }
- }));
- }
-
- private void addHeading(SectionBuilder host, String title,
- double ruleWidth) {
- host.spacing(3)
- .addParagraph(paragraph -> paragraph
- .text(title.toUpperCase(java.util.Locale.ROOT))
- .textStyle(sectionTitleStyle())
- .margin(DocumentInsets.zero()))
- .addLine(line -> line
- .horizontal(ruleWidth)
- .color(options.accentColor())
- .thickness(theme.spacing().accentRuleWidth())
- .margin(DocumentInsets.bottom(2)));
+ ProjectRenderer.inline(host, row,
+ CvTextStyles.of(theme.typography().bodyFont(), 7.35,
+ DocumentTextDecoration.BOLD, theme.palette().ink()),
+ bodyStyle(6.95, theme.palette().muted()),
+ bodyStyle(7.2, theme.palette().ink()),
+ 1.08, DocumentInsets.bottom(3));
}
private void addLabelValueLine(SectionBuilder host, String label,
String value, double size,
double lineSpacing) {
- DocumentTextStyle labelStyle = style(theme.typography().bodyFont(),
+ DocumentTextStyle labelStyle = CvTextStyles.of(theme.typography().bodyFont(),
size, DocumentTextDecoration.BOLD, theme.palette().ink());
DocumentTextStyle valueStyle = bodyStyle(size,
theme.palette().muted());
- host.addParagraph(paragraph -> paragraph
- .textStyle(valueStyle)
- .lineSpacing(lineSpacing)
- .margin(DocumentInsets.bottom(1.5))
- .rich(rich -> {
- rich.style(stripBasicMarkdown(label) + ":",
- labelStyle);
- if (value != null && !value.isBlank()) {
- rich.style(" ", valueStyle);
- appendMarkdown(rich, value, valueStyle);
- }
- }));
+ LabelValueRenderer.render(host, label, value, labelStyle,
+ valueStyle, lineSpacing, DocumentInsets.bottom(1.5));
}
private DocumentTextStyle headlineStyle() {
- return style(theme.typography().headlineFont(),
+ return CvTextStyles.of(theme.typography().headlineFont(),
theme.typography().sizeHeadline(),
DocumentTextDecoration.BOLD,
theme.palette().ink());
}
private DocumentTextStyle sectionTitleStyle() {
- return style(theme.typography().headlineFont(),
+ return CvTextStyles.of(theme.typography().headlineFont(),
theme.typography().sizeBanner(),
DocumentTextDecoration.BOLD,
options.accentColor());
}
private DocumentTextStyle contactMetaStyle() {
- return style(theme.typography().bodyFont(),
+ return CvTextStyles.of(theme.typography().bodyFont(),
theme.typography().sizeContact(),
DocumentTextDecoration.DEFAULT,
theme.palette().muted());
}
private DocumentTextStyle contactLinkStyle() {
- return style(theme.typography().bodyFont(),
+ return CvTextStyles.of(theme.typography().bodyFont(),
theme.typography().sizeContact(),
DocumentTextDecoration.UNDERLINE,
options.accentColor());
}
private DocumentTextStyle bodyStyle(double size, DocumentColor color) {
- return style(theme.typography().bodyFont(), size,
+ return CvTextStyles.of(theme.typography().bodyFont(), size,
DocumentTextDecoration.DEFAULT, color);
}
}
-
- private static void appendMarkdown(RichText rich, String text,
- DocumentTextStyle baseStyle) {
- MarkdownInline.append(rich, text == null ? "" : text.trim(), baseStyle);
- }
-
- private static CvSection findSection(List sections,
- List keys) {
- for (CvSection section : sections) {
- String normalizedTitle = normalize(section.title());
- for (String key : keys) {
- if (normalizedTitle.contains(normalize(key))) {
- return section;
- }
- }
- }
- return null;
- }
-
- private static DocumentTextStyle style(FontName font, double size,
- DocumentTextDecoration decoration,
- DocumentColor color) {
- return DocumentTextStyle.builder()
- .fontName(font)
- .size(size)
- .decoration(decoration)
- .color(color)
- .build();
- }
-
- private static TitleAndStack splitTitleAndStack(String value) {
- String title = value == null ? "" : value.trim();
- String stack = "";
- int open = title.indexOf('(');
- int close = title.lastIndexOf(')');
- if (open > 0 && close > open) {
- stack = title.substring(open + 1, close).trim();
- title = title.substring(0, open).trim();
- }
- return new TitleAndStack(title, stack);
- }
-
- private static String stripBasicMarkdown(String value) {
- if (value == null) {
- return "";
- }
- return value
- .replace("**", "")
- .replace("__", "")
- .replace("`", "")
- .replace("*", "")
- .replace("_", "");
- }
-
- private static String normalize(String value) {
- String safe = value == null ? "" : value;
- StringBuilder builder = new StringBuilder(safe.length());
- for (int i = 0; i < safe.length(); i++) {
- char current = Character.toLowerCase(safe.charAt(i));
- if (Character.isLetterOrDigit(current)) {
- builder.append(current);
- }
- }
- return builder.toString();
- }
-
- private record TitleAndStack(String title, String stack) {
- }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/FlowSectionHeader.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/FlowSectionHeader.java
new file mode 100644
index 00000000..4216e2f1
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/FlowSectionHeader.java
@@ -0,0 +1,124 @@
+package com.demcha.compose.document.templates.cv.v2.widgets;
+
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+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.DocumentTextStyle;
+import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+
+/**
+ * Page-flow section-header widget for presets whose title treatment
+ * includes full-width rules outside the section body.
+ *
+ * {@link SectionHeader} renders into an existing
+ * {@code SectionBuilder}. This widget owns the surrounding
+ * {@code PageFlowBuilder.addLine(...)} calls too, so presets such as
+ * Blue Banner and Editorial Blue do not repeat the same top-rule,
+ * title-section, bottom-rule plumbing.
+ */
+public final class FlowSectionHeader {
+
+ private FlowSectionHeader() {
+ }
+
+ /**
+ * Renders a filled banner title with a rule above and below.
+ * Visual signature of the Blue Banner preset.
+ */
+ public static void banner(PageFlowBuilder flow,
+ String name,
+ String title,
+ double ruleWidth,
+ CvTheme theme,
+ DocumentTextStyle titleStyle,
+ DocumentInsets topRuleMargin,
+ DocumentInsets bottomRuleMargin) {
+ banner(flow, name, title, ruleWidth, theme, titleStyle,
+ theme.palette().rule(), topRuleMargin, bottomRuleMargin);
+ }
+
+ /**
+ * Renders a filled banner title with caller-controlled rule colour.
+ */
+ public static void banner(PageFlowBuilder flow,
+ String name,
+ String title,
+ double ruleWidth,
+ CvTheme theme,
+ DocumentTextStyle titleStyle,
+ DocumentColor ruleColor,
+ DocumentInsets topRuleMargin,
+ DocumentInsets bottomRuleMargin) {
+ addRule(flow, name + "RuleTop", ruleWidth, ruleColor, theme,
+ topRuleMargin);
+ flow.addSection(name, host -> SectionHeader.fullWidthBanner(host,
+ title, theme, titleStyle));
+ addRule(flow, name + "RuleBottom", ruleWidth, ruleColor, theme,
+ bottomRuleMargin);
+ }
+
+ /**
+ * Renders a plain left-aligned title between horizontal rules.
+ * Visual signature of the Editorial Blue preset.
+ */
+ public static void label(PageFlowBuilder flow,
+ String name,
+ String title,
+ double ruleWidth,
+ CvTheme theme,
+ DocumentTextStyle titleStyle,
+ DocumentInsets topRuleMargin,
+ DocumentInsets titlePadding,
+ DocumentInsets bottomRuleMargin,
+ boolean withTopRule) {
+ label(flow, name, title, ruleWidth, theme, titleStyle,
+ theme.palette().rule(), topRuleMargin, titlePadding,
+ bottomRuleMargin, withTopRule);
+ }
+
+ /**
+ * Renders a plain left-aligned title with caller-controlled rule
+ * colour.
+ */
+ public static void label(PageFlowBuilder flow,
+ String name,
+ String title,
+ double ruleWidth,
+ CvTheme theme,
+ DocumentTextStyle titleStyle,
+ DocumentColor ruleColor,
+ DocumentInsets topRuleMargin,
+ DocumentInsets titlePadding,
+ DocumentInsets bottomRuleMargin,
+ boolean withTopRule) {
+ if (withTopRule) {
+ addRule(flow, name + "RuleTop", ruleWidth, ruleColor, theme,
+ topRuleMargin);
+ }
+ flow.addSection(name, section -> section
+ .spacing(0)
+ .padding(titlePadding)
+ .addParagraph(paragraph -> paragraph
+ .text(title)
+ .textStyle(titleStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero())));
+ addRule(flow, name + "RuleBottom", ruleWidth, ruleColor, theme,
+ bottomRuleMargin);
+ }
+
+ private static void addRule(PageFlowBuilder flow,
+ String name,
+ double width,
+ DocumentColor color,
+ CvTheme theme,
+ DocumentInsets margin) {
+ flow.addLine(line -> line
+ .name(name)
+ .horizontal(width)
+ .color(color)
+ .thickness(theme.spacing().accentRuleWidth())
+ .margin(margin));
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Masthead.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Masthead.java
new file mode 100644
index 00000000..765c0803
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Masthead.java
@@ -0,0 +1,215 @@
+package com.demcha.compose.document.templates.cv.v2.widgets;
+
+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.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+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.theme.CvTheme;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * CV masthead widget for centred editorial headers: name, optional
+ * job title, compact contact metadata, and a separate link row.
+ */
+public final class Masthead {
+
+ private Masthead() {
+ }
+
+ public static void centered(SectionBuilder host,
+ CvIdentity identity,
+ CvTheme theme,
+ Style style) {
+ Style safeStyle = style == null ? Style.defaults(theme) : style;
+ DocumentTextStyle nameStyle = safeStyle.nameStyle() == null
+ ? theme.headlineStyle()
+ : safeStyle.nameStyle();
+ Headline.uppercaseCentered(host, identity.name(), theme,
+ nameStyle);
+ addOptionalLine(host, identity.jobTitle(), safeStyle.titleStyle(),
+ safeStyle.lineMargin());
+ addOptionalLine(host,
+ join(safeStyle.metaJoiner(),
+ identity.contact().phone(),
+ identity.contact().address()),
+ safeStyle.metaStyle(), safeStyle.lineMargin());
+ addLinkRow(host, identity, theme, safeStyle);
+ }
+
+ private static void addOptionalLine(SectionBuilder host,
+ String text,
+ DocumentTextStyle style,
+ DocumentInsets margin) {
+ if (style == null || text == null || text.isBlank()) {
+ return;
+ }
+ host.addParagraph(paragraph -> paragraph
+ .text(text)
+ .textStyle(style)
+ .align(TextAlign.CENTER)
+ .margin(margin));
+ }
+
+ private static void addLinkRow(SectionBuilder host,
+ CvIdentity identity,
+ CvTheme theme,
+ Style style) {
+ List parts = linkParts(identity);
+ if (parts.isEmpty()) {
+ return;
+ }
+
+ DocumentTextStyle base = style.metaStyle() == null
+ ? theme.contactStyle()
+ : style.metaStyle();
+ DocumentTextStyle linkStyle = style.linkStyle() == null
+ ? base
+ : style.linkStyle();
+ DocumentTextStyle separatorStyle = style.separatorStyle() == null
+ ? base
+ : style.separatorStyle();
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(base)
+ .align(TextAlign.CENTER)
+ .margin(style.lineMargin())
+ .rich(rich -> {
+ for (int i = 0; i < parts.size(); i++) {
+ LinkPart part = parts.get(i);
+ if (part.link() == null) {
+ rich.style(part.text(), base);
+ } else {
+ rich.with(part.text(), linkStyle, part.link());
+ }
+ if (i < parts.size() - 1) {
+ rich.style(theme.decoration().contactSeparator(),
+ separatorStyle);
+ }
+ }
+ }));
+ }
+
+ private static List linkParts(CvIdentity identity) {
+ List parts = new ArrayList<>();
+ String email = identity.contact().email();
+ if (!email.isBlank()) {
+ parts.add(new LinkPart(email,
+ new DocumentLinkOptions("mailto:" + email)));
+ }
+ for (CvLink link : identity.links()) {
+ if (!link.label().isBlank()) {
+ parts.add(new LinkPart(link.label(), link.url().isBlank()
+ ? null
+ : new DocumentLinkOptions(link.url())));
+ }
+ }
+ return parts;
+ }
+
+ private static String join(String separator, String... values) {
+ StringBuilder out = new StringBuilder();
+ for (String value : values) {
+ if (value == null || value.isBlank()) {
+ continue;
+ }
+ if (!out.isEmpty()) {
+ out.append(separator);
+ }
+ out.append(value.trim());
+ }
+ return out.toString();
+ }
+
+ /**
+ * Styling knobs for the centred masthead.
+ */
+ public record Style(DocumentTextStyle nameStyle,
+ DocumentTextStyle titleStyle,
+ DocumentTextStyle metaStyle,
+ DocumentTextStyle linkStyle,
+ DocumentTextStyle separatorStyle,
+ String metaJoiner,
+ DocumentInsets lineMargin) {
+
+ public Style {
+ metaJoiner = metaJoiner == null ? " - " : metaJoiner;
+ lineMargin = lineMargin == null
+ ? DocumentInsets.zero()
+ : lineMargin;
+ }
+
+ public static Style defaults(CvTheme theme) {
+ return builder()
+ .nameStyle(theme.headlineStyle())
+ .titleStyle(theme.bodyStyle())
+ .metaStyle(theme.contactStyle())
+ .linkStyle(theme.contactStyle())
+ .separatorStyle(theme.contactSeparatorStyle())
+ .build();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private DocumentTextStyle nameStyle;
+ private DocumentTextStyle titleStyle;
+ private DocumentTextStyle metaStyle;
+ private DocumentTextStyle linkStyle;
+ private DocumentTextStyle separatorStyle;
+ private String metaJoiner = " - ";
+ private DocumentInsets lineMargin = DocumentInsets.zero();
+
+ private Builder() {
+ }
+
+ public Builder nameStyle(DocumentTextStyle value) {
+ this.nameStyle = value;
+ return this;
+ }
+
+ public Builder titleStyle(DocumentTextStyle value) {
+ this.titleStyle = value;
+ return this;
+ }
+
+ public Builder metaStyle(DocumentTextStyle value) {
+ this.metaStyle = value;
+ return this;
+ }
+
+ public Builder linkStyle(DocumentTextStyle value) {
+ this.linkStyle = value;
+ return this;
+ }
+
+ public Builder separatorStyle(DocumentTextStyle value) {
+ this.separatorStyle = value;
+ return this;
+ }
+
+ public Builder metaJoiner(String value) {
+ this.metaJoiner = value;
+ return this;
+ }
+
+ public Builder lineMargin(DocumentInsets value) {
+ this.lineMargin = value;
+ return this;
+ }
+
+ public Style build() {
+ return new Style(nameStyle, titleStyle, metaStyle, linkStyle,
+ separatorStyle, metaJoiner, lineMargin);
+ }
+ }
+ }
+
+ private record LinkPart(String text, DocumentLinkOptions link) {
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ProfileBand.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ProfileBand.java
new file mode 100644
index 00000000..54537e45
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ProfileBand.java
@@ -0,0 +1,212 @@
+package com.demcha.compose.document.templates.cv.v2.widgets;
+
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentCornerRadius;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.cv.v2.components.MarkdownInline;
+
+import java.util.Objects;
+
+/**
+ * CV profile/summary band widget: a titled rich-text block with
+ * optional fill and accents.
+ */
+public final class ProfileBand {
+ private ProfileBand() {
+ }
+
+ public static void render(PageFlowBuilder flow,
+ String name,
+ String title,
+ String body,
+ Style style) {
+ if (body == null || body.isBlank()) {
+ return;
+ }
+ flow.addSection(name, host -> render(host, title, body, style));
+ }
+
+ public static void render(SectionBuilder host,
+ String title,
+ String body,
+ Style style) {
+ if (body == null || body.isBlank()) {
+ return;
+ }
+ Style safeStyle = style == null ? Style.defaults() : style;
+ host.spacing(safeStyle.spacing())
+ .padding(safeStyle.padding());
+ if (safeStyle.fillColor() != null) {
+ host.fillColor(safeStyle.fillColor());
+ }
+ if (safeStyle.cornerRadius() != null) {
+ host.cornerRadius(safeStyle.cornerRadius());
+ }
+ if (safeStyle.accentLeftColor() != null) {
+ host.accentLeft(safeStyle.accentLeftColor(),
+ safeStyle.accentLeftWidth());
+ }
+ if (safeStyle.accentTopColor() != null) {
+ host.accentTop(safeStyle.accentTopColor(),
+ safeStyle.accentTopWidth());
+ }
+ if (safeStyle.accentBottomColor() != null) {
+ host.accentBottom(safeStyle.accentBottomColor(),
+ safeStyle.accentBottomWidth());
+ }
+ DocumentTextStyle bodyStyle = Objects.requireNonNull(
+ safeStyle.bodyStyle(), "ProfileBand bodyStyle");
+ if (title != null && !title.isBlank()
+ && safeStyle.titleStyle() != null) {
+ host.addParagraph(paragraph -> paragraph
+ .text(safeStyle.transformTitle()
+ ? title.toUpperCase(java.util.Locale.ROOT)
+ : title)
+ .textStyle(safeStyle.titleStyle())
+ .align(safeStyle.titleAlign())
+ .margin(DocumentInsets.zero()));
+ }
+ host.addParagraph(paragraph -> paragraph
+ .textStyle(bodyStyle)
+ .lineSpacing(safeStyle.bodyLineSpacing())
+ .align(safeStyle.bodyAlign())
+ .margin(DocumentInsets.zero())
+ .rich(rich -> MarkdownInline.appendTrimmed(rich, body,
+ bodyStyle)));
+ }
+
+ public record Style(double spacing,
+ DocumentInsets padding,
+ DocumentColor fillColor,
+ DocumentCornerRadius cornerRadius,
+ DocumentColor accentLeftColor,
+ double accentLeftWidth,
+ DocumentColor accentTopColor,
+ double accentTopWidth,
+ DocumentColor accentBottomColor,
+ double accentBottomWidth,
+ DocumentTextStyle titleStyle,
+ TextAlign titleAlign,
+ boolean transformTitle,
+ DocumentTextStyle bodyStyle,
+ TextAlign bodyAlign,
+ double bodyLineSpacing) {
+
+ public Style {
+ padding = padding == null ? DocumentInsets.zero() : padding;
+ titleAlign = titleAlign == null ? TextAlign.LEFT : titleAlign;
+ bodyAlign = bodyAlign == null ? TextAlign.LEFT : bodyAlign;
+ }
+
+ public static Style defaults() {
+ return builder().build();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private double spacing;
+ private DocumentInsets padding = DocumentInsets.zero();
+ private DocumentColor fillColor;
+ private DocumentCornerRadius cornerRadius;
+ private DocumentColor accentLeftColor;
+ private double accentLeftWidth;
+ private DocumentColor accentTopColor;
+ private double accentTopWidth;
+ private DocumentColor accentBottomColor;
+ private double accentBottomWidth;
+ private DocumentTextStyle titleStyle;
+ private TextAlign titleAlign = TextAlign.LEFT;
+ private boolean transformTitle;
+ private DocumentTextStyle bodyStyle;
+ private TextAlign bodyAlign = TextAlign.LEFT;
+ private double bodyLineSpacing = 1.0;
+
+ private Builder() {
+ }
+
+ public Builder spacing(double value) {
+ this.spacing = value;
+ return this;
+ }
+
+ public Builder padding(DocumentInsets value) {
+ this.padding = value;
+ return this;
+ }
+
+ public Builder fillColor(DocumentColor value) {
+ this.fillColor = value;
+ return this;
+ }
+
+ public Builder cornerRadius(DocumentCornerRadius value) {
+ this.cornerRadius = value;
+ return this;
+ }
+
+ public Builder accentLeft(DocumentColor color, double width) {
+ this.accentLeftColor = color;
+ this.accentLeftWidth = width;
+ return this;
+ }
+
+ public Builder accentTop(DocumentColor color, double width) {
+ this.accentTopColor = color;
+ this.accentTopWidth = width;
+ return this;
+ }
+
+ public Builder accentBottom(DocumentColor color, double width) {
+ this.accentBottomColor = color;
+ this.accentBottomWidth = width;
+ return this;
+ }
+
+ public Builder titleStyle(DocumentTextStyle value) {
+ this.titleStyle = value;
+ return this;
+ }
+
+ public Builder titleAlign(TextAlign value) {
+ this.titleAlign = value;
+ return this;
+ }
+
+ public Builder transformTitle(boolean value) {
+ this.transformTitle = value;
+ return this;
+ }
+
+ public Builder bodyStyle(DocumentTextStyle value) {
+ this.bodyStyle = value;
+ return this;
+ }
+
+ public Builder bodyAlign(TextAlign value) {
+ this.bodyAlign = value;
+ return this;
+ }
+
+ public Builder bodyLineSpacing(double value) {
+ this.bodyLineSpacing = value;
+ return this;
+ }
+
+ public Style build() {
+ return new Style(spacing, padding, fillColor, cornerRadius,
+ accentLeftColor, accentLeftWidth,
+ accentTopColor, accentTopWidth,
+ accentBottomColor, accentBottomWidth,
+ titleStyle, titleAlign, transformTitle,
+ bodyStyle, bodyAlign, bodyLineSpacing);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java
index c21bdd76..839dc7a0 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionHeader.java
@@ -37,6 +37,12 @@
* {@link #tickLabel} — a short accent tick above a compact
* uppercase label. Used by command-card presets such as
* {@code CompactMono}.
+ * {@link #upperRule} — uppercase label followed by a short
+ * rule. Used by side-rail presets such as
+ * {@code NordicClean}.
+ * {@link #spacedCapsRule} — spaced-caps label followed by a
+ * short rule. Used by quiet classic modules such as
+ * {@code ClassicSerif}.
*
*
* Unlike {@link Headline} (one rendering shape, two text
@@ -238,4 +244,45 @@ public static void tickLabel(SectionBuilder host, String title,
.align(TextAlign.LEFT)
.margin(DocumentInsets.zero()));
}
+
+ /**
+ * Uppercase label followed by a short rule. Used by side-rail
+ * presets where a section title is a compact block heading rather
+ * than a full-width page-flow banner.
+ */
+ public static void upperRule(SectionBuilder host, String title,
+ CvTheme theme, DocumentTextStyle titleStyle,
+ DocumentColor ruleColor, double ruleWidth) {
+ host.spacing(3)
+ .addParagraph(paragraph -> paragraph
+ .text(title.toUpperCase(Locale.ROOT))
+ .textStyle(titleStyle)
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero()))
+ .addLine(line -> line
+ .horizontal(ruleWidth)
+ .color(ruleColor)
+ .thickness(theme.spacing().accentRuleWidth())
+ .margin(DocumentInsets.bottom(2)));
+ }
+
+ /**
+ * Left-aligned spaced-caps label followed by a short rule. Used by
+ * classic/editorial CV modules that need a quiet title plus a
+ * small accent underline.
+ */
+ public static void spacedCapsRule(SectionBuilder host, String title,
+ CvTheme theme, DocumentTextStyle titleStyle,
+ DocumentColor ruleColor,
+ double ruleWidth,
+ double ruleThickness,
+ DocumentInsets ruleMargin) {
+ flatSpacedCaps(host, title, ruleColor, theme, titleStyle);
+ host.addLine(line -> line
+ .name("CvV2SectionHeaderSpacedCapsRule")
+ .horizontal(ruleWidth)
+ .color(ruleColor)
+ .thickness(ruleThickness)
+ .margin(ruleMargin));
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionModule.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionModule.java
new file mode 100644
index 00000000..1f0b550a
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionModule.java
@@ -0,0 +1,65 @@
+package com.demcha.compose.document.templates.cv.v2.widgets;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * Small module wrapper for CV side rails and card interiors: create a
+ * named section, render a reusable {@link SectionHeader} variant, then
+ * let the caller draw the body.
+ */
+public final class SectionModule {
+
+ private SectionModule() {
+ }
+
+ /**
+ * Module headed by {@link SectionHeader#tickLabel}; useful for
+ * compact card/terminal layouts.
+ */
+ public static void tick(SectionBuilder parent,
+ String name,
+ String title,
+ CvTheme theme,
+ DocumentColor color,
+ double tickWidth,
+ DocumentTextStyle titleStyle,
+ Consumer body) {
+ render(parent, name, host -> SectionHeader.tickLabel(host, title,
+ theme, color, tickWidth, titleStyle), body);
+ }
+
+ /**
+ * Module headed by {@link SectionHeader#upperRule}; useful for
+ * clean side-rail layouts.
+ */
+ public static void upperRule(SectionBuilder parent,
+ String name,
+ String title,
+ CvTheme theme,
+ DocumentTextStyle titleStyle,
+ DocumentColor ruleColor,
+ double ruleWidth,
+ Consumer body) {
+ render(parent, name, host -> SectionHeader.upperRule(host, title,
+ theme, titleStyle, ruleColor, ruleWidth), body);
+ }
+
+ private static void render(SectionBuilder parent,
+ String name,
+ Consumer heading,
+ Consumer body) {
+ Objects.requireNonNull(parent, "parent");
+ Objects.requireNonNull(heading, "heading");
+ Objects.requireNonNull(body, "body");
+ parent.addSection(name, host -> {
+ heading.accept(host);
+ body.accept(host);
+ });
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/package-info.java
index af7277f8..f170e9ae 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/package-info.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/package-info.java
@@ -53,17 +53,31 @@
* ({@code centered}, {@code rightAligned},
* {@code leftAligned}, {@code rightAlignedStacked},
* {@code twoRowRightAligned}).
+ * {@link com.demcha.compose.document.templates.cv.v2.widgets.Masthead}
+ * — centred editorial identity block: name, optional title,
+ * compact metadata, and link row.
* {@link com.demcha.compose.document.templates.cv.v2.widgets.SectionHeader}
- * — section title in 6 variants ({@code banner},
+ * — section title in 8 variants ({@code banner},
* {@code fullWidthBanner}, {@code underlined}, {@code flat},
- * {@code flatSpacedCaps}, {@code tickLabel}).
+ * {@code flatSpacedCaps}, {@code tickLabel},
+ * {@code upperRule}, {@code spacedCapsRule}).
+ * {@link com.demcha.compose.document.templates.cv.v2.widgets.FlowSectionHeader}
+ * — page-flow-level section headers where rules live outside
+ * the section body.
+ * {@link com.demcha.compose.document.templates.cv.v2.widgets.ProfileBand}
+ * — tinted/ruled summary block with markdown-aware body text.
+ * {@link com.demcha.compose.document.templates.cv.v2.widgets.SectionModule}
+ * — named module wrapper that pairs a {@code SectionHeader}
+ * variant with caller-supplied body content.
*
*
* Generic widgets that are useful beyond CVs live in
* {@link com.demcha.compose.document.templates.widgets}; for example
* {@link com.demcha.compose.document.templates.widgets.TableWidget}
* provides configurable fixed-column and grid tables with border,
- * fill, zebra, padding, and typography options.
+ * fill, zebra, padding, and typography options, while
+ * {@link com.demcha.compose.document.templates.widgets.CardWidget}
+ * provides a reusable styled card/container shell.
*
* Each widget delegates internally to the lower-level renderers
* in {@code cv/v2/components/} where helpful, but its public face
diff --git a/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java b/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java
new file mode 100644
index 00000000..30c52007
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java
@@ -0,0 +1,117 @@
+package com.demcha.compose.document.templates.widgets;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentCornerRadius;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * Shared card/container widget for template presets.
+ *
+ *
The widget captures the reusable visual shell only: spacing,
+ * padding, fill, stroke, and corner radius. The caller still supplies
+ * the card body so CVs, proposals, invoices, and cover letters can
+ * reuse the same shell without sharing document-specific content
+ * logic.
+ */
+public final class CardWidget {
+ private CardWidget() {
+ }
+
+ public static void render(SectionBuilder parent,
+ String name,
+ Style style,
+ Consumer content) {
+ Objects.requireNonNull(parent, "parent");
+ Objects.requireNonNull(content, "content");
+ Style safeStyle = style == null ? Style.builder().build() : style;
+
+ parent.addSection(name, card -> {
+ card.spacing(safeStyle.spacing())
+ .padding(safeStyle.padding());
+ if (safeStyle.fillColor() != null) {
+ card.fillColor(safeStyle.fillColor());
+ }
+ if (safeStyle.stroke() != null) {
+ card.stroke(safeStyle.stroke());
+ }
+ if (safeStyle.cornerRadius() != null) {
+ card.cornerRadius(safeStyle.cornerRadius());
+ }
+ content.accept(card);
+ });
+ }
+
+ /**
+ * Visual shell options for {@link CardWidget}.
+ */
+ public record Style(double spacing,
+ DocumentInsets padding,
+ DocumentColor fillColor,
+ DocumentStroke stroke,
+ DocumentCornerRadius cornerRadius) {
+
+ public Style {
+ if (Double.isNaN(spacing) || Double.isInfinite(spacing)
+ || spacing < 0) {
+ throw new IllegalArgumentException(
+ "spacing must be finite and non-negative");
+ }
+ padding = padding == null ? DocumentInsets.zero() : padding;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private double spacing;
+ private DocumentInsets padding = DocumentInsets.zero();
+ private DocumentColor fillColor;
+ private DocumentStroke stroke;
+ private DocumentCornerRadius cornerRadius;
+
+ private Builder() {
+ }
+
+ public Builder spacing(double value) {
+ this.spacing = value;
+ return this;
+ }
+
+ public Builder padding(DocumentInsets value) {
+ this.padding = value;
+ return this;
+ }
+
+ public Builder fillColor(DocumentColor value) {
+ this.fillColor = value;
+ return this;
+ }
+
+ public Builder stroke(DocumentStroke value) {
+ this.stroke = value;
+ return this;
+ }
+
+ public Builder cornerRadius(double value) {
+ this.cornerRadius = DocumentCornerRadius.of(value);
+ return this;
+ }
+
+ public Builder cornerRadius(DocumentCornerRadius value) {
+ this.cornerRadius = value;
+ return this;
+ }
+
+ public Style build() {
+ return new Style(spacing, padding, fillColor, stroke,
+ cornerRadius);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/widgets/package-info.java b/src/main/java/com/demcha/compose/document/templates/widgets/package-info.java
index ec026c47..ade81fe6 100644
--- a/src/main/java/com/demcha/compose/document/templates/widgets/package-info.java
+++ b/src/main/java/com/demcha/compose/document/templates/widgets/package-info.java
@@ -5,5 +5,10 @@
* preset-specific composition. They are deliberately generic: CV,
* proposal, invoice, cover-letter, and future templates can reuse
* them without depending on a CV-only package.
+ *
+ * Current shared widgets include configurable tables
+ * ({@link com.demcha.compose.document.templates.widgets.TableWidget})
+ * and reusable card/container shells
+ * ({@link com.demcha.compose.document.templates.widgets.CardWidget}).
*/
package com.demcha.compose.document.templates.widgets;
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/components/CvV2ComponentUtilityTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/components/CvV2ComponentUtilityTest.java
new file mode 100644
index 00000000..56dd0424
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/components/CvV2ComponentUtilityTest.java
@@ -0,0 +1,75 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+import com.demcha.compose.document.templates.cv.v2.data.CvSection;
+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 org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CvV2ComponentUtilityTest {
+
+ @Test
+ void plainTextRemovesInlineMarkdownMarkers() {
+ assertThat(MarkdownInline.plainText("**Graph** _Compose_ `PDF`"))
+ .isEqualTo("Graph Compose PDF");
+ }
+
+ @Test
+ void labelValueRendererNormalizesTrailingColons() {
+ assertThat(LabelValueRenderer.normalizedLabel("Languages:"))
+ .isEqualTo("Languages");
+ assertThat(LabelValueRenderer.normalizedLabel("**Languages:**"))
+ .isEqualTo("Languages");
+ }
+
+ @Test
+ void projectLabelSplitsTrailingStack() {
+ ProjectLabel label = ProjectLabel.parse(
+ "**GraphCompose** (Java 21, PDFBox, Maven)");
+
+ assertThat(label.title()).isEqualTo("GraphCompose");
+ assertThat(label.stack()).isEqualTo("Java 21, PDFBox, Maven");
+ }
+
+ @Test
+ void projectLabelLeavesPlainTitlesUntouched() {
+ ProjectLabel label = ProjectLabel.parse("LayoutLint");
+
+ assertThat(label.title()).isEqualTo("LayoutLint");
+ assertThat(label.stack()).isEmpty();
+ }
+
+ @Test
+ void sectionLookupFindsByNormalizedTitle() {
+ CvSection profile = new ParagraphSection("Professional Profile", "Body");
+ CvSection projects = RowsSection.builder("Selected Projects",
+ RowStyle.PLAIN)
+ .row("GraphCompose", "PDF layout engine")
+ .build();
+
+ CvSection found = SectionLookup.firstMatching(
+ List.of(profile, projects),
+ List.of("projects", "selected projects"));
+
+ assertThat(found).isSameAs(projects);
+ }
+
+ @Test
+ void sectionLookupDetectsEmptyAndNonEmptySections() {
+ assertThat(SectionLookup.hasContent(
+ new ParagraphSection("Summary", ""))).isFalse();
+ assertThat(SectionLookup.hasContent(
+ new ParagraphSection("Summary", "Body"))).isTrue();
+ assertThat(SectionLookup.hasContent(
+ RowsSection.builder("Projects", RowStyle.PLAIN).build()))
+ .isFalse();
+ assertThat(SectionLookup.hasContent(
+ SkillsSection.builder("Technical Skills").build()))
+ .isFalse();
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java
index 5f886ada..984ef065 100644
--- a/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java
@@ -97,6 +97,60 @@ void sectionHeader_variants_render_without_throwing() throws Exception {
SectionHeader.tickLabel(section, "Projects",
CvTheme.compactMono(),
DocumentColor.rgb(0, 126, 151), 22));
+ renderWithSection(section ->
+ SectionHeader.upperRule(section, "Skills",
+ CvTheme.nordicClean(), bodyStyle(),
+ DocumentColor.rgb(28, 128, 135), 64));
+ renderWithSection(section ->
+ SectionHeader.spacedCapsRule(section, "Experience", theme,
+ bodyStyle(), DocumentColor.rgb(126, 93, 52),
+ 72, 1.0, DocumentInsets.zero()));
+ }
+
+ @Test
+ void flowSectionHeader_variants_render_without_throwing() throws Exception {
+ CvTheme theme = CvTheme.blueBanner();
+ renderWithFlow(flow -> FlowSectionHeader.banner(flow, "FlowBanner",
+ "Experience", 240, theme, bodyStyle(),
+ DocumentInsets.top(2), DocumentInsets.bottom(2)));
+ renderWithFlow(flow -> FlowSectionHeader.label(flow, "FlowLabel",
+ "PROJECTS", 240, CvTheme.editorialBlue(), bodyStyle(),
+ DocumentInsets.top(2), DocumentInsets.of(2),
+ DocumentInsets.zero(), true));
+ }
+
+ @Test
+ void moduleAndBand_widgets_render_without_throwing() throws Exception {
+ CvTheme theme = CvTheme.nordicClean();
+ renderWithSection(section -> ProfileBand.render(section, "Profile",
+ "**Markdown** body", ProfileBand.Style.builder()
+ .titleStyle(bodyStyle())
+ .bodyStyle(bodyStyle())
+ .accentLeft(DocumentColor.rgb(28, 128, 135), 2)
+ .build()));
+ renderWithSection(section -> SectionModule.tick(section, "Tick",
+ "Skills", CvTheme.compactMono(),
+ DocumentColor.rgb(0, 126, 151), 24, bodyStyle(),
+ body -> body.addParagraph(p -> p.text("Java").textStyle(bodyStyle()))));
+ renderWithSection(section -> SectionModule.upperRule(section, "Rule",
+ "Experience", theme, bodyStyle(),
+ DocumentColor.rgb(28, 128, 135), 72,
+ body -> body.addParagraph(p -> p.text("Senior Engineer")
+ .textStyle(bodyStyle()))));
+ }
+
+ @Test
+ void masthead_renders_without_throwing() throws Exception {
+ CvTheme theme = CvTheme.editorialBlue();
+ renderWithSection(section -> Masthead.centered(section, identity(),
+ theme, Masthead.Style.builder()
+ .nameStyle(bodyStyle())
+ .titleStyle(bodyStyle())
+ .metaStyle(bodyStyle())
+ .linkStyle(underlinedLinkStyle())
+ .separatorStyle(bodyStyle())
+ .lineMargin(DocumentInsets.top(1))
+ .build()));
}
@Test
@@ -121,6 +175,18 @@ private static void renderWithSection(SectionAction action) throws Exception {
}
}
+ private static void renderWithFlow(FlowAction action) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(420, 595)
+ .margin(DocumentInsets.of(24))
+ .create()) {
+ var flow = session.dsl().pageFlow().name("FlowWidgetTestRoot");
+ action.run(flow);
+ flow.build();
+ assertThat(session.roots()).isNotEmpty();
+ }
+ }
+
private static CvName name() {
return CvName.of("Jane", "Doe");
}
@@ -142,8 +208,22 @@ private static DocumentTextStyle underlinedLinkStyle() {
.build();
}
+ private static DocumentTextStyle bodyStyle() {
+ return DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA)
+ .size(9)
+ .decoration(DocumentTextDecoration.DEFAULT)
+ .color(DocumentColor.rgb(30, 40, 55))
+ .build();
+ }
+
@FunctionalInterface
private interface SectionAction {
void run(com.demcha.compose.document.dsl.SectionBuilder section);
}
+
+ @FunctionalInterface
+ private interface FlowAction {
+ void run(com.demcha.compose.document.dsl.PageFlowBuilder flow);
+ }
}
diff --git a/src/test/java/com/demcha/compose/document/templates/widgets/TableWidgetTest.java b/src/test/java/com/demcha/compose/document/templates/widgets/TableWidgetTest.java
index 65dd8c7b..652e1349 100644
--- a/src/test/java/com/demcha/compose/document/templates/widgets/TableWidgetTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/widgets/TableWidgetTest.java
@@ -4,6 +4,7 @@
import com.demcha.compose.document.api.DocumentSession;
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.font.FontName;
@@ -48,6 +49,22 @@ void grid_table_renders_with_zebra_rows() throws Exception {
.build()));
}
+ @Test
+ void card_widget_renders_with_custom_shell() throws Exception {
+ render(section -> CardWidget.render(section, "SharedWidgetCard",
+ CardWidget.Style.builder()
+ .spacing(4)
+ .padding(new DocumentInsets(6, 8, 6, 8))
+ .fillColor(DocumentColor.rgb(250, 252, 255))
+ .stroke(DocumentStroke.of(
+ DocumentColor.rgb(180, 190, 205), 0.5))
+ .cornerRadius(3)
+ .build(),
+ card -> card.addParagraph(paragraph -> paragraph
+ .text("Reusable card")
+ .textStyle(bodyStyle()))));
+ }
+
private static void render(SectionAction action) throws Exception {
try (DocumentSession session = GraphCompose.document()
.pageSize(320, 420)