From a2ffcfa9efb1f36dc493503e0ef5b15ebb5e92eb Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 25 May 2026 11:32:22 +0200
Subject: [PATCH 1/2] feat(cv-v2): migrate editorial blue preset
---
docs/templates/v2-layered/README.md | 10 +-
.../templates/v2-layered/authoring-presets.md | 7 +-
docs/templates/v2-layered/quickstart.md | 15 +-
docs/templates/v2-layered/using-templates.md | 54 +-
.../examples/support/ExampleDataFactory.java | 48 +-
.../cv/v2/CvEditorialBlueExample.java | 47 ++
.../document/templates/cv/v2/AUTHORS.md | 14 +-
.../cv/v2/components/SectionDispatcher.java | 3 +
.../cv/v2/components/SkillsRenderer.java | 37 ++
.../templates/cv/v2/data/CvIdentity.java | 35 +-
.../templates/cv/v2/data/CvSection.java | 6 +-
.../templates/cv/v2/data/RowStyle.java | 8 +-
.../templates/cv/v2/data/RowsSection.java | 4 +-
.../templates/cv/v2/data/SkillGroup.java | 49 ++
.../templates/cv/v2/data/SkillsSection.java | 81 +++
.../templates/cv/v2/data/package-info.java | 11 +-
.../templates/cv/v2/package-info.java | 15 +-
.../cv/v2/presets/EditorialBlue.java | 561 ++++++++++++++++++
.../templates/cv/v2/theme/CvPalette.java | 13 +
.../templates/cv/v2/theme/CvSpacing.java | 22 +
.../templates/cv/v2/theme/CvTheme.java | 12 +
.../templates/cv/v2/theme/CvTypography.java | 16 +
.../templates/cv/v2/theme/package-info.java | 7 +-
.../templates/cv/v2/widgets/Headline.java | 36 ++
.../templates/cv/v2/widgets/package-info.java | 11 +-
.../templates/widgets/TableWidget.java | 288 +++++++++
.../templates/widgets/package-info.java | 9 +
.../cv/v2/data/SkillsSectionTest.java | 41 ++
.../cv/v2/presets/BoxedSectionsSmokeTest.java | 9 +-
.../cv/v2/presets/CvV2VisualParityTest.java | 47 +-
.../cv/v2/presets/EditorialBlueSmokeTest.java | 89 +++
.../presets/MinimalUnderlinedSmokeTest.java | 7 +-
.../presets/ModernProfessionalSmokeTest.java | 7 +-
.../cv/v2/widgets/WidgetSmokeTest.java | 3 +
.../templates/widgets/TableWidgetTest.java | 77 +++
.../cv-v2-layered/boxed_sections-page-0.png | Bin 100687 -> 100674 bytes
.../cv-v2-layered/editorial_blue-page-0.png | Bin 0 -> 134078 bytes
.../cv-v2-layered/editorial_blue-page-1.png | Bin 0 -> 27087 bytes
.../minimal_underlined-page-0.png | Bin 99160 -> 99148 bytes
.../modern_professional-page-0.png | Bin 149826 -> 149793 bytes
40 files changed, 1583 insertions(+), 116 deletions(-)
create mode 100644 examples/src/main/java/com/demcha/examples/templates/cv/v2/CvEditorialBlueExample.java
create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillsRenderer.java
create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/data/SkillGroup.java
create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSection.java
create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/presets/EditorialBlue.java
create mode 100644 src/main/java/com/demcha/compose/document/templates/widgets/TableWidget.java
create mode 100644 src/main/java/com/demcha/compose/document/templates/widgets/package-info.java
create mode 100644 src/test/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSectionTest.java
create mode 100644 src/test/java/com/demcha/compose/document/templates/cv/v2/presets/EditorialBlueSmokeTest.java
create mode 100644 src/test/java/com/demcha/compose/document/templates/widgets/TableWidgetTest.java
create mode 100644 src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-0.png
create mode 100644 src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-1.png
diff --git a/docs/templates/v2-layered/README.md b/docs/templates/v2-layered/README.md
index 765e91cc..2cbdd8b5 100644
--- a/docs/templates/v2-layered/README.md
+++ b/docs/templates/v2-layered/README.md
@@ -34,10 +34,10 @@ experience, your skills.
→ **[using-templates.md](using-templates.md)**
-You'll learn the `CvDocument` builder API, the three section types
-(paragraph / rows / entries), how slots place sections into columns,
-and how to swap a theme (colours / fonts / glyphs) without forking a
-preset.
+You'll learn the `CvDocument` builder API, the built-in section types
+(paragraph / grouped skills / rows / entries), how slots place
+sections into columns, and how to swap a theme (colours / fonts /
+glyphs) without forking a preset.
### 🎨 You want a custom visual style on top of v2
Existing presets aren't quite your design. You want a new look —
@@ -121,7 +121,7 @@ The detailed contract for each layer is in
glyph" to "add a new section subtype".
- **Examples**:
[`examples/cv/v2/`](../../examples/src/main/java/com/demcha/examples/templates/cv/v2)
- has three runnable rendering examples — one per shipped preset.
+ has runnable rendering examples for the shipped presets.
- **Legacy v1 surface**:
[`docs/templates/v1-classic/README.md`](../v1-classic/README.md) describes the older
spec / preset / theme split used by the v1 templates. Still valid
diff --git a/docs/templates/v2-layered/authoring-presets.md b/docs/templates/v2-layered/authoring-presets.md
index 37662ac0..4bb829ab 100644
--- a/docs/templates/v2-layered/authoring-presets.md
+++ b/docs/templates/v2-layered/authoring-presets.md
@@ -2,9 +2,9 @@
You like the layered architecture, but the shipped presets
(`BoxedSections`, `MinimalUnderlined`, `ModernProfessional`,
-`CenteredHeadline`, `BlueBanner`) don't match the design you want.
-This doc walks you through writing a new preset from scratch —
-**without subclassing, without duplicating rendering code**.
+`CenteredHeadline`, `BlueBanner`, `EditorialBlue`) don't match the
+design you want. This doc walks you through writing a new preset from
+scratch — **without subclassing, without duplicating rendering code**.
If you haven't read [quickstart.md](quickstart.md) and
[using-templates.md](using-templates.md), do those first.
@@ -68,6 +68,7 @@ small set of named variants.
| Variant | Visual |
|---|---|
| `Headline.spacedCentered(host, name, theme)` | Centred letter-spaced uppercase (`J A N E D O E`) |
+| `Headline.uppercaseCentered(host, name, theme)` | Centred uppercase without extra spacing (`JANE DOE`) |
| `Headline.rightAligned(host, name, theme)` | Right-aligned plain bold (`Jane Doe`) |
| `Headline.render(host, name, theme, align, spacedCaps)` | Low-level — any (alignment, transform) combo |
diff --git a/docs/templates/v2-layered/quickstart.md b/docs/templates/v2-layered/quickstart.md
index e6b23c14..ff302044 100644
--- a/docs/templates/v2-layered/quickstart.md
+++ b/docs/templates/v2-layered/quickstart.md
@@ -19,8 +19,8 @@ GraphCompose's templates v2 (layered) gives you:
you can drop into a preset.
- **Presets as compositions** — a preset orchestrates widgets in a
page flow. `BoxedSections`, `MinimalUnderlined`,
- `ModernProfessional`, `CenteredHeadline`, `BlueBanner` ship today;
- writing your own is ~150 lines.
+ `ModernProfessional`, `CenteredHeadline`, `BlueBanner`, and
+ `EditorialBlue` ship today; writing your own is ~150 lines.
You hand a `CvDocument` to a preset, you get a PDF. The preset
internally composes widgets that read theme tokens that ultimately
@@ -49,9 +49,9 @@ CvDocument doc = CvDocument.builder()
.section(new ParagraphSection("Professional Summary",
"Backend engineer with **5 years** of experience building "
+ "high-throughput payment systems."))
- .section(RowsSection.builder("Technical Skills", RowStyle.BULLETED)
- .row("Languages", "Java 21, Kotlin, SQL")
- .row("Frameworks", "Spring Boot, Quarkus")
+ .section(SkillsSection.builder("Technical Skills")
+ .group("Languages", "Java 21", "Kotlin", "SQL")
+ .group("Frameworks", "Spring Boot", "Quarkus")
.build())
.section(EntriesSection.builder("Experience")
.entry("Senior Engineer", "Acme Payments", "2022-Present",
@@ -89,7 +89,7 @@ Same data, different visual. That's the layering.
┌─────────────────────────────────────────────────────────────┐
│ presets/ BoxedSections, MinimalUnderlined, │
│ ModernProfessional, CenteredHeadline, │
-│ BlueBanner │
+│ BlueBanner, EditorialBlue │
│ — composition of widgets in a page flow │
└─────────────────────────────────────────────────────────────┘
│ compose from widgets
@@ -113,7 +113,8 @@ Same data, different visual. That's the layering.
▼
┌─────────────────────────────────────────────────────────────┐
│ data/ CvDocument, CvIdentity, CvSection (sealed), │
-│ ParagraphSection / RowsSection / EntriesSection,│
+│ ParagraphSection / SkillsSection / RowsSection │
+│ / EntriesSection, │
│ CvRow, CvEntry, Slot │
│ — pure records, zero rendering deps │
└─────────────────────────────────────────────────────────────┘
diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md
index 142344dc..1b77d964 100644
--- a/docs/templates/v2-layered/using-templates.md
+++ b/docs/templates/v2-layered/using-templates.md
@@ -12,9 +12,9 @@ it sets up the conceptual model in 5 minutes.
## Table of contents
-1. [The three pieces you assemble](#the-three-pieces-you-assemble)
+1. [The pieces you assemble](#the-pieces-you-assemble)
2. [Identity — name, contact, optional links](#identity)
-3. [Section types — three shapes cover every case](#section-types)
+3. [Section types](#section-types)
4. [Slots — main vs sidebar](#slots)
5. [Picking a preset](#picking-a-preset)
6. [Customising a theme](#customising-a-theme)
@@ -23,7 +23,7 @@ it sets up the conceptual model in 5 minutes.
---
-## The three pieces you assemble
+## The Pieces You Assemble
```java
CvDocument doc = …; // your content
@@ -76,9 +76,9 @@ The widget renders the label; the URL is the click target.
---
-## Section types — three shapes cover every case
+## Section Types
-The `CvSection` sealed hierarchy has exactly **three** concrete
+The `CvSection` sealed hierarchy has a small set of concrete
shapes. Each captures a structurally different content pattern, not
a visual flavour.
@@ -93,15 +93,31 @@ new ParagraphSection("Professional Summary",
Inline markdown (`**bold**`, `*italic*`, `_italic_`) is honoured.
-### 2. `RowsSection` — list of label/body rows with a decoration style
+### 2. `SkillsSection` — grouped skills
-For Technical Skills, Languages, Awards, Additional Information,
-Projects, anything with "label: value" entries.
+For Technical Skills and similar capability groups where the content
+is naturally `category -> skills[]`.
```java
-RowsSection.builder("Technical Skills", RowStyle.BULLETED)
- .row("Languages", "Java 21, Kotlin, SQL")
- .row("Tools", "Maven, Docker, GitHub Actions")
+SkillsSection.builder("Technical Skills")
+ .group("Languages", "Java 21", "Kotlin", "SQL")
+ .group("Tools", "Maven", "Docker", "GitHub Actions")
+ .build();
+```
+
+Keeping skills grouped means the same CV data can render as bullets,
+a grid/table, sidebar chips, or compact inline rows depending on the
+preset.
+
+### 3. `RowsSection` — list of label/body rows with a decoration style
+
+For Languages, Awards, Additional Information, Projects, anything
+with "label: value" entries that is not a skill taxonomy.
+
+```java
+RowsSection.builder("Additional Information", RowStyle.PLAIN)
+ .row("Languages", "English (Fluent), German (Intermediate)")
+ .row("Work Eligibility", "Eligible to work in the UK and the EU")
.build();
```
@@ -114,11 +130,10 @@ RowStyle.BULLETED_STACKED // • Label
// body (on second line, indented)
```
-The same `RowsSection` type covers Technical Skills, Additional
-Information, Projects — pick the style that matches the visual
-density you want.
+The same `RowsSection` type covers Additional Information and
+Projects — pick the style that matches the visual density you want.
-### 3. `EntriesSection` — timeline entries (title / subtitle / date / body)
+### 4. `EntriesSection` — timeline entries (title / subtitle / date / body)
For Education, Professional Experience — anything where you have a
list of items each with a title, subtitle, date, and description.
@@ -159,9 +174,9 @@ CvDocument doc = CvDocument.builder()
```
**Single-column presets** (`BoxedSections`, `MinimalUnderlined`,
-`ModernProfessional`, `CenteredHeadline`, `BlueBanner`) render only
-`Slot.MAIN`. Sidebar content is silently dropped — switch to a
-multi-column preset to render it.
+`ModernProfessional`, `CenteredHeadline`, `BlueBanner`,
+`EditorialBlue`) render only `Slot.MAIN`. Sidebar content is silently
+dropped — switch to a multi-column preset to render it.
If you don't use slots at all, your sections go to `MAIN` and every
preset renders them. The slot model is opt-in.
@@ -171,7 +186,7 @@ preset renders them. The slot model is opt-in.
## Picking a preset
-Five shipped today:
+Six shipped today:
| Preset | Visual signature |
|---|---|
@@ -180,6 +195,7 @@ Five shipped today:
| `ModernProfessional.create()` | Right-aligned big slate-blue name, flat bright-blue bold section titles, dense single page |
| `CenteredHeadline.create()` | Centred spaced-caps name, small subheadline, full-width rules around contact and modules |
| `BlueBanner.create()` | Centred PT-Serif name, compact Lato body, blue full-width section banners between thin rules |
+| `EditorialBlue.create()` | Centred uppercase masthead, optional job-title subtitle, blue editorial rules, compact skills table |
Each factory has a no-arg form (uses a sensible default theme) and
a `create(CvTheme)` form (custom theme).
diff --git a/examples/src/main/java/com/demcha/examples/support/ExampleDataFactory.java b/examples/src/main/java/com/demcha/examples/support/ExampleDataFactory.java
index a762b8c0..c96d9b20 100644
--- a/examples/src/main/java/com/demcha/examples/support/ExampleDataFactory.java
+++ b/examples/src/main/java/com/demcha/examples/support/ExampleDataFactory.java
@@ -18,6 +18,7 @@
import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection;
import com.demcha.compose.document.templates.cv.v2.data.RowStyle;
import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
+import com.demcha.compose.document.templates.cv.v2.data.SkillsSection;
import com.demcha.compose.document.templates.data.common.EmailYaml;
import com.demcha.compose.document.templates.data.common.Header;
import com.demcha.compose.document.templates.data.coverletter.CoverLetterDocumentSpec;
@@ -452,14 +453,16 @@ public static CoverLetterHeader sampleCoverLetterHeaderV2() {
/**
* Returns a sample {@code CvDocument} for the v2 CV pipeline —
- * the canonical Jordan Rivera content expressed in the new
- * 3-section sealed hierarchy
+ * the canonical Jordan Rivera content expressed in the v2
+ * sealed section hierarchy
* ({@link com.demcha.compose.document.templates.cv.v2.data.ParagraphSection},
+ * {@link SkillsSection},
* {@link RowsSection},
* {@link EntriesSection}).
*
- * Each section explicitly picks its visual decoration —
- * Technical Skills uses {@link RowStyle#BULLETED}, Projects uses
+ *
Technical Skills is grouped semantically via
+ * {@link SkillsSection}; row-style sections still explicitly pick
+ * their visual decoration. Projects uses
* {@link RowStyle#BULLETED_STACKED}, Additional Information uses
* {@link RowStyle#PLAIN}. Education and Experience are the same
* {@link EntriesSection} record, distinguished only by title.
@@ -469,6 +472,7 @@ public static CoverLetterHeader sampleCoverLetterHeaderV2() {
public static CvDocument sampleCvDocumentV2() {
CvIdentity identity = CvIdentity.builder()
.name("Jordan", "Rivera")
+ .jobTitle("Platform Engineer")
.contact("+44 20 5555 1000",
"jordan.rivera@example.com",
"London, UK")
@@ -485,31 +489,37 @@ public static CvDocument sampleCvDocumentV2() {
+ "DSLs, and turning brittle production-ops scripts "
+ "into typed, snapshot-tested libraries that scale.");
- RowsSection skills = RowsSection
- .builder("Technical Skills", RowStyle.BULLETED)
- .row("Languages", "Java 21, Kotlin, Groovy, Python, SQL")
- .row("Document & Print", "PDFBox, Apache POI (DOCX/XLSX), iText, "
- + "PostScript, ICC colour profiles, font metrics")
- .row("Layout engines", "Custom DSL design, semantic layout trees, "
- + "pagination, snapshot testing, visual regression")
- .row("Build & infrastructure", "Maven, Gradle, GitHub Actions, "
- + "JitPack, Docker, JMH benchmarking")
- .row("Testing", "JUnit 5, AssertJ, PDFBox-based PNG diff, "
- + "layout-graph snapshots, mutation testing (Pitest)")
- .row("Distribution", "Maven Central, Sonatype OSSRH, GPG signing, "
- + "JitPack, semantic versioning discipline")
+ SkillsSection skills = SkillsSection
+ .builder("Technical Skills")
+ .group("Languages", "Java 21", "Kotlin", "Groovy",
+ "Python", "SQL")
+ .group("Document & Print", "PDFBox",
+ "Apache POI (DOCX/XLSX)", "iText", "PostScript",
+ "ICC colour profiles", "font metrics")
+ .group("Layout engines", "Custom DSL design",
+ "semantic layout trees", "pagination",
+ "snapshot testing", "visual regression")
+ .group("Build & infrastructure", "Maven", "Gradle",
+ "GitHub Actions", "JitPack", "Docker",
+ "JMH benchmarking")
+ .group("Testing", "JUnit 5", "AssertJ",
+ "PDFBox-based PNG diff", "layout-graph snapshots",
+ "mutation testing (Pitest)")
+ .group("Distribution", "Maven Central", "Sonatype OSSRH",
+ "GPG signing", "JitPack",
+ "semantic versioning discipline")
.build();
EntriesSection education = EntriesSection
.builder("Education & Certifications")
.entry("MSc Computer Science",
"University of Manchester",
- "2020-2021",
+ "2019-2021",
"Distinction. Thesis: *Composable layout primitives "
+ "for deterministic document rendering*.")
.entry("BSc Software Engineering",
"Imperial College London",
- "2016-2019",
+ "2015-2019",
"First-class honours. Specialisation in compilers and "
+ "static analysis.")
.entry("Oracle Java Certification",
diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvEditorialBlueExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvEditorialBlueExample.java
new file mode 100644
index 00000000..7558c197
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvEditorialBlueExample.java
@@ -0,0 +1,47 @@
+package com.demcha.examples.templates.cv.v2;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
+import com.demcha.compose.document.templates.cv.v2.presets.EditorialBlue;
+import com.demcha.examples.support.ExampleDataFactory;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.nio.file.Path;
+
+/**
+ * Renders the v2 Editorial Blue CV preset against the shared grouped
+ * skills sample data — centred uppercase masthead, optional job-title
+ * subtitle, blue editorial rules, and compact skills table.
+ *
+ * Output:
+ * {@code examples/target/generated-pdfs/templates/cv/cv-editorial-blue-v2.pdf}.
+ */
+public final class CvEditorialBlueExample {
+
+ private CvEditorialBlueExample() {
+ }
+
+ public static Path generate() throws Exception {
+ Path outputFile = ExampleOutputPaths.prepare(
+ "templates/cv", "cv-editorial-blue-v2.pdf");
+ CvDocument doc = ExampleDataFactory.sampleCvDocumentV2();
+ DocumentTemplate template = EditorialBlue.create();
+
+ float m = (float) EditorialBlue.RECOMMENDED_MARGIN;
+ try (DocumentSession document = GraphCompose.document(outputFile)
+ .pageSize(DocumentPageSize.A4)
+ .margin(m, m, m, m)
+ .create()) {
+ template.compose(document, doc);
+ document.buildPdf();
+ }
+ return outputFile;
+ }
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("Generated: " + generate());
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md b/src/main/java/com/demcha/compose/document/templates/cv/v2/AUTHORS.md
index 141632cb..25c38d09 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
@@ -55,10 +55,10 @@ CvDocument.builder()
Notice what's absent:
- **No `link(...)` calls.** Optional, simply omitted.
-- **No Projects, no Skills.** The three section types
- (`ParagraphSection`, `RowsSection`, `EntriesSection`) work for any
- content shape — you choose what to put in them and what to call
- them.
+- **No Projects, no Skills.** The built-in section types
+ (`ParagraphSection`, `RowsSection`, `EntriesSection`, `SkillsSection`)
+ work for common CV shapes — you choose what to put in them and what
+ to call them.
- **No required IT vocabulary anywhere.** Section titles are free
strings (`"About Me"`, `"Teaching Experience"`, `"Certifications"`).
@@ -210,7 +210,7 @@ calls them. No inheritance, no instance state to manage.
## Recipe 5 — add a brand-new section subtype
-You need something the existing three section types can't express —
+You need something the existing section types can't express —
say, a skill-bar chart, a quote block, or a contact-references list.
Three places to touch (compile-checked path):
@@ -233,7 +233,8 @@ public record QuoteSection(String title, String quote, String attribution)
```java
public sealed interface CvSection
- permits ParagraphSection, RowsSection, EntriesSection, QuoteSection {
+ permits ParagraphSection, RowsSection, EntriesSection,
+ SkillsSection, QuoteSection {
String title();
}
```
@@ -325,6 +326,7 @@ as DSL plumbing. Below is the current catalog.
| Variant | Visual | Used in |
|---|---|---|
| `Headline.spacedCentered(host, name, theme)` | centred letter-spaced uppercase (`J A N E D O E`) | BoxedSections, MinimalUnderlined, CenteredHeadline, BlueBanner |
+| `Headline.uppercaseCentered(host, name, theme)` | centred uppercase without extra spacing (`JANE DOE`) | EditorialBlue |
| `Headline.rightAligned(host, name, theme)` | right-aligned plain bold (`Jane Doe`) | ModernProfessional |
| `Headline.render(host, name, theme, align, spacedCaps)` | low-level: pick any alignment + transform | — |
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionDispatcher.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionDispatcher.java
index 28a7d8dc..7045447f 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionDispatcher.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SectionDispatcher.java
@@ -7,6 +7,7 @@
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 com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
/**
@@ -35,6 +36,8 @@ public static void renderBody(SectionBuilder host, CvSection section, CvTheme th
if (section instanceof ParagraphSection p) {
ParagraphRenderer.render(host, p.body(), theme);
+ } else if (section instanceof SkillsSection s) {
+ SkillsRenderer.render(host, s, theme);
} else if (section instanceof RowsSection r) {
// Multi-line stacked rows (Projects-style) get a spacer
// between items so consecutive entries don't visually
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillsRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillsRenderer.java
new file mode 100644
index 00000000..47c034af
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/SkillsRenderer.java
@@ -0,0 +1,37 @@
+package com.demcha.compose.document.templates.cv.v2.components;
+
+import com.demcha.compose.document.dsl.SectionBuilder;
+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 com.demcha.compose.document.templates.cv.v2.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+
+/**
+ * Default renderer for a grouped {@link SkillsSection}.
+ *
+ * The data model says "these are labelled skill groups"; this
+ * renderer chooses the conservative shared visual: one bulleted
+ * group per paragraph with the category bolded and skills joined
+ * inline. Presets with a stronger visual signature (for example a
+ * compact grid/table) can branch on {@link SkillsSection} and render
+ * the same data differently without parsing generic rows.
+ */
+public final class SkillsRenderer {
+
+ private SkillsRenderer() {
+ }
+
+ public static void render(SectionBuilder section,
+ SkillsSection skills,
+ CvTheme theme) {
+ DocumentTextStyle style = theme.bodyStyle();
+ DocumentInsets margin = DocumentInsets.top(
+ (float) theme.spacing().paragraphMarginTop());
+ for (SkillGroup group : skills.groups()) {
+ String text = "**" + group.category() + ":** " + group.skillsInline();
+ ParagraphPrimitive.writeBulleted(section, text, style,
+ theme.decoration().bulletGlyph(), margin, theme);
+ }
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvIdentity.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvIdentity.java
index ca06caea..715c993f 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvIdentity.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvIdentity.java
@@ -5,8 +5,9 @@
import java.util.Objects;
/**
- * Top-of-document identity block — name, required contact triple, and
- * an optional ordered list of labelled outbound links.
+ * Top-of-document identity block — name, optional professional title,
+ * required contact triple, and an optional ordered list of labelled
+ * outbound links.
*
* Required pieces have their own types ({@link CvName},
* {@link CvContact}); optional links are accumulated through the
@@ -15,17 +16,29 @@
*
* @param name structured name with first / last required and an
* optional middle component
- * @param contact phone / email / address — all three required
- * @param links ordered list of optional outbound links; never null
+ * @param jobTitle optional professional title rendered by presets
+ * that have a subtitle line; blank when absent
+ * @param contact phone / email / address — all three required
+ * @param links ordered list of optional outbound links; never null
*/
-public record CvIdentity(CvName name, CvContact contact, List links) {
+public record CvIdentity(CvName name, String jobTitle,
+ CvContact contact, List links) {
public CvIdentity {
Objects.requireNonNull(name, "name");
+ jobTitle = jobTitle == null ? "" : jobTitle.trim();
Objects.requireNonNull(contact, "contact");
links = links == null ? List.of() : List.copyOf(links);
}
+ /**
+ * Backward-compatible constructor for callers that predate the
+ * optional job-title field. The title simply stays blank.
+ */
+ public CvIdentity(CvName name, CvContact contact, List links) {
+ this(name, "", contact, links);
+ }
+
/**
* @return new fluent builder
*/
@@ -38,6 +51,7 @@ public static Builder builder() {
*/
public static final class Builder {
private CvName name;
+ private String jobTitle = "";
private CvContact contact;
private final List links = new ArrayList<>();
@@ -59,6 +73,15 @@ public Builder name(String first, String middle, String last) {
return this;
}
+ /**
+ * Sets the optional professional title shown only by presets
+ * with a dedicated subtitle/header line.
+ */
+ public Builder jobTitle(String value) {
+ this.jobTitle = value == null ? "" : value;
+ return this;
+ }
+
public Builder contact(CvContact value) {
this.contact = value;
return this;
@@ -80,7 +103,7 @@ public Builder link(String label, String url) {
}
public CvIdentity build() {
- return new CvIdentity(name, contact, links);
+ return new CvIdentity(name, jobTitle, contact, links);
}
}
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvSection.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvSection.java
index a04009e2..97d81037 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvSection.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvSection.java
@@ -6,7 +6,7 @@
* a visual flavour. Visual flavours like "bulleted vs plain" are
* style toggles inside the section, not separate types.
*
- * Three subtypes cover every canonical resume layout:
+ * The built-in subtypes cover the canonical CV layout shapes:
*
*
* - {@link ParagraphSection} — one block of prose. One field.
@@ -15,13 +15,15 @@
* bulleted-stacked).
* - {@link EntriesSection} — list of timeline {@link CvEntry}
* items with four fields (title, subtitle, date, body).
+ * - {@link SkillsSection} — grouped skill categories where each
+ * category owns an ordered list of skill labels.
*
*
* Every implementation carries a {@code title} — the banner text
* the renderer wraps in a styled panel above the section body.
*/
public sealed interface CvSection
- permits ParagraphSection, RowsSection, EntriesSection {
+ permits ParagraphSection, RowsSection, EntriesSection, SkillsSection {
/**
* @return banner heading shown above the body (non-blank by
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/RowStyle.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/RowStyle.java
index 77016f9c..640b054c 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/RowStyle.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/RowStyle.java
@@ -5,9 +5,9 @@
* each {@link CvRow} is laid out — bullet on/off, inline vs stacked.
*
* This is the orthogonal "decoration" axis the v2 model factors
- * out of section types so that bulleted-skills, plain Additional
- * Information, and stacked Projects all share one {@link RowsSection}
- * record.
+ * out of section types so that plain Additional Information,
+ * ad-hoc bulleted rows, and stacked Projects all share one
+ * {@link RowsSection} record.
*/
public enum RowStyle {
@@ -22,7 +22,7 @@ public enum RowStyle {
/**
* Bullet glyph + bold label + colon + body, inline:
* {@code • Languages: Java 21, Kotlin}.
- * Used by Technical Skills.
+ * Used by ad-hoc bulleted label/value lists.
*/
BULLETED,
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/RowsSection.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/RowsSection.java
index 8de33b5a..e4e4b685 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/RowsSection.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/RowsSection.java
@@ -8,12 +8,12 @@
* Section whose body is a list of {@link CvRow}s rendered with a
* uniform {@link RowStyle} decoration.
*
- * One record covers three common shapes:
+ * One record covers common two-field row shapes:
*
*
- * - Technical Skills: {@code style = BULLETED}.
* - Projects: {@code style = BULLETED_STACKED}.
* - Additional Information: {@code style = PLAIN}.
+ * - Ad-hoc label/value lists: {@code style = BULLETED}.
*
*
* If a future shape needs a different decoration (numbered list,
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/SkillGroup.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/SkillGroup.java
new file mode 100644
index 00000000..0003b7f4
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/SkillGroup.java
@@ -0,0 +1,49 @@
+package com.demcha.compose.document.templates.cv.v2.data;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * One semantic skill category and its atomic skill labels.
+ *
+ *
Examples: {@code Languages -> [Java 21, Kotlin, SQL]} or
+ * {@code Testing -> [JUnit 5, AssertJ, visual regression]}. Presets
+ * can render the same group as a table cell, a sidebar list, chips,
+ * or a compact inline row without reparsing free text.
+ *
+ * @param category category label, non-blank
+ * @param skills ordered skill labels; blank values are ignored
+ */
+public record SkillGroup(String category, List skills) {
+
+ public SkillGroup {
+ Objects.requireNonNull(category, "category");
+ Objects.requireNonNull(skills, "skills");
+ category = category.trim();
+ if (category.isBlank()) {
+ throw new IllegalArgumentException("category must not be blank");
+ }
+ List cleaned = new ArrayList<>(skills.size());
+ for (String skill : skills) {
+ String value = skill == null ? "" : skill.trim();
+ if (!value.isBlank()) {
+ cleaned.add(value);
+ }
+ }
+ skills = List.copyOf(cleaned);
+ }
+
+ public static SkillGroup of(String category, String... skills) {
+ return new SkillGroup(category,
+ skills == null ? List.of() : Arrays.asList(skills));
+ }
+
+ /**
+ * @return comma-separated skills, useful for compact renderers
+ */
+ public String skillsInline() {
+ return String.join(", ", skills);
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSection.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSection.java
new file mode 100644
index 00000000..83f71954
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSection.java
@@ -0,0 +1,81 @@
+package com.demcha.compose.document.templates.cv.v2.data;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Section whose body is an ordered list of skill categories.
+ *
+ * This is the semantic skill model: a preset receives
+ * {@code Languages -> [Java 21, Kotlin]} rather than having to parse
+ * comma-separated strings from a generic {@link RowsSection}. Legacy
+ * or import layers may still split older module text into this shape
+ * before it reaches the v2 renderer.
+ *
+ * @param title non-blank section heading
+ * @param groups ordered skill groups; empty groups are ignored
+ */
+public record SkillsSection(String title, List groups)
+ implements CvSection {
+
+ public SkillsSection {
+ Objects.requireNonNull(title, "title");
+ Objects.requireNonNull(groups, "groups");
+ if (title.isBlank()) {
+ throw new IllegalArgumentException("title must not be blank");
+ }
+ List cleaned = new ArrayList<>(groups.size());
+ for (SkillGroup group : groups) {
+ if (group != null && !group.skills().isEmpty()) {
+ cleaned.add(group);
+ }
+ }
+ groups = List.copyOf(cleaned);
+ }
+
+ /**
+ * Fluent builder for callers that assemble skills one by one.
+ */
+ public static Builder builder(String title) {
+ return new Builder(title);
+ }
+
+ public static SkillsSection of(String title, SkillGroup... groups) {
+ if (groups == null) {
+ return new SkillsSection(title, List.of());
+ }
+ return new SkillsSection(title, List.of(groups));
+ }
+
+ /**
+ * Mutable builder.
+ */
+ public static final class Builder {
+ private final String title;
+ private final List groups = new ArrayList<>();
+
+ private Builder(String title) {
+ this.title = title;
+ }
+
+ public Builder group(SkillGroup group) {
+ this.groups.add(Objects.requireNonNull(group, "group"));
+ return this;
+ }
+
+ public Builder group(String category, List skills) {
+ this.groups.add(new SkillGroup(category, skills));
+ return this;
+ }
+
+ public Builder group(String category, String... skills) {
+ this.groups.add(SkillGroup.of(category, skills));
+ return this;
+ }
+
+ public SkillsSection build() {
+ return new SkillsSection(title, groups);
+ }
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java
index 8ee6da21..ea8e7595 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/package-info.java
@@ -19,7 +19,7 @@
* Section catalog
*
* The sealed {@link com.demcha.compose.document.templates.cv.v2.data.CvSection}
- * hierarchy intentionally has only three concrete
+ * hierarchy intentionally has a small set of concrete
* shapes — one per genuinely-different structural pattern, not one
* per visual flavour:
*
@@ -30,13 +30,16 @@
* — list of two-field {@link com.demcha.compose.document.templates.cv.v2.data.CvRow}
* items. Visual decoration is picked via the
* {@link com.demcha.compose.document.templates.cv.v2.data.RowStyle}
- * enum so Technical Skills (bulleted), Additional Information
- * (plain), and Projects (bulleted, two-line) share one
- * record.
+ * enum so Additional Information (plain) and Projects
+ * (bulleted, two-line) share one record.
* {@link com.demcha.compose.document.templates.cv.v2.data.EntriesSection}
* — list of timeline {@link com.demcha.compose.document.templates.cv.v2.data.CvEntry}
* items with title / subtitle / date / body. Used for Education
* and Professional Experience interchangeably.
+ * {@link com.demcha.compose.document.templates.cv.v2.data.SkillsSection}
+ * — grouped skills: category plus ordered skill labels. This
+ * keeps skills semantic so presets can render them as tables,
+ * sidebar chips, or inline rows without reparsing text.
*
*
* Placement
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java
index 904d3779..57d2bc8e 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/package-info.java
@@ -23,12 +23,14 @@
* │ ModernProfessional ← corporate composition variant │
* │ CenteredHeadline ← classic centred headline variant │
* │ BlueBanner ← full-width banner composition │
+ * │ EditorialBlue ← compact editorial composition │
* └─────────────────────────────────────────────────────────────┘
* │ compose from
* ▼
* ┌─────────────────────────────────────────────────────────────┐
* │ widgets/ ← named visual building blocks (LEGO bricks) │
- * │ Headline .spacedCentered | .rightAligned │
+ * │ Headline .spacedCentered | .uppercaseCentered │
+ * │ | .rightAligned │
* │ Subheadline .centeredSpacedCaps │
* │ ContactLine .centered | .rightAligned │
* │ .twoRowRightAligned │
@@ -51,8 +53,8 @@
* ▼
* ┌─────────────────────────────────────────────────────────────┐
* │ data/ │
- * │ CvIdentity ← name, contact, optional links │
- * │ CvSection ← sealed: Paragraph | Rows | Entries │
+ * │ CvIdentity ← name, optional job title, contact, links │
+ * │ CvSection ← sealed: Paragraph | Rows | Entries | Skills│
* │ CvDocument ← identity + Placement(slot, section) │
* │ Slot ← MAIN | SIDEBAR | FOOTER │
* └─────────────────────────────────────────────────────────────┘
@@ -104,15 +106,16 @@
* .identity(CvIdentity.builder()
* .name("Jane", "Doe") // required: first + last
* // .name("Jane", "Quinn", "Doe") // optional middle
+ * .jobTitle("Backend Engineer") // optional
* .contact("+44 0", "j@d.com", "London, UK") // required triple
* .link("LinkedIn", "https://...") // optional
* .link("GitHub", "https://...") // optional
* .build())
* .section(new ParagraphSection("Professional Summary",
* "Backend engineer with **5 years** of..."))
- * .section(RowsSection.builder("Technical Skills", RowStyle.BULLETED)
- * .row("Languages", "Java 21, Kotlin")
- * .row("Testing", "JUnit 5, AssertJ")
+ * .section(SkillsSection.builder("Technical Skills")
+ * .group("Languages", "Java 21", "Kotlin")
+ * .group("Testing", "JUnit 5", "AssertJ")
* .build())
* .section(EntriesSection.builder("Experience")
* .entry("Senior Engineer", "Acme Inc", "2022-Present", "Built ...")
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
new file mode 100644
index 00000000..d5000f1a
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/EditorialBlue.java
@@ -0,0 +1,561 @@
+package com.demcha.compose.document.templates.cv.v2.presets;
+
+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.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;
+import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
+import com.demcha.compose.document.templates.cv.v2.data.SkillGroup;
+import com.demcha.compose.document.templates.cv.v2.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.v2.data.Slot;
+import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import com.demcha.compose.document.templates.cv.v2.widgets.Headline;
+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;
+
+/**
+ * v2 port of the "Editorial Blue" CV preset.
+ *
+ * Visual signature:
+ *
+ * - centred navy uppercase masthead with optional job-title
+ * subtitle;
+ * - compact centred contact metadata plus blue underlined
+ * profile links;
+ * - uppercase blue section labels with thin editorial rules;
+ * - dense prose, inline editorial timeline entries, and a
+ * four-column skills table fed by {@link SkillsSection}.
+ *
+ *
+ * The preset owns the custom entry/project/skills shapes locally
+ * because they are editorial-specific. Shared widgets and renderers
+ * are extended only for reusable concepts such as the uppercase
+ * headline variant.
+ */
+public final class EditorialBlue {
+
+ /** Stable template identifier. */
+ public static final String ID = "editorial-blue";
+
+ /** Human-readable display name. */
+ public static final String DISPLAY_NAME = "Editorial Blue";
+
+ /** Recommended page margin (in points). */
+ public static final double RECOMMENDED_MARGIN = 28.0;
+
+ private static final DocumentColor NAME_COLOR =
+ DocumentColor.rgb(18, 31, 72);
+ private static final int SKILL_COLUMNS = 4;
+
+ private EditorialBlue() {
+ }
+
+ /**
+ * Builds the preset with its Editorial Blue theme.
+ */
+ public static DocumentTemplate create() {
+ return create(CvTheme.editorialBlue());
+ }
+
+ /**
+ * Builds the preset with a caller-supplied theme.
+ */
+ public static DocumentTemplate create(CvTheme theme) {
+ Objects.requireNonNull(theme, "theme");
+ return new Template(theme);
+ }
+
+ private static final class Template implements DocumentTemplate {
+
+ private final CvTheme theme;
+
+ Template(CvTheme theme) {
+ this.theme = theme;
+ }
+
+ @Override
+ public String id() {
+ return ID;
+ }
+
+ @Override
+ public String displayName() {
+ return DISPLAY_NAME;
+ }
+
+ @Override
+ public void compose(DocumentSession document, CvDocument doc) {
+ Objects.requireNonNull(document, "document");
+ Objects.requireNonNull(doc, "doc");
+
+ double width = document.canvas().innerWidth();
+ PageFlowBuilder pageFlow = document.dsl()
+ .pageFlow()
+ .name("CvV2EditorialBlueRoot")
+ .spacing(theme.spacing().pageFlowSpacing());
+
+ addHeader(pageFlow, doc.identity());
+
+ List sections = doc.sectionsIn(Slot.MAIN).stream()
+ .filter(this::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);
+ pageFlow.addSection(name + "_Body",
+ host -> renderSectionBody(host, section, width));
+ }
+
+ addFooter(pageFlow, width);
+ 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())
+ .padding(theme.spacing().sectionBodyPadding());
+
+ if (cvSection instanceof ParagraphSection paragraph) {
+ renderParagraph(section, paragraph.body(), 1.6);
+ } else if (cvSection instanceof SkillsSection skills) {
+ renderSkills(section, skills.groups(), width);
+ } else if (cvSection instanceof EntriesSection entries) {
+ renderEntries(section, entries);
+ } else if (cvSection instanceof RowsSection rows) {
+ renderRows(section, rows);
+ }
+ }
+
+ private void renderEntries(SectionBuilder section, EntriesSection entries) {
+ boolean education = isEducation(entries.title());
+ for (int i = 0; i < entries.entries().size(); i++) {
+ if (i > 0) {
+ section.spacer(0, theme.spacing().entrySeparation());
+ }
+ if (education) {
+ renderEducationEntry(section, entries.entries().get(i));
+ } else {
+ renderExperienceEntry(section, entries.entries().get(i));
+ }
+ }
+ }
+
+ private void renderExperienceEntry(SectionBuilder section, CvEntry entry) {
+ DocumentTextStyle titleStyle = style(FontName.HELVETICA_BOLD,
+ 11.0, DocumentTextDecoration.BOLD, NAME_COLOR);
+ DocumentTextStyle dateStyle = style(FontName.HELVETICA_BOLD,
+ 11.0, DocumentTextDecoration.BOLD, theme.palette().rule());
+ DocumentTextStyle subtitleStyle = style(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);
+ }
+ }
+
+ private void renderEducationEntry(SectionBuilder section, CvEntry entry) {
+ DocumentTextStyle titleStyle = style(FontName.HELVETICA_BOLD,
+ 10.6, DocumentTextDecoration.BOLD, NAME_COLOR);
+ DocumentTextStyle dateStyle = style(FontName.HELVETICA_BOLD,
+ 10.0, DocumentTextDecoration.BOLD, theme.palette().rule());
+ DocumentTextStyle subtitleStyle = style(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);
+ }
+ }
+
+ private void renderRows(SectionBuilder section, RowsSection rows) {
+ if (isProjects(rows.title())) {
+ for (int i = 0; i < rows.rows().size(); i++) {
+ if (i > 0) {
+ section.spacer(0, theme.spacing().entrySeparation());
+ }
+ renderProject(section, rows.rows().get(i));
+ }
+ return;
+ }
+
+ for (CvRow row : rows.rows()) {
+ renderKeyValue(section, row);
+ }
+ }
+
+ private void renderProject(SectionBuilder section, CvRow row) {
+ TitleAndStack title = splitTitleAndStack(row.label());
+ DocumentTextStyle titleStyle = style(FontName.HELVETICA_BOLD,
+ 10.6, DocumentTextDecoration.BOLD, NAME_COLOR);
+ DocumentTextStyle stackStyle = style(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);
+ }
+ }
+
+ private void renderKeyValue(SectionBuilder section, CvRow row) {
+ DocumentTextStyle keyStyle = style(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());
+ }
+ }));
+ }
+
+ private void renderParagraph(SectionBuilder section, String text,
+ double lineSpacing) {
+ String value = text == null ? "" : text.trim();
+ 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())));
+ }
+
+ private void renderSkills(SectionBuilder section, List groups,
+ double width) {
+ if (groups.isEmpty()) {
+ return;
+ }
+ DocumentTextStyle cellStyle = style(FontName.HELVETICA,
+ 8.6, DocumentTextDecoration.DEFAULT, theme.palette().ink());
+ TableWidget.Style tableStyle = TableWidget.Style.builder()
+ .name("CvV2EditorialBlueSkillsTable")
+ .columns(SKILL_COLUMNS)
+ .cellPadding(new DocumentInsets(4, 6, 4, 6))
+ .border(theme.palette().banner(), 0.5)
+ .textStyle(cellStyle)
+ .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;
+ }
+
+ private void addFooter(PageFlowBuilder pageFlow, double width) {
+ pageFlow.addLine(line -> line
+ .name("CvV2EditorialBlueFooterRule")
+ .horizontal(width)
+ .color(theme.palette().banner())
+ .thickness(theme.spacing().accentRuleWidth())
+ .margin(new DocumentInsets(6, 0, 0, 0)));
+ pageFlow.addSection("CvV2EditorialBlueFooter", section -> section
+ .padding(new DocumentInsets(2, 0, 0, 0))
+ .addParagraph(paragraph -> paragraph
+ .text("References available upon request.")
+ .textStyle(style(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);
+ if (normalized.contains("summary") || normalized.contains("profile")) {
+ return "PROFESSIONAL PROFILE";
+ }
+ if (normalized.contains("experience")
+ || normalized.contains("employment")) {
+ return "EMPLOYMENT HISTORY";
+ }
+ if (normalized.contains("project")) {
+ return "PROJECTS";
+ }
+ if (normalized.contains("education")
+ || normalized.contains("certification")) {
+ return "EDUCATION";
+ }
+ if (normalized.contains("skill")) {
+ return "KEY SKILLS";
+ }
+ if (normalized.contains("additional")) {
+ return "ADDITIONAL";
+ }
+ 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();
+ }
+ 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);
+ }
+ }
+ 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/theme/CvPalette.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java
index c3285b04..a5bf64fc 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvPalette.java
@@ -68,4 +68,17 @@ public static CvPalette blueBanner() {
DocumentColor.rgb(58, 82, 118),
DocumentColor.rgb(112, 146, 190));
}
+
+ /**
+ * Editorial Blue palette: deep blue-grey body text, muted
+ * subtitles, vivid blue rules, and a neutral border token reused
+ * by compact skill grids.
+ */
+ public static CvPalette editorialBlue() {
+ return new CvPalette(
+ DocumentColor.rgb(60, 72, 106),
+ DocumentColor.rgb(150, 158, 178),
+ DocumentColor.rgb(86, 136, 255),
+ DocumentColor.rgb(193, 201, 211));
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java
index 356492f3..39963443 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvSpacing.java
@@ -189,4 +189,26 @@ public static CvSpacing blueBanner() {
0.4, // entryDateWeight
0.0); // entrySeparation
}
+
+ /**
+ * Compact spacing for Editorial Blue: section headers own their
+ * rule/title rhythm, while bodies start close to the lower rule.
+ */
+ public static CvSpacing editorialBlue() {
+ return new CvSpacing(
+ 0, // pageFlowSpacing
+ 2, // sectionBodySpacing
+ new DocumentInsets(8, 0, 0, 0), // sectionBodyPadding
+ new DocumentInsets(2, 0, 2, 0), // headlinePadding
+ new DocumentInsets(1, 0, 0, 0), // contactPadding
+ 0.0, // bannerCornerRadius
+ 0.0, // bannerInnerPadding
+ DocumentInsets.zero(), // bannerMargin
+ 0.6, // accentRuleWidth
+ 1.0, // paragraphMarginTop
+ 8.0, // entryHeaderRowSpacing
+ 1.0, // entryTitleWeight
+ 0.45, // entryDateWeight
+ 3.0); // entrySeparation
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java
index a9828463..28c5bbc3 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTheme.java
@@ -115,6 +115,18 @@ public static CvTheme blueBanner() {
CvDecoration.blueBanner());
}
+ /**
+ * The "Editorial Blue" look — compact Helvetica, vivid blue
+ * section rules, centred editorial header, and dense body
+ * spacing.
+ */
+ public static CvTheme editorialBlue() {
+ return new CvTheme(
+ CvPalette.editorialBlue(),
+ CvTypography.editorialBlue(),
+ CvSpacing.editorialBlue(),
+ CvDecoration.classic());
+ }
// -- pre-built text-style helpers ------------------------------------
// Renderers ask the theme for an already-composed DocumentTextStyle
// instead of re-assembling font + size + decoration + colour every
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java
index 376744db..f1ff4a6d 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/CvTypography.java
@@ -119,4 +119,20 @@ public static CvTypography blueBanner() {
7.7, // body
1.3); // line spacing
}
+
+ /**
+ * Compact Helvetica scale for the Editorial Blue preset.
+ */
+ public static CvTypography editorialBlue() {
+ return new CvTypography(
+ FontName.HELVETICA_BOLD, FontName.HELVETICA,
+ 22.0, // headline
+ 9.0, // contact
+ 11.0, // section title
+ 10.6, // entry title
+ 10.0, // entry date
+ 9.2, // entry subtitle
+ 9.4, // body
+ 1.45); // line spacing
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/package-info.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/package-info.java
index 8b3dca17..32fc1afd 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/package-info.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/theme/package-info.java
@@ -4,7 +4,7 @@
* The shift-able layer. Every colour, font, size,
* padding, corner radius, accent width — everything purely visual —
* lives in {@link com.demcha.compose.document.templates.cv.v2.theme.CvTheme}
- * and its three sub-records:
+ * and its four sub-records:
*
*
* - {@link com.demcha.compose.document.templates.cv.v2.theme.CvPalette}
@@ -14,6 +14,9 @@
*
- {@link com.demcha.compose.document.templates.cv.v2.theme.CvSpacing}
* — paddings, margins, banner radius, accent widths, row
* weights.
+ * - {@link com.demcha.compose.document.templates.cv.v2.theme.CvDecoration}
+ * — bullets, contact separators, and other small glyph
+ * choices.
*
*
* Renderers in {@code cv/v2/components} accept a {@code CvTheme}
@@ -21,7 +24,7 @@
* is just a new {@code CvTheme} factory, no renderer changes
* required.
*
- * Why split into three sub-records: it lets you mix-and-match — a
+ *
Why split into sub-records: it lets you mix-and-match — a
* preset can build {@code new CvTheme(palette, defaultTypography,
* tighterSpacing)} for a compact variant without redeclaring every
* field.
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java
index 8a0c622a..98feabc3 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/Headline.java
@@ -19,6 +19,9 @@
* (e.g. {@code J A N E D O E}). Used by classic /
* editorial presets where the name is the page's visual
* focal point.
+ * {@link #uppercaseCentered} — centred uppercase without
+ * extra letter spacing (e.g. {@code JANE DOE}). Used by
+ * compact editorial presets.
* {@link #rightAligned} — right-aligned plain bold (e.g.
* {@code Jane Doe}). Used by modern / corporate presets
* where the name sits in a header bar next to contacts.
@@ -48,6 +51,30 @@ public static void spacedCentered(SectionBuilder host, CvName name, CvTheme them
render(host, name, theme, TextAlign.CENTER, true);
}
+ /**
+ * Centred uppercase headline without letter spacing. Visual
+ * signature of compact editorial presets that want a strong
+ * masthead but not the wider classic spaced-caps treatment.
+ */
+ public static void uppercaseCentered(SectionBuilder host, CvName name,
+ CvTheme theme) {
+ uppercaseCentered(host, name, theme, null);
+ }
+
+ /**
+ * Centred uppercase headline without letter spacing and with an
+ * explicit style override.
+ *
+ * @param styleOverride explicit style; pass {@code null} to fall
+ * back to {@code theme.headlineStyle()}
+ */
+ public static void uppercaseCentered(SectionBuilder host, CvName name,
+ CvTheme theme,
+ DocumentTextStyle styleOverride) {
+ renderText(host, name.full().toUpperCase(java.util.Locale.ROOT),
+ theme, TextAlign.CENTER, styleOverride);
+ }
+
/**
* Right-aligned plain headline using the theme's default
* {@link CvTheme#headlineStyle() headline style}. Visual
@@ -108,6 +135,15 @@ public static void render(SectionBuilder host, CvName name, CvTheme theme,
? TextOrnaments.spacedUpper(name.full())
: name.full();
+ renderText(host, text, theme, alignment, style);
+ }
+
+ private static void renderText(SectionBuilder host, String text, CvTheme theme,
+ TextAlign alignment,
+ DocumentTextStyle styleOverride) {
+ DocumentTextStyle style = styleOverride != null
+ ? styleOverride
+ : theme.headlineStyle();
host.spacing(2)
.padding(theme.spacing().headlinePadding())
.addParagraph(p -> p
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 308012f6..c1c73145 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
@@ -42,8 +42,9 @@
*
*
* - {@link com.demcha.compose.document.templates.cv.v2.widgets.Headline}
- * — top-of-document name in 2 variants
- * ({@code spacedCentered}, {@code rightAligned}).
+ * — top-of-document name in 3 variants
+ * ({@code spacedCentered}, {@code uppercaseCentered},
+ * {@code rightAligned}).
* - {@link com.demcha.compose.document.templates.cv.v2.widgets.Subheadline}
* — secondary tagline under the name in 1 variant
* ({@code centeredSpacedCaps}).
@@ -56,6 +57,12 @@
* {@code underlined}, {@code flat}, {@code flatSpacedCaps}).
*
*
+ * 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.
+ *
* Each widget delegates internally to the lower-level renderers
* in {@code cv/v2/components/} where helpful, but its public face
* is the small set of factory methods above.
diff --git a/src/main/java/com/demcha/compose/document/templates/widgets/TableWidget.java b/src/main/java/com/demcha/compose/document/templates/widgets/TableWidget.java
new file mode 100644
index 00000000..940d60cf
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/widgets/TableWidget.java
@@ -0,0 +1,288 @@
+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.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import com.demcha.compose.document.table.DocumentTableStyle;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Shared table widget for template presets.
+ *
+ * The low-level DSL already knows how to lay out tables; this
+ * widget captures the common template-facing knobs so presets can
+ * swap border colour, cell fill, zebra rows, padding, typography,
+ * and column count without rewriting table plumbing.
+ *
+ * Use {@link #fixed} when you already have rows. Use
+ * {@link #grid} when you have a flat list of items that should flow
+ * left-to-right into a fixed number of columns.
+ */
+public final class TableWidget {
+
+ private TableWidget() {
+ }
+
+ /**
+ * Renders a fixed-column table from pre-grouped rows.
+ *
+ * @param host section receiving the table
+ * @param rows row data; short rows are padded with blank cells
+ * @param width available table width in points
+ * @param style visual table options
+ */
+ public static void fixed(SectionBuilder host,
+ List> rows,
+ double width,
+ Style style) {
+ Objects.requireNonNull(host, "host");
+ Objects.requireNonNull(rows, "rows");
+ Style safeStyle = style == null ? Style.builder().build() : style;
+ List> normalized = normalizeRows(rows, safeStyle.columns());
+ if (normalized.isEmpty()) {
+ return;
+ }
+
+ host.addTable(table -> {
+ table.name(safeStyle.name())
+ .columns(fixedColumns(width, safeStyle))
+ .defaultCellStyle(cellStyle(safeStyle));
+ if (safeStyle.zebraFillColor() != null) {
+ table.zebra(fillStyle(safeStyle.cellFillColor()),
+ fillStyle(safeStyle.zebraFillColor()));
+ }
+ for (List row : normalized) {
+ table.row(row.toArray(String[]::new));
+ }
+ });
+ }
+
+ /**
+ * Renders a flat list as a fixed-column grid.
+ *
+ * @param host section receiving the table
+ * @param cells flat cell values, filled row-major
+ * @param width available table width in points
+ * @param style visual table options
+ */
+ public static void grid(SectionBuilder host,
+ List cells,
+ double width,
+ Style style) {
+ Objects.requireNonNull(cells, "cells");
+ Style safeStyle = style == null ? Style.builder().build() : style;
+ List cleaned = new ArrayList<>();
+ for (String cell : cells) {
+ if (cell != null && !cell.isBlank()) {
+ cleaned.add(cell.trim());
+ }
+ }
+ if (cleaned.isEmpty()) {
+ return;
+ }
+
+ List> rows = new ArrayList<>();
+ for (int i = 0; i < cleaned.size(); i += safeStyle.columns()) {
+ List row = new ArrayList<>(safeStyle.columns());
+ for (int c = 0; c < safeStyle.columns(); c++) {
+ int index = i + c;
+ row.add(index < cleaned.size() ? cleaned.get(index) : "");
+ }
+ rows.add(row);
+ }
+ fixed(host, rows, width, safeStyle);
+ }
+
+ private static DocumentTableColumn[] fixedColumns(double width, Style style) {
+ double columnWidth = (width - style.widthAdjustment()) / style.columns();
+ DocumentTableColumn[] columns = new DocumentTableColumn[style.columns()];
+ for (int i = 0; i < columns.length; i++) {
+ columns[i] = DocumentTableColumn.fixed(columnWidth);
+ }
+ return columns;
+ }
+
+ private static DocumentTableStyle cellStyle(Style style) {
+ DocumentTableStyle.Builder builder = DocumentTableStyle.builder()
+ .padding(style.cellPadding());
+ if (style.cellFillColor() != null) {
+ builder.fillColor(style.cellFillColor());
+ }
+ if (style.cellStroke() != null) {
+ builder.stroke(style.cellStroke());
+ }
+ if (style.textStyle() != null) {
+ builder.textStyle(style.textStyle());
+ }
+ if (style.lineSpacing() != null) {
+ builder.lineSpacing(style.lineSpacing());
+ }
+ return builder.build();
+ }
+
+ private static DocumentTableStyle fillStyle(DocumentColor color) {
+ return color == null
+ ? null
+ : DocumentTableStyle.builder().fillColor(color).build();
+ }
+
+ private static List> normalizeRows(List> rows,
+ int columns) {
+ List> out = new ArrayList<>();
+ for (List row : rows) {
+ if (row == null || row.isEmpty()) {
+ continue;
+ }
+ if (row.size() > columns) {
+ throw new IllegalArgumentException(
+ "Row has " + row.size() + " cells, but table has "
+ + columns + " columns");
+ }
+ List normalized = new ArrayList<>(columns);
+ for (int i = 0; i < columns; i++) {
+ String value = i < row.size() ? row.get(i) : "";
+ normalized.add(value == null ? "" : value);
+ }
+ out.add(normalized);
+ }
+ return List.copyOf(out);
+ }
+
+ /**
+ * Visual options for the shared table widget.
+ *
+ * @param name semantic table node name
+ * @param columns fixed column count
+ * @param cellPadding padding inside every cell
+ * @param cellStroke optional border stroke; null means no
+ * explicit border override
+ * @param cellFillColor optional default fill for every cell
+ * @param zebraFillColor optional fill for alternating rows
+ * @param textStyle optional text style override
+ * @param lineSpacing optional line spacing override
+ * @param widthAdjustment value subtracted before splitting width
+ * into fixed columns, useful when borders
+ * would otherwise nudge the table over
+ */
+ public record Style(String name,
+ int columns,
+ DocumentInsets cellPadding,
+ DocumentStroke cellStroke,
+ DocumentColor cellFillColor,
+ DocumentColor zebraFillColor,
+ DocumentTextStyle textStyle,
+ Double lineSpacing,
+ double widthAdjustment) {
+
+ public Style {
+ name = (name == null || name.isBlank()) ? "TemplateTable" : name;
+ if (columns < 1) {
+ throw new IllegalArgumentException(
+ "columns must be >= 1: " + columns);
+ }
+ cellPadding = cellPadding == null
+ ? DocumentInsets.zero()
+ : cellPadding;
+ if (lineSpacing != null
+ && (lineSpacing < 0
+ || Double.isNaN(lineSpacing)
+ || Double.isInfinite(lineSpacing))) {
+ throw new IllegalArgumentException(
+ "lineSpacing must be finite and non-negative");
+ }
+ if (widthAdjustment < 0
+ || Double.isNaN(widthAdjustment)
+ || Double.isInfinite(widthAdjustment)) {
+ throw new IllegalArgumentException(
+ "widthAdjustment must be finite and non-negative");
+ }
+ }
+
+ /**
+ * @return mutable builder seeded with conservative defaults
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Fluent builder for {@link Style}.
+ */
+ public static final class Builder {
+ private String name = "TemplateTable";
+ private int columns = 2;
+ private DocumentInsets cellPadding = DocumentInsets.zero();
+ private DocumentStroke cellStroke;
+ private DocumentColor cellFillColor;
+ private DocumentColor zebraFillColor;
+ private DocumentTextStyle textStyle;
+ private Double lineSpacing;
+ private double widthAdjustment = 0.0;
+
+ private Builder() {
+ }
+
+ public Builder name(String value) {
+ this.name = value;
+ return this;
+ }
+
+ public Builder columns(int value) {
+ this.columns = value;
+ return this;
+ }
+
+ public Builder cellPadding(DocumentInsets value) {
+ this.cellPadding = value;
+ return this;
+ }
+
+ public Builder border(DocumentColor color, double width) {
+ this.cellStroke = DocumentStroke.of(color, width);
+ return this;
+ }
+
+ public Builder cellStroke(DocumentStroke value) {
+ this.cellStroke = value;
+ return this;
+ }
+
+ public Builder cellFillColor(DocumentColor value) {
+ this.cellFillColor = value;
+ return this;
+ }
+
+ public Builder zebraFillColor(DocumentColor value) {
+ this.zebraFillColor = value;
+ return this;
+ }
+
+ public Builder textStyle(DocumentTextStyle value) {
+ this.textStyle = value;
+ return this;
+ }
+
+ public Builder lineSpacing(Double value) {
+ this.lineSpacing = value;
+ return this;
+ }
+
+ public Builder widthAdjustment(double value) {
+ this.widthAdjustment = value;
+ return this;
+ }
+
+ public Style build() {
+ return new Style(name, columns, cellPadding, cellStroke,
+ cellFillColor, zebraFillColor, textStyle,
+ lineSpacing, widthAdjustment);
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000..ec026c47
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/widgets/package-info.java
@@ -0,0 +1,9 @@
+/**
+ * Shared visual widgets available to all template families.
+ *
+ * These widgets sit above the raw document DSL and below
+ * 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.
+ */
+package com.demcha.compose.document.templates.widgets;
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSectionTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSectionTest.java
new file mode 100644
index 00000000..f01ddab1
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSectionTest.java
@@ -0,0 +1,41 @@
+package com.demcha.compose.document.templates.cv.v2.data;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SkillsSectionTest {
+
+ @Test
+ void skillGroup_trims_category_and_skips_blank_skills() {
+ SkillGroup group = new SkillGroup(" Languages ",
+ List.of(" Java 21 ", "", "Kotlin"));
+
+ assertThat(group.category()).isEqualTo("Languages");
+ assertThat(group.skills()).containsExactly("Java 21", "Kotlin");
+ assertThat(group.skillsInline()).isEqualTo("Java 21, Kotlin");
+ }
+
+ @Test
+ void skillsSection_keeps_non_empty_groups_only() {
+ SkillsSection section = SkillsSection.builder("Technical Skills")
+ .group("Languages", "Java 21", "Kotlin")
+ .group(new SkillGroup("Empty", List.of()))
+ .group("Testing", "JUnit 5")
+ .build();
+
+ assertThat(section.groups())
+ .extracting(SkillGroup::category)
+ .containsExactly("Languages", "Testing");
+ }
+
+ @Test
+ void rejects_blank_category() {
+ assertThatThrownBy(() -> SkillGroup.of(" ", "Java"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("category");
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSectionsSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSectionsSmokeTest.java
index 322d9862..69712cc2 100644
--- a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSectionsSmokeTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSectionsSmokeTest.java
@@ -11,6 +11,7 @@
import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection;
import com.demcha.compose.document.templates.cv.v2.data.RowStyle;
import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
+import com.demcha.compose.document.templates.cv.v2.data.SkillsSection;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
import org.junit.jupiter.api.Test;
@@ -56,8 +57,8 @@ void every_section_subtype_dispatches_cleanly() throws Exception {
renderAndAssertNonEmpty(template, documentWith(
new ParagraphSection("Summary", "body text")));
renderAndAssertNonEmpty(template, documentWith(
- RowsSection.builder("Skills", RowStyle.BULLETED)
- .row("Languages", "Java, Kotlin")
+ SkillsSection.builder("Skills")
+ .group("Languages", "Java", "Kotlin")
.build()));
renderAndAssertNonEmpty(template, documentWith(
RowsSection.builder("Info", RowStyle.PLAIN)
@@ -100,8 +101,8 @@ private static CvDocument fullDocument() {
.identity(identity())
.sections(
new ParagraphSection("Summary", "body"),
- RowsSection.builder("Skills", RowStyle.BULLETED)
- .row("Languages", "Java").build(),
+ SkillsSection.builder("Skills")
+ .group("Languages", "Java").build(),
EntriesSection.builder("Experience")
.entry("Engineer", "Acme", "2020", "did stuff").build(),
RowsSection.builder("Projects", RowStyle.BULLETED_STACKED)
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java
index a7dd6eb6..6051e8ee 100644
--- a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java
@@ -10,6 +10,7 @@
import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection;
import com.demcha.compose.document.templates.cv.v2.data.RowStyle;
import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
+import com.demcha.compose.document.templates.cv.v2.data.SkillsSection;
import com.demcha.testing.visual.PdfVisualRegression;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
@@ -98,13 +99,16 @@ private static Stream presets() {
(Supplier>) CenteredHeadline::create),
Arguments.of("blue_banner",
BlueBanner.RECOMMENDED_MARGIN,
- (Supplier>) BlueBanner::create));
+ (Supplier>) BlueBanner::create),
+ Arguments.of("editorial_blue",
+ EditorialBlue.RECOMMENDED_MARGIN,
+ (Supplier>) EditorialBlue::create));
}
/**
* Canonical sample document — Jordan Rivera with every v2 section
- * subtype exercised so the gate covers paragraph, all three
- * row-styles, and timeline entries.
+ * subtype exercised so the gate covers paragraph, grouped
+ * skills, row styles, and timeline entries.
*
* Kept inline (not pulled from the examples module) so the
* test depends only on main + main-test code.
@@ -113,6 +117,7 @@ private static CvDocument canonicalDocument() {
return CvDocument.builder()
.identity(CvIdentity.builder()
.name("Jordan", "Rivera")
+ .jobTitle("Platform Engineer")
.contact("+44 20 5555 1000",
"jordan.rivera@example.com",
"London, UK")
@@ -126,28 +131,36 @@ private static CvDocument canonicalDocument() {
+ "high-throughput PDF rendering, semantic authoring "
+ "DSLs, and turning brittle production-ops scripts "
+ "into typed, snapshot-tested libraries that scale."))
- .section(RowsSection.builder("Technical Skills", RowStyle.BULLETED)
- .row("Languages", "Java 21, Kotlin, Groovy, Python, SQL")
- .row("Document & Print", "PDFBox, Apache POI (DOCX/XLSX), iText, "
- + "PostScript, ICC colour profiles, font metrics")
- .row("Layout engines", "Custom DSL design, semantic layout trees, "
- + "pagination, snapshot testing, visual regression")
- .row("Build & infrastructure", "Maven, Gradle, GitHub Actions, "
- + "JitPack, Docker, JMH benchmarking")
- .row("Testing", "JUnit 5, AssertJ, PDFBox-based PNG diff, "
- + "layout-graph snapshots, mutation testing (Pitest)")
- .row("Distribution", "Maven Central, Sonatype OSSRH, GPG signing, "
- + "JitPack, semantic versioning discipline")
+ .section(SkillsSection.builder("Technical Skills")
+ .group("Languages", "Java 21", "Kotlin", "Groovy",
+ "Python", "SQL")
+ .group("Document & Print", "PDFBox",
+ "Apache POI (DOCX/XLSX)", "iText",
+ "PostScript", "ICC colour profiles",
+ "font metrics")
+ .group("Layout engines", "Custom DSL design",
+ "semantic layout trees", "pagination",
+ "snapshot testing", "visual regression")
+ .group("Build & infrastructure", "Maven", "Gradle",
+ "GitHub Actions", "JitPack", "Docker",
+ "JMH benchmarking")
+ .group("Testing", "JUnit 5", "AssertJ",
+ "PDFBox-based PNG diff",
+ "layout-graph snapshots",
+ "mutation testing (Pitest)")
+ .group("Distribution", "Maven Central",
+ "Sonatype OSSRH", "GPG signing", "JitPack",
+ "semantic versioning discipline")
.build())
.section(EntriesSection.builder("Education & Certifications")
.entry("MSc Computer Science",
"University of Manchester",
- "2020-2021",
+ "2019-2021",
"Distinction. Thesis: *Composable layout primitives "
+ "for deterministic document rendering*.")
.entry("BSc Software Engineering",
"Imperial College London",
- "2016-2019",
+ "2015-2019",
"First-class honours. Specialisation in compilers and "
+ "static analysis.")
.entry("Oracle Java Certification",
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/EditorialBlueSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/EditorialBlueSmokeTest.java
new file mode 100644
index 00000000..3540e4e7
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/EditorialBlueSmokeTest.java
@@ -0,0 +1,89 @@
+package com.demcha.compose.document.templates.cv.v2.presets;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
+import com.demcha.compose.document.templates.cv.v2.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.v2.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.v2.data.RowStyle;
+import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
+import com.demcha.compose.document.templates.cv.v2.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Smoke test for the v2 EditorialBlue preset. Covers the optional
+ * job-title header line and the grouped {@link SkillsSection} path.
+ */
+class EditorialBlueSmokeTest {
+
+ @Test
+ void exposes_stable_identity() {
+ DocumentTemplate template = EditorialBlue.create();
+ assertThat(template.id()).isEqualTo("editorial-blue");
+ assertThat(template.displayName()).isEqualTo("Editorial Blue");
+ }
+
+ @Test
+ void default_factory_renders_full_document() throws Exception {
+ renderAndAssertNonEmpty(EditorialBlue.create(), fullDocument());
+ }
+
+ @Test
+ void custom_theme_factory_renders() throws Exception {
+ renderAndAssertNonEmpty(EditorialBlue.create(CvTheme.editorialBlue()),
+ fullDocument());
+ }
+
+ private static void renderAndAssertNonEmpty(
+ DocumentTemplate template,
+ CvDocument doc) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(420, 595)
+ .margin(DocumentInsets.of(28))
+ .create()) {
+ template.compose(session, doc);
+ assertThat(session.roots()).isNotEmpty();
+ }
+ }
+
+ private static CvDocument fullDocument() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Jane", "Doe")
+ .jobTitle("Backend Engineer")
+ .contact("+44 0", "j@d.com", "London")
+ .link("LinkedIn", "https://linkedin.com/in/jane-doe")
+ .build())
+ .sections(
+ new ParagraphSection("Professional Summary",
+ "Builds **reliable** document pipelines."),
+ SkillsSection.builder("Technical Skills")
+ .group("Languages", "Java 21", "Kotlin")
+ .group("Testing", "JUnit 5", "AssertJ")
+ .build(),
+ EntriesSection.builder("Education & Certifications")
+ .entry("MSc Computer Science",
+ "University of Manchester",
+ "2019-2021",
+ "Distinction.")
+ .build(),
+ RowsSection.builder("Projects", RowStyle.BULLETED_STACKED)
+ .row("GraphCompose (Java, PDFBox)",
+ "Declarative PDF layout engine.")
+ .build(),
+ EntriesSection.builder("Professional Experience")
+ .entry("Engineer", "Acme", "2021-2024",
+ "Built rendering services.")
+ .build(),
+ RowsSection.builder("Additional Information", RowStyle.PLAIN)
+ .row("Languages", "English, German")
+ .build())
+ .build();
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlinedSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlinedSmokeTest.java
index a2235a8f..9819029d 100644
--- a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlinedSmokeTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlinedSmokeTest.java
@@ -8,8 +8,7 @@
import com.demcha.compose.document.templates.cv.v2.data.CvIdentity;
import com.demcha.compose.document.templates.cv.v2.data.EntriesSection;
import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection;
-import com.demcha.compose.document.templates.cv.v2.data.RowStyle;
-import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
+import com.demcha.compose.document.templates.cv.v2.data.SkillsSection;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
import org.junit.jupiter.api.Test;
@@ -62,8 +61,8 @@ private static CvDocument fullDocument() {
.build())
.sections(
new ParagraphSection("Summary", "body"),
- RowsSection.builder("Skills", RowStyle.BULLETED)
- .row("Languages", "Java").build(),
+ SkillsSection.builder("Skills")
+ .group("Languages", "Java").build(),
EntriesSection.builder("Experience")
.entry("Engineer", "Acme", "2020", "did stuff").build())
.build();
diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessionalSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessionalSmokeTest.java
index 88a30db1..f7ca50e8 100644
--- a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessionalSmokeTest.java
+++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessionalSmokeTest.java
@@ -8,8 +8,7 @@
import com.demcha.compose.document.templates.cv.v2.data.CvIdentity;
import com.demcha.compose.document.templates.cv.v2.data.EntriesSection;
import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection;
-import com.demcha.compose.document.templates.cv.v2.data.RowStyle;
-import com.demcha.compose.document.templates.cv.v2.data.RowsSection;
+import com.demcha.compose.document.templates.cv.v2.data.SkillsSection;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
import org.junit.jupiter.api.Test;
@@ -72,8 +71,8 @@ private static CvDocument fullDocument() {
.build())
.sections(
new ParagraphSection("Summary", "body"),
- RowsSection.builder("Skills", RowStyle.BULLETED)
- .row("Languages", "Java").build(),
+ SkillsSection.builder("Skills")
+ .group("Languages", "Java").build(),
EntriesSection.builder("Experience")
.entry("Engineer", "Acme", "2020", "did stuff").build())
.build();
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 d8839e41..da753912 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
@@ -25,6 +25,9 @@ void headline_variants_render_without_throwing() throws Exception {
renderWithSection(section -> {
Headline.spacedCentered(section, name(), CvTheme.boxedClassic());
});
+ renderWithSection(section -> {
+ Headline.uppercaseCentered(section, name(), CvTheme.editorialBlue());
+ });
renderWithSection(section -> {
Headline.rightAligned(section, name(), CvTheme.boxedClassic());
});
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
new file mode 100644
index 00000000..65dd8c7b
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/widgets/TableWidgetTest.java
@@ -0,0 +1,77 @@
+package com.demcha.compose.document.templates.widgets;
+
+import com.demcha.compose.GraphCompose;
+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.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TableWidgetTest {
+
+ @Test
+ void fixed_table_renders_with_custom_border_and_fill() throws Exception {
+ render(section -> TableWidget.fixed(section,
+ List.of(
+ List.of("Name", "Role"),
+ List.of("Jordan", "Engineer")),
+ 240,
+ TableWidget.Style.builder()
+ .name("SharedWidgetTable")
+ .columns(2)
+ .cellPadding(new DocumentInsets(3, 4, 3, 4))
+ .border(DocumentColor.rgb(80, 120, 180), 0.5)
+ .cellFillColor(DocumentColor.rgb(248, 250, 252))
+ .textStyle(bodyStyle())
+ .build()));
+ }
+
+ @Test
+ void grid_table_renders_with_zebra_rows() throws Exception {
+ render(section -> TableWidget.grid(section,
+ List.of("Java", "Kotlin", "SQL", "PDFBox", "Maven"),
+ 240,
+ TableWidget.Style.builder()
+ .name("SharedWidgetGrid")
+ .columns(3)
+ .cellPadding(new DocumentInsets(2, 3, 2, 3))
+ .border(DocumentColor.rgb(180, 190, 205), 0.5)
+ .zebraFillColor(DocumentColor.rgb(245, 247, 250))
+ .textStyle(bodyStyle())
+ .widthAdjustment(1.0)
+ .build()));
+ }
+
+ private static void render(SectionAction action) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(320, 420)
+ .margin(DocumentInsets.of(24))
+ .create()) {
+ session.dsl().pageFlow()
+ .name("SharedTableWidgetRoot")
+ .addSection("SharedTableWidgetSlot", action::run)
+ .build();
+ assertThat(session.roots()).isNotEmpty();
+ }
+ }
+
+ 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);
+ }
+}
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png
index 9ce90da029250185340843341afbc463bb476a88..4af9eb4d3fab3c01da371a789a299fac99404d2f 100644
GIT binary patch
delta 74160
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