From 4b6532092dc7d3b7f6819cb180c25b9cb8d1ba00 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 26 May 2026 09:51:08 +0100 Subject: [PATCH] feat(cv-v2): markdown cleanup, widget extraction, drop preset style wrapper * Route preset rich-text through MarkdownInline / RichParagraphRenderer instead of touching the engine MarkDownParser directly. Presets that used .rich(...) now share one adapter. * Extract shared body renderers (EntryCompactRenderer, ProjectRenderer, LabelValueRenderer, SkillLineRenderer, SkillTableRenderer) and shared widgets (FlowSectionHeader, Masthead, ProfileBand, SectionModule, CardWidget) so v2 presets stay thin orchestrators. * Drop the duplicated 'private static DocumentTextStyle style(...)' wrapper from BlueBanner, ClassicSerif, CompactMono, EditorialBlue, NordicClean. All call sites now use CvTextStyles.of(...) directly and the unused FontName imports are removed. * Inline single-use isEducation / isProjects helpers in EditorialBlue via SectionLookup, and replace inline DocumentTextStyle.builder() for the BlueBanner banner-title with CvTextStyles.of for consistency. All cv/v2 pixel baselines unchanged: CvV2VisualParityTest passes 9/9 without -Dgraphcompose.visual.approve, smoke tests 24/24, full suite 932/932. --- .../templates/v2-layered/authoring-presets.md | 24 +- .../document/templates/cv/v2/AUTHORS.md | 8 + .../cv/v2/components/CvTextStyles.java | 26 ++ .../v2/components/EntryCompactRenderer.java | 178 +++++++++ .../cv/v2/components/LabelValueRenderer.java | 43 ++ .../cv/v2/components/MarkdownInline.java | 26 ++ .../cv/v2/components/ProjectLabel.java | 23 ++ .../cv/v2/components/ProjectRenderer.java | 86 ++++ .../v2/components/RichParagraphRenderer.java | 40 ++ .../cv/v2/components/SectionLookup.java | 61 +++ .../cv/v2/components/SkillLineRenderer.java | 42 ++ .../cv/v2/components/SkillTableRenderer.java | 43 ++ .../cv/v2/components/package-info.java | 5 +- .../templates/cv/v2/presets/BlueBanner.java | 189 ++------- .../cv/v2/presets/CenteredHeadline.java | 24 +- .../templates/cv/v2/presets/ClassicSerif.java | 273 +++---------- .../templates/cv/v2/presets/CompactMono.java | 284 ++++---------- .../cv/v2/presets/EditorialBlue.java | 371 ++++-------------- .../templates/cv/v2/presets/NordicClean.java | 290 ++++---------- .../cv/v2/widgets/FlowSectionHeader.java | 124 ++++++ .../templates/cv/v2/widgets/Masthead.java | 215 ++++++++++ .../templates/cv/v2/widgets/ProfileBand.java | 212 ++++++++++ .../cv/v2/widgets/SectionHeader.java | 47 +++ .../cv/v2/widgets/SectionModule.java | 65 +++ .../templates/cv/v2/widgets/package-info.java | 20 +- .../templates/widgets/CardWidget.java | 117 ++++++ .../templates/widgets/package-info.java | 5 + .../components/CvV2ComponentUtilityTest.java | 75 ++++ .../cv/v2/widgets/WidgetSmokeTest.java | 80 ++++ .../templates/widgets/TableWidgetTest.java | 17 + 30 files changed, 1891 insertions(+), 1122 deletions(-) create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/components/CvTextStyles.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/components/EntryCompactRenderer.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/components/LabelValueRenderer.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabel.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectRenderer.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/components/RichParagraphRenderer.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionLookup.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillLineRenderer.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillTableRenderer.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/FlowSectionHeader.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Masthead.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/ProfileBand.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SectionModule.java create mode 100644 src/main/java/com/demcha/compose/document/templates/widgets/CardWidget.java create mode 100644 src/test/java/com/demcha/compose/document/templates/cv/v2/components/CvV2ComponentUtilityTest.java 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)