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/BlueBanner.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java
index 8672607f..35af79cc 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BlueBanner.java
@@ -12,6 +12,7 @@
import com.demcha.compose.document.templates.cv.v2.components.MarkdownInline;
import com.demcha.compose.document.templates.cv.v2.components.ParagraphRenderer;
import com.demcha.compose.document.templates.cv.v2.components.RowRenderer;
+import com.demcha.compose.document.templates.cv.v2.components.SkillsRenderer;
import com.demcha.compose.document.templates.cv.v2.data.CvDocument;
import com.demcha.compose.document.templates.cv.v2.data.CvEntry;
import com.demcha.compose.document.templates.cv.v2.data.CvRow;
@@ -20,6 +21,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.data.Slot;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
import com.demcha.compose.document.templates.cv.v2.widgets.ContactLine;
@@ -174,6 +176,8 @@ private static void renderBody(SectionBuilder host,
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) {
renderRows(host, r, theme);
} else if (section instanceof EntriesSection e) {
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 9ce90da0..4af9eb4d 100644
Binary files a/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png and b/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png differ
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-0.png
new file mode 100644
index 00000000..90dfa808
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-0.png differ
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-1.png
new file mode 100644
index 00000000..16feaf78
Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-1.png differ
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png
index fd2a6f82..80e6f5b0 100644
Binary files a/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png and b/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png differ
diff --git a/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png
index 66173d0a..032b0395 100644
Binary files a/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png and b/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png differ