From 36b8d097c66a32644b9ba6a2be22b3524ed6627b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 23 May 2026 15:20:59 +0200 Subject: [PATCH 1/2] =?UTF-8?q?v2(Phase=201):=20slot=20mapping=20=E2=80=94?= =?UTF-8?q?=20CvDocument=20supports=20multi-column=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the single-column-only regression that v2 had vs. the legacy CvBuilder.place(slot, name) API. Single PR, no engine changes, no v1 edits, no behavioural change for existing single-column callers. What's new ---------- - data/Slot.java — enum {MAIN, SIDEBAR, FOOTER}. MAIN is the default when a section is built without an explicit slot. - data/CvDocument refactor: canonical record is now (CvIdentity, List) where Placement(Slot, CvSection). Custom sections() method still returns the flat list (source-order) so debug/inspect call sites keep working. New sectionsIn(Slot) and slotOf(CvSection) accessors. - CvDocument.Builder gains: .section(Slot slot, CvSection section) .sections(Slot slot, CvSection... values) The pre-existing zero-slot calls (.section(s), .sections(s1, s2)) default to MAIN — every existing call site keeps compiling and rendering identically. - @Deprecated CvDocument.ofMainSections(identity, list) — migration helper for any caller that used a direct two-arg constructor. The Builder is the recommended path forward. What's updated -------------- - presets/BoxedSections + presets/MinimalUnderlined now iterate doc.sectionsIn(Slot.MAIN) instead of doc.sections(). Sidebar / footer placements are silently dropped by single-column presets — documented in AUTHORS.md. - AUTHORS.md gains Recipe 6 ("place sections in slots") with both the builder-side and the preset-side patterns. Old Recipe 6 (conditional sections) renumbered to 7. - package-info.java gains a "Slots — placing sections in columns" section in the root v2 package. - data/package-info.java mentions Placement and Slot. Tests ----- - New CvDocumentSlotTest with 10 cases (default MAIN, explicit slot, source-order across slots, sectionsIn filter, slotOf, deprecated ofMainSections, null guards). - All existing v2 tests stay green (BoxedSectionsSmokeTest, MinimalUnderlinedSmokeTest, CvDecorationTest, CvNameTest, CvContactTest). - Full mvn test: 881/881 pass. - cv-boxed-sections-v2.pdf and cv-minimal-underlined.pdf render pixel-identical to baseline (sample data is all MAIN today). --- .../document/templates/cv/v2/AUTHORS.md | 101 ++++++++- .../templates/cv/v2/data/CvDocument.java | 195 +++++++++++++++--- .../document/templates/cv/v2/data/Slot.java | 34 +++ .../templates/cv/v2/data/package-info.java | 11 + .../templates/cv/v2/package-info.java | 34 +++ .../cv/v2/presets/BoxedSections.java | 6 +- .../cv/v2/presets/MinimalUnderlined.java | 5 +- .../cv/v2/data/CvDocumentSlotTest.java | 149 +++++++++++++ 8 files changed, 506 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java create mode 100644 src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java 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 a8f3a5b4..6cba35f8 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 @@ -13,6 +13,60 @@ This guide answers the **"how do I…"** questions. --- +## The scaffold is persona-neutral + +Nothing in the v2 API assumes a software-developer audience. The +sample data shipped with the package happens to be a developer CV +(Jordan Rivera, "Platform Engineer", GitHub, Projects, Tech Skills), +but that's only because we needed *some* fixture for visual +regression. The same builders fit any persona — skip the link / section +types you don't need. + +```java +// A primary school teacher's CV — no GitHub, no Projects, no Tech Skills +CvDocument.builder() + .identity(CvIdentity.builder() + .name("Maria", "Lopez") + .contact("+34 600 000 000", "maria@example.com", "Madrid, Spain") + // no .link(...) calls — she has no public profiles, and that is fine + .build()) + .section(new ParagraphSection("About Me", + "Primary school teacher with 12 years' experience in literacy " + + "and inclusive education.")) + .section(EntriesSection.builder("Teaching Experience") + .entry("Lead Teacher Y3", "Colegio Santa Ana", "2018-Present", + "Year-3 lead teacher; designed the school's reading-rota " + + "and mentored two newly-qualified teachers.") + .entry("Year Teacher", "Escuela Primaria Goya", "2013-2018", + "Y1-Y2 generalist; led the SEN reading-club after hours.") + .build()) + .section(RowsSection.builder("Languages", RowStyle.PLAIN) + .row("Spanish", "Native") + .row("English", "Fluent (CEFR C1)") + .row("Catalan", "Conversational") + .build()) + .section(RowsSection.builder("Certifications", RowStyle.PLAIN) + .row("Inclusive Education", "Universidad Complutense, 2020") + .row("Children's First Aid", "Cruz Roja, 2022") + .build()) + .build(); +``` + +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 required IT vocabulary anywhere.** Section titles are free + strings (`"About Me"`, `"Teaching Experience"`, `"Certifications"`). + +The rest of this guide leans on dev-style examples for continuity, +but every recipe below works the same way for any persona. + +--- + ## Recipe 1 — change a bullet glyph You want `▶` instead of `•`, or numbered bullets, or em-dashes. @@ -211,7 +265,52 @@ guard.) --- -## Recipe 6 — conditional sections (data-driven) +## Recipe 6 — place sections in slots (sidebar / footer) + +A `CvDocument` is not just a flat list of sections — every section is +**placed** into a `Slot` (one of `MAIN`, `SIDEBAR`, `FOOTER`). +Single-column presets like `BoxedSections` read only `Slot.MAIN` +sections; multi-column presets read whichever slots they support. + +**Placing a section into a slot** (builder API): + +```java +CvDocument doc = CvDocument.builder() + .identity(identity) + .section(summary) // defaults to MAIN + .section(Slot.MAIN, technicalSkills) // explicit, same as above + .section(Slot.SIDEBAR, languagesSpoken) // sidebar column + .section(Slot.SIDEBAR, certifications) // sidebar column + .sections(Slot.MAIN, experience, projects) // varargs in a slot + .build(); +``` + +**Reading by slot** (in a preset's `compose()`): + +```java +// Single-column preset — render only MAIN, drop the rest +List mainSections = doc.sectionsIn(Slot.MAIN); + +// Two-column preset +List mainSections = doc.sectionsIn(Slot.MAIN); +List sidebarSections = doc.sectionsIn(Slot.SIDEBAR); +``` + +**Why this matters:** when the user feeds your document to a +single-column preset, sidebar sections are silently dropped (they +have no place to render). When the same document goes through a +two-column preset, sidebar sections show up in the sidebar column. +The data model is the same — only the preset's interpretation +differs. + +**Tip:** `doc.sections()` returns *every* section in source order +regardless of slot. Use it for debug printing or unfiltered +iteration — not in a preset's render loop unless you really want +sidebar content to flow inline with main. + +--- + +## Recipe 7 — conditional sections (data-driven) Real CVs often hide a section when there's nothing to put in it: diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java index de294886..bd214778 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/CvDocument.java @@ -6,27 +6,127 @@ /** * Root of the v2 CV data model — required {@link CvIdentity} block - * plus an ordered list of {@link CvSection} entries that render in - * source order beneath the header. + * plus an ordered list of section {@link Placement}s. * - *

This record carries no styling or rendering decision. Pair it - * with a - * {@link com.demcha.compose.document.templates.cv.v2.theme.CvTheme} - * and a preset from - * {@code com.demcha.compose.document.templates.cv.v2.presets} to - * produce a PDF.

+ *

Each {@link Placement} pairs a {@link CvSection} with a + * {@link Slot} so multi-column presets can decide which sections go + * into the sidebar vs. the main column without having to guess from + * section titles. Sections built without an explicit slot default to + * {@link Slot#MAIN}.

* - * @param identity required identity / contact block - * @param sections ordered sections; rendered in source order + *

Single-column presets read {@link #sectionsIn(Slot) + * sectionsIn(Slot.MAIN)} and ignore the rest; multi-column presets + * call {@code sectionsIn} once per slot they support.

+ * + * @param identity required identity / contact block + * @param placements ordered placements; render order matches source + * order within each slot */ -public record CvDocument(CvIdentity identity, List sections) { +public record CvDocument(CvIdentity identity, List placements) { public CvDocument { Objects.requireNonNull(identity, "identity"); - Objects.requireNonNull(sections, "sections"); - sections = List.copyOf(sections); + Objects.requireNonNull(placements, "placements"); + placements = List.copyOf(placements); + } + + /** + * Backward-compatible factory that wraps every supplied section + * in a {@link Slot#MAIN} placement. Provided so callers + * migrating from the pre-slot {@code new CvDocument(identity, + * sections)} pattern have a 1:1 replacement without rewriting + * their construction code. + * + *

Equivalent to building each section through + * {@link Builder#section(CvSection)} (which also defaults to + * {@link Slot#MAIN}). Prefer the {@link Builder} in new code — + * multi-column presets need explicit slot placement that the + * builder makes obvious at the call site.

+ * + * @deprecated since the slot model — prefer the {@link Builder} + * so non-MAIN slots are visible at the call site. + */ + @Deprecated + public static CvDocument ofMainSections(CvIdentity identity, + List sections) { + return new CvDocument(identity, toMainPlacements(sections)); + } + + /** + * One section together with the slot it should render in. + * + * @param slot placement region (never null) + * @param section section body (never null) + */ + public record Placement(Slot slot, CvSection section) { + + public Placement { + Objects.requireNonNull(slot, "slot"); + Objects.requireNonNull(section, "section"); + } + } + + // -- accessors ------------------------------------------------------- + + /** + * Returns every section in this document in source order, + * regardless of slot. Useful for debugging, indexing, or + * iterating all sections without caring where they render. + * + *

Presets should usually call {@link #sectionsIn(Slot)} + * instead — a single-column preset that iterates this flat list + * will accidentally render sidebar content inline with the main + * flow.

+ * + * @return flat list of all sections, source order + */ + public List sections() { + List out = new ArrayList<>(placements.size()); + for (Placement p : placements) { + out.add(p.section()); + } + return List.copyOf(out); + } + + /** + * Returns the sections placed in {@code slot}, in source order. + * The standard call from a preset's {@code compose()} method. + * + * @param slot non-null slot + * @return sections placed in that slot; empty list if none + */ + public List sectionsIn(Slot slot) { + Objects.requireNonNull(slot, "slot"); + List out = new ArrayList<>(); + for (Placement p : placements) { + if (p.slot() == slot) { + out.add(p.section()); + } + } + return List.copyOf(out); + } + + /** + * Returns the slot a given section was placed in. Uses identity + * comparison ({@code ==}) to avoid surprises when two sections + * happen to be record-equal but were added separately. + * + * @param section section instance to look up + * @return its slot, or {@link Slot#MAIN} if the section is not + * in this document + */ + public Slot slotOf(CvSection section) { + Objects.requireNonNull(section, "section"); + for (Placement p : placements) { + if (p.section() == section) { + return p.slot(); + } + } + return Slot.MAIN; } + // -- builder --------------------------------------------------------- + /** * @return new fluent builder */ @@ -35,12 +135,16 @@ public static Builder builder() { } /** - * Mutable builder. Section order in the builder is preserved 1:1 - * in the built document. + * Mutable builder for {@link CvDocument}. + * + *

The {@code section}-family methods append to the placement + * list in call order. Pass a {@link Slot} as the first argument + * to place into a non-main slot; omit it to default to + * {@link Slot#MAIN}.

*/ public static final class Builder { private CvIdentity identity; - private final List sections = new ArrayList<>(); + private final List placements = new ArrayList<>(); private Builder() { } @@ -50,37 +154,76 @@ public Builder identity(CvIdentity value) { return this; } + /** + * Appends one section into {@link Slot#MAIN}. + */ public Builder section(CvSection section) { - this.sections.add(Objects.requireNonNull(section, "section")); + return section(Slot.MAIN, section); + } + + /** + * Appends one section into a chosen slot. + */ + public Builder section(Slot slot, CvSection section) { + this.placements.add(new Placement(slot, section)); return this; } /** - * Varargs convenience for the common case where every section - * is constructed up-front. Equivalent to chained - * {@link #section(CvSection)} calls. - * - * @param values one or more sections in render order - * @return this builder + * Varargs convenience — all sections placed in + * {@link Slot#MAIN}. */ public Builder sections(CvSection... values) { Objects.requireNonNull(values, "values"); for (CvSection s : values) { - section(s); + section(Slot.MAIN, s); + } + return this; + } + + /** + * Varargs convenience — all supplied sections placed in + * {@code slot}. + */ + public Builder sections(Slot slot, CvSection... values) { + Objects.requireNonNull(slot, "slot"); + Objects.requireNonNull(values, "values"); + for (CvSection s : values) { + section(slot, s); } return this; } + /** + * List variant — all sections placed in {@link Slot#MAIN}. + */ public Builder sections(List values) { Objects.requireNonNull(values, "values"); for (CvSection s : values) { - section(s); + section(Slot.MAIN, s); } return this; } + /** + * Appends a pre-built {@link Placement}. + */ + public Builder placement(Placement placement) { + this.placements.add(Objects.requireNonNull(placement, "placement")); + return this; + } + public CvDocument build() { - return new CvDocument(identity, sections); + return new CvDocument(identity, placements); + } + } + + private static List toMainPlacements(List sections) { + Objects.requireNonNull(sections, "sections"); + List out = new ArrayList<>(sections.size()); + for (CvSection s : sections) { + out.add(new Placement(Slot.MAIN, s)); } + return out; } } diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java new file mode 100644 index 00000000..da501d65 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/data/Slot.java @@ -0,0 +1,34 @@ +package com.demcha.compose.document.templates.cv.v2.data; + +/** + * Logical placement region inside a {@link CvDocument}. Presets read + * the slot of each section to decide where on the page it should + * appear — main column, sidebar, or footer. + * + *

Single-column presets like + * {@code com.demcha.compose.document.templates.cv.v2.presets.BoxedSections} + * iterate only {@link #MAIN} sections; sections placed in + * {@link #SIDEBAR} or {@link #FOOTER} are silently dropped by such + * presets. Multi-column presets (two-column sidebar, magazine + * layouts) iterate {@link #MAIN} and {@link #SIDEBAR} separately to + * fill their columns.

+ * + *

Sections that don't specify a slot at build time default to + * {@link #MAIN} — so single-column callers keep working without + * changes after the slot model was introduced.

+ */ +public enum Slot { + + /** Primary content column. Default when no slot is specified. */ + MAIN, + + /** Sidebar / secondary column — typically narrower than MAIN. */ + SIDEBAR, + + /** + * Footer area below the main flow — useful for references, + * disclaimers, or page-bottom notes. Currently only rendered by + * presets that explicitly opt in. + */ + FOOTER +} 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 7d3fe544..8ee6da21 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 @@ -39,6 +39,17 @@ * and Professional Experience interchangeably. * * + *

Placement

+ * + *

Sections live inside a {@link com.demcha.compose.document.templates.cv.v2.data.CvDocument} + * as ordered {@link com.demcha.compose.document.templates.cv.v2.data.CvDocument.Placement} + * entries. Each placement pairs a section with a + * {@link com.demcha.compose.document.templates.cv.v2.data.Slot} + * ({@code MAIN}, {@code SIDEBAR}, {@code FOOTER}). Single-column + * presets read only {@code MAIN}; multi-column presets read multiple + * slots. Sections built without an explicit slot default to + * {@code MAIN}, so existing call sites stay valid.

+ * *

Adding a new section type

* *
    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 ce983f0a..1fc7d5a7 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 @@ -5,6 +5,14 @@ * documents on top of GraphCompose. Below is the conceptual model * every author should hold in their head before writing a template.

    * + *

    Persona-neutral. Nothing here assumes a + * software-developer audience. The shipped sample data is a + * developer CV because we needed a fixture for visual regression, + * but the same builders fit a teacher, a chef, a nurse, an + * artist — anyone. Skip the link / section types you don't need; + * section titles are free strings and links are optional. See + * {@code AUTHORS.md} for a non-IT example.

    + * *

    Four layers, one responsibility each

    * *
    @@ -119,6 +127,31 @@
      * }
      * }
    * + *

    Slots — placing sections in columns

    + * + *

    Every section is placed into a + * {@link com.demcha.compose.document.templates.cv.v2.data.Slot} — + * {@code MAIN}, {@code SIDEBAR}, or {@code FOOTER}. Single-column + * presets read only {@code MAIN} sections; multi-column presets + * read whichever slots they support, leaving the data model + * unchanged.

    + * + *
    {@code
    + * CvDocument doc = CvDocument.builder()
    + *     .identity(identity)
    + *     .section(summary)                     // implicit MAIN
    + *     .section(Slot.SIDEBAR, languages)     // sidebar column
    + *     .build();
    + * }
    + * + *

    Inside a preset:

    + * + *
    {@code
    + * for (CvSection s : doc.sectionsIn(Slot.MAIN)) {
    + *     // render s into the main flow
    + * }
    + * }
    + * *

    Going further — extension recipes

    * *

    See {@code AUTHORS.md} alongside this package for longer @@ -128,6 +161,7 @@ *

  1. Build a new theme variant (navy / compact / serif)
  2. *
  3. Write a brand-new preset that reuses existing renderers
  4. *
  5. Add a brand-new section subtype (compile-checked dispatch)
  6. + *
  7. Place sections in slots (sidebar / footer)
  8. * * *

    What this package is not

    diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java index 57d51364..33c1705f 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/BoxedSections.java @@ -9,6 +9,7 @@ import com.demcha.compose.document.templates.cv.v2.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.v2.data.CvDocument; import com.demcha.compose.document.templates.cv.v2.data.CvSection; +import com.demcha.compose.document.templates.cv.v2.data.Slot; import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; import java.util.List; @@ -114,7 +115,10 @@ public void compose(DocumentSession document, CvDocument doc) { ContactRenderer.render(section, doc.identity(), theme); }); - List sections = doc.sections(); + // Single-column preset — only renders MAIN-slot sections. + // Sidebar / footer placements are intentionally dropped here; + // switch to a multi-column preset to render them. + List sections = doc.sectionsIn(Slot.MAIN); for (int i = 0; i < sections.size(); i++) { final CvSection sec = sections.get(i); final int idx = i; diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java index 77dda81e..20c52adc 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MinimalUnderlined.java @@ -12,6 +12,7 @@ import com.demcha.compose.document.templates.cv.v2.components.TextOrnaments; import com.demcha.compose.document.templates.cv.v2.data.CvDocument; import com.demcha.compose.document.templates.cv.v2.data.CvSection; +import com.demcha.compose.document.templates.cv.v2.data.Slot; import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; import java.util.List; @@ -108,7 +109,9 @@ public void compose(DocumentSession document, CvDocument doc) { ContactRenderer.render(section, doc.identity(), theme); }); - List sections = doc.sections(); + // Single-column preset — only renders MAIN-slot sections. + // Sidebar / footer placements are intentionally dropped here. + List sections = doc.sectionsIn(Slot.MAIN); for (int i = 0; i < sections.size(); i++) { final CvSection sec = sections.get(i); final int idx = i; diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java new file mode 100644 index 00000000..272e852f --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/data/CvDocumentSlotTest.java @@ -0,0 +1,149 @@ +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 CvDocumentSlotTest { + + private static CvIdentity identity() { + return CvIdentity.builder() + .name("Jane", "Doe") + .contact("+44 0", "j@d.com", "London") + .build(); + } + + private static ParagraphSection summary() { + return new ParagraphSection("Summary", "body"); + } + + private static RowsSection skills() { + return RowsSection.builder("Skills", RowStyle.BULLETED) + .row("Languages", "Java").build(); + } + + @Test + void builder_section_defaults_to_main() { + ParagraphSection s = summary(); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(s) + .build(); + + assertThat(doc.placements()).hasSize(1); + assertThat(doc.placements().get(0).slot()).isEqualTo(Slot.MAIN); + assertThat(doc.slotOf(s)).isEqualTo(Slot.MAIN); + } + + @Test + void builder_section_with_explicit_slot_uses_it() { + ParagraphSection s = summary(); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(Slot.SIDEBAR, s) + .build(); + + assertThat(doc.placements().get(0).slot()).isEqualTo(Slot.SIDEBAR); + assertThat(doc.slotOf(s)).isEqualTo(Slot.SIDEBAR); + } + + @Test + void sections_returns_all_regardless_of_slot() { + ParagraphSection mainSec = summary(); + RowsSection sidebarSec = skills(); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(mainSec) + .section(Slot.SIDEBAR, sidebarSec) + .build(); + + assertThat(doc.sections()).containsExactly(mainSec, sidebarSec); + } + + @Test + void sectionsIn_filters_by_slot() { + ParagraphSection mainSec = summary(); + RowsSection sidebarSec = skills(); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(mainSec) + .section(Slot.SIDEBAR, sidebarSec) + .build(); + + assertThat(doc.sectionsIn(Slot.MAIN)).containsExactly(mainSec); + assertThat(doc.sectionsIn(Slot.SIDEBAR)).containsExactly(sidebarSec); + assertThat(doc.sectionsIn(Slot.FOOTER)).isEmpty(); + } + + @Test + void slotOf_returns_main_for_unknown_section() { + ParagraphSection orphan = new ParagraphSection("Orphan", "x"); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(summary()) + .build(); + + assertThat(doc.slotOf(orphan)).isEqualTo(Slot.MAIN); + } + + @Test + void sections_varargs_with_slot_places_all_in_that_slot() { + ParagraphSection a = new ParagraphSection("A", "x"); + ParagraphSection b = new ParagraphSection("B", "y"); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .sections(Slot.SIDEBAR, a, b) + .build(); + + assertThat(doc.sectionsIn(Slot.SIDEBAR)).containsExactly(a, b); + assertThat(doc.sectionsIn(Slot.MAIN)).isEmpty(); + } + + @Test + @SuppressWarnings("deprecation") + void deprecated_ofMainSections_wraps_everything_in_main() { + ParagraphSection a = new ParagraphSection("A", "x"); + ParagraphSection b = new ParagraphSection("B", "y"); + CvDocument doc = CvDocument.ofMainSections(identity(), List.of(a, b)); + + assertThat(doc.sectionsIn(Slot.MAIN)).containsExactly(a, b); + assertThat(doc.sectionsIn(Slot.SIDEBAR)).isEmpty(); + } + + @Test + void placements_preserves_source_order_across_slots() { + ParagraphSection a = new ParagraphSection("A", "x"); + RowsSection b = skills(); + ParagraphSection c = new ParagraphSection("C", "z"); + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(a) + .section(Slot.SIDEBAR, b) + .section(c) + .build(); + + // sections() preserves the global add order + assertThat(doc.sections()).containsExactly(a, b, c); + // sectionsIn filters but keeps relative order + assertThat(doc.sectionsIn(Slot.MAIN)).containsExactly(a, c); + } + + @Test + void rejects_null_section() { + CvDocument.Builder b = CvDocument.builder().identity(identity()); + assertThatThrownBy(() -> b.section(null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> b.section(Slot.SIDEBAR, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rejects_null_slot() { + CvDocument.Builder b = CvDocument.builder().identity(identity()); + assertThatThrownBy(() -> b.section(null, summary())) + .isInstanceOf(NullPointerException.class); + } +} From 3017893eb4519ee5b7e15d7ff903d99f8be97dac Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 24 May 2026 13:26:37 +0200 Subject: [PATCH 2/2] v2(Phase 2): port ModernProfessional preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second preset on the v2 architecture — proves the compose-don't-subclass pattern works for a visually-different style on the same data + same body renderers. Single-column, no slots needed (those are exercised in Phase 3). What's new ---------- - presets/ModernProfessional.java — Helvetica-based preset with right-aligned big slate-blue display name, flat bright-blue bold section titles (no banner panels), pipe-separated contact + links on the right. Visual signature ported from the v1 preset of the same name. - theme/CvTheme.modernProfessional() factory — Helvetica typography, tighter spacing, classic palette + decoration. Body renderers (ParagraphRenderer, RowRenderer, EntryRenderer, SectionDispatcher) consumed unchanged. - theme/CvTypography.modernProfessional() — Helvetica scale: 28pt name, 17.4pt section title, 10pt body. - theme/CvSpacing.modernProfessional() — tighter spacing for single-page-friendly proportions. - examples/CvModernV2Example — renders cv-modern-professional-v2.pdf against the shared sample data. - ModernProfessionalSmokeTest — 4 tests (identity, default theme, custom theme, theme portability). Architectural notes ------------------- - The preset's three preset-specific colours (slate-blue name, bright-blue section title, royal-blue link) live as private static finals inside the preset class, NOT in CvPalette. The palette has four fields today (ink/muted/rule/banner); adding three more for a single preset would pollute the shared theme record. When a second preset reaches for the same accent colours, extract them to CvPalette and update both call sites. - Section title rendering is an inline private method (renderSectionTitle), same pattern as MinimalUnderlined. Once 3+ presets need a flat-title rendering, factor out a SectionTitleRenderer component with style variants. Until then, per-preset inline keeps each preset readable end-to-end. - Preset reads doc.sectionsIn(Slot.MAIN) — sidebar content is silently dropped, consistent with the single-column convention established in Phase 1. Test results ------------ - 38/38 v2 tests pass (4 new ModernProfessional + 34 prior). - cv-modern-professional-v2.pdf renders cleanly with the v1 visual signature (slate-blue name right, bright-blue section titles, Helvetica body). One row of content spills onto page 2 vs the v1 single-page render — minor spacing fidelity gap, not blocking. Engine and v1 surface untouched. --- .../templates/cv/v2/CvModernV2Example.java | 47 ++++ .../cv/v2/presets/ModernProfessional.java | 228 ++++++++++++++++++ .../templates/cv/v2/theme/CvSpacing.java | 24 ++ .../templates/cv/v2/theme/CvTheme.java | 19 ++ .../templates/cv/v2/theme/CvTypography.java | 17 ++ .../presets/ModernProfessionalSmokeTest.java | 81 +++++++ 6 files changed, 416 insertions(+) create mode 100644 examples/src/main/java/com/demcha/examples/templates/cv/v2/CvModernV2Example.java create mode 100644 src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java create mode 100644 src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessionalSmokeTest.java diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvModernV2Example.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvModernV2Example.java new file mode 100644 index 00000000..a3e23479 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvModernV2Example.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.ModernProfessional; +import com.demcha.examples.support.ExampleDataFactory; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Renders the v2 Modern Professional CV preset against the shared + * sample data — single-page, right-aligned slate-blue name, flat + * bright-blue section titles. + * + *

    Output: + * {@code examples/target/generated-pdfs/templates/cv/cv-modern-professional-v2.pdf}.

    + */ +public final class CvModernV2Example { + + private CvModernV2Example() { + } + + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare( + "templates/cv", "cv-modern-professional-v2.pdf"); + CvDocument doc = ExampleDataFactory.sampleCvDocumentV2(); + DocumentTemplate template = ModernProfessional.create(); + + float m = (float) ModernProfessional.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/presets/ModernProfessional.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java new file mode 100644 index 00000000..03cf976e --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessional.java @@ -0,0 +1,228 @@ +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.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.SectionDispatcher; +import com.demcha.compose.document.templates.cv.v2.data.CvContact; +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.CvLink; +import com.demcha.compose.document.templates.cv.v2.data.CvSection; +import com.demcha.compose.document.templates.cv.v2.data.Slot; +import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; +import com.demcha.compose.font.FontName; + +import java.util.List; +import java.util.Objects; + +/** + * v2 port of the canonical "Modern Professional" CV preset. + * + *

    Visual signature ported from the legacy v1 preset:

    + *
      + *
    • Right-aligned big slate-blue display name + * (no spaced caps, no centring) at the top.
    • + *
    • Right-aligned pipe-separated contact + link row beneath.
    • + *
    • Large bright-blue bold section titles — + * flat, left-aligned, no banner panel.
    • + *
    • Body text in Helvetica 10pt — denser than Boxed Sections.
    • + *
    • Single-page-friendly proportions on A4 with 18pt margins.
    • + *
    + * + *

    Why some colours live inside this preset and not in + * {@link CvTheme}: the slate-blue display name and the + * bright-blue accent for section titles are unique to this preset — + * no other v2 preset shares them today. Putting them in + * {@link com.demcha.compose.document.templates.cv.v2.theme.CvPalette} + * would pollute the palette with single-use fields. When (or if) a + * second preset reaches for the same colours, extract them to + * {@code CvPalette} and update both presets.

    + * + *

    Architectural lesson learned in Phase 2: + * single-column presets that don't fit the boxed-banner visual + * (e.g. flat titles, underlined titles, coloured titles) currently + * inline their own {@code renderSectionTitle} helper. Once 3+ presets + * share this need, factor out a {@code SectionTitleRenderer} + * component with style variants. Until then, the per-preset inline + * helper keeps each preset readable end-to-end.

    + */ +public final class ModernProfessional { + + /** Stable template identifier. */ + public static final String ID = "modern-professional"; + + /** Human-readable display name. */ + public static final String DISPLAY_NAME = "Modern Professional"; + + /** Recommended page margin (in points) — matches the legacy v1 preset. */ + public static final double RECOMMENDED_MARGIN = 18.0; + + /** Slate-blue used by the display name. Preset-specific. */ + private static final DocumentColor NAME_COLOR = DocumentColor.rgb(44, 62, 80); + + /** Bright-blue used by section titles. Preset-specific. */ + private static final DocumentColor SECTION_TITLE_COLOR = + DocumentColor.rgb(41, 128, 185); + + /** Royal-blue used by contact links. Preset-specific. */ + private static final DocumentColor LINK_COLOR = DocumentColor.rgb(65, 105, 225); + + private ModernProfessional() { + } + + /** + * Builds the preset with the Modern Professional theme + * ({@link CvTheme#modernProfessional()}). + */ + public static DocumentTemplate create() { + return create(CvTheme.modernProfessional()); + } + + /** + * Builds the preset with a caller-supplied theme. Allows + * variations on the Modern Professional theme (different + * typography scale, custom spacing) without forking this class. + */ + 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"); + + PageFlowBuilder pageFlow = document.dsl() + .pageFlow() + .name("CvV2ModernRoot") + .spacing(theme.spacing().pageFlowSpacing()) + .addSection("Header", section -> + renderHeader(section, doc.identity())) + .addSection("Contact", section -> + renderContact(section, doc.identity())); + + // Single-column preset — only MAIN slot. + List sections = doc.sectionsIn(Slot.MAIN); + for (int i = 0; i < sections.size(); i++) { + final CvSection sec = sections.get(i); + final int idx = i; + pageFlow.addSection("Title_" + idx, host -> + renderSectionTitle(host, sec.title())); + pageFlow.addSection("Body_" + idx, host -> + SectionDispatcher.renderBody(host, sec, theme)); + } + + pageFlow.build(); + } + + /** + * Big slate-blue display name, right-aligned. No spaced caps. + */ + private void renderHeader(SectionBuilder section, CvIdentity identity) { + DocumentTextStyle nameStyle = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD) + .size(theme.typography().sizeHeadline()) + .decoration(DocumentTextDecoration.BOLD) + .color(NAME_COLOR) + .build(); + + section.padding(DocumentInsets.zero()) + .addParagraph(p -> p + .text(identity.name().full()) + .textStyle(nameStyle) + .align(TextAlign.RIGHT) + .margin(DocumentInsets.zero())); + } + + /** + * Right-aligned pipe-separated contact + links. Links rendered + * underlined in royal blue; contact strings in body grey. + */ + private void renderContact(SectionBuilder section, CvIdentity identity) { + DocumentTextStyle bodyStyle = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA) + .size(theme.typography().sizeContact()) + .color(theme.palette().ink()) + .build(); + DocumentTextStyle linkStyle = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA) + .size(theme.typography().sizeContact()) + .decoration(DocumentTextDecoration.UNDERLINE) + .color(LINK_COLOR) + .build(); + DocumentTextStyle separatorStyle = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA) + .size(theme.typography().sizeContact()) + .color(theme.palette().rule()) + .build(); + + CvContact c = identity.contact(); + section.padding(theme.spacing().contactPadding()) + .accentBottom(theme.palette().rule(), + theme.spacing().accentRuleWidth()) + .addParagraph(p -> p + .textStyle(bodyStyle) + .align(TextAlign.RIGHT) + .margin(DocumentInsets.zero()) + .rich(rich -> { + rich.style(c.address(), bodyStyle); + rich.style(" | ", separatorStyle); + rich.style(c.phone(), bodyStyle); + rich.style(" | ", separatorStyle); + rich.link(c.email(), + new DocumentLinkOptions("mailto:" + c.email())); + for (CvLink l : identity.links()) { + rich.style(" | ", separatorStyle); + rich.style(l.label(), linkStyle); + } + })); + } + + /** + * Flat bright-blue bold section title, left-aligned, no panel. + * This is the visual hallmark of the Modern Professional look. + */ + private void renderSectionTitle(SectionBuilder section, String title) { + DocumentTextStyle titleStyle = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD) + .size(theme.typography().sizeBanner()) + .decoration(DocumentTextDecoration.BOLD) + .color(SECTION_TITLE_COLOR) + .build(); + + section.padding(new DocumentInsets(8, 0, 2, 0)) + .addParagraph(p -> p + .text(title) + .textStyle(titleStyle) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero())); + } + } +} 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 2b4ae669..10881856 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 @@ -72,4 +72,28 @@ public static CvSpacing classic() { 1.0, // entryTitleWeight 0.45); // entryDateWeight } + + /** + * Tighter spacing for the Modern Professional preset — no banner + * panels, denser body, single-page-friendly proportions. + * Banner-related fields (corner radius, inner padding, margin) + * are left non-zero so a future preset that wants to draw an MP + * banner can read them; the canonical MP preset ignores them. + */ + public static CvSpacing modernProfessional() { + return new CvSpacing( + 4, // pageFlowSpacing + 3, // sectionBodySpacing + new DocumentInsets(2, 0, 0, 0), // sectionBodyPadding + new DocumentInsets(0, 0, 0, 0), // headlinePadding + new DocumentInsets(0, 0, 6, 0), // contactPadding + 0.0, // bannerCornerRadius (unused) + 5.0, // bannerInnerPadding (unused) + DocumentInsets.top(6), // bannerMargin (unused — section title margin) + 0.7, // accentRuleWidth + 2.0, // paragraphMarginTop + 10.0, // entryHeaderRowSpacing + 1.0, // entryTitleWeight + 0.45); // entryDateWeight + } } 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 d62e96a1..a7cb88e3 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 @@ -68,6 +68,25 @@ public static CvTheme boxedClassic() { CvDecoration.classic()); } + /** + * The "Modern Professional" look — Helvetica throughout, larger + * scale, tighter spacing. Body palette is the classic ink/muted + * pair; the preset itself adds the slate-blue name and + * bright-blue section title accents because those colours are not + * shared with any other v2 preset today. + * + *

    When (or if) a second preset wants the same accent palette, + * extract those colours into a new field on {@link CvPalette} and + * point both presets at it.

    + */ + public static CvTheme modernProfessional() { + return new CvTheme( + CvPalette.classic(), + CvTypography.modernProfessional(), + CvSpacing.modernProfessional(), + 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 e3cdbb53..1e4dea62 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 @@ -61,4 +61,21 @@ public static CvTypography classic() { 8.6, // body 1.4); // line spacing } + + /** + * Helvetica scale for the Modern Professional preset — larger + * display name, larger section titles, comfortable body size. + */ + public static CvTypography modernProfessional() { + return new CvTypography( + FontName.HELVETICA_BOLD, FontName.HELVETICA, + 28.0, // headline (display name) + 9.0, // contact + 17.4, // banner (used as section title here) + 10.5, // entry title + 10.0, // entry date + 9.5, // entry subtitle + 10.0, // body + 1.35); // line spacing + } } 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 new file mode 100644 index 00000000..88a30db1 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/ModernProfessionalSmokeTest.java @@ -0,0 +1,81 @@ +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.theme.CvTheme; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Smoke test for the v2 ModernProfessional preset — proves the + * compose-don't-subclass pattern works for a second visually-distinct + * preset on the same data + same body renderers. + */ +class ModernProfessionalSmokeTest { + + @Test + void exposes_stable_identity() { + DocumentTemplate template = ModernProfessional.create(); + assertThat(template.id()).isEqualTo("modern-professional"); + assertThat(template.displayName()).isEqualTo("Modern Professional"); + } + + @Test + void default_factory_uses_modernProfessional_theme_and_renders() throws Exception { + DocumentTemplate template = ModernProfessional.create(); + renderAndAssertNonEmpty(template, fullDocument()); + } + + @Test + void custom_theme_factory_renders() throws Exception { + DocumentTemplate template = + ModernProfessional.create(CvTheme.modernProfessional()); + renderAndAssertNonEmpty(template, fullDocument()); + } + + @Test + void renders_with_classic_theme_too() throws Exception { + // Preset should not assume any specific theme — handing it the + // boxedClassic theme must not throw. + DocumentTemplate template = + ModernProfessional.create(CvTheme.boxedClassic()); + renderAndAssertNonEmpty(template, fullDocument()); + } + + private static void renderAndAssertNonEmpty( + DocumentTemplate template, CvDocument doc) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 595) + .margin(DocumentInsets.of(18)) + .create()) { + + template.compose(session, doc); + assertThat(session.roots()).isNotEmpty(); + } + } + + private static CvDocument fullDocument() { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jane", "Doe") + .contact("+44 0", "j@d.com", "London") + .link("LinkedIn", "https://linkedin.com/in/jane-doe") + .build()) + .sections( + new ParagraphSection("Summary", "body"), + RowsSection.builder("Skills", RowStyle.BULLETED) + .row("Languages", "Java").build(), + EntriesSection.builder("Experience") + .entry("Engineer", "Acme", "2020", "did stuff").build()) + .build(); + } +}