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:

* * * *

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:

* * * *

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