From d4e2b45d4261a767db6dfb6c7b1176bd12c45422 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 17 Aug 2026 14:23:40 +0100 Subject: [PATCH 1/4] chore(release): open the 2.3.0 development line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-release bump always opens the next patch, so the train sat on 2.2.1-SNAPSHOT. The next release adds public API to graph-compose-templates, which makes it a minor β€” and an @since tag written while the poms and the CHANGELOG name different lines has two answers to choose between, one of which outlives the cycle. The CHANGELOG heading records the real next version and the thirteen train poms follow it, which is the order VersionConsistencyGuardTest pins. The README development line and the roadmap's note of when the templates gate ships move with them. The install snippets stay on 2.2.0 β€” the version actually on Central. --- CHANGELOG.md | 2 +- README.md | 2 +- benchmarks/pom.xml | 2 +- bundle/pom.xml | 2 +- core/pom.xml | 2 +- coverage/pom.xml | 2 +- docs/roadmaps/post-2.0-engineering.md | 2 +- examples/pom.xml | 2 +- pom.xml | 2 +- qa/pom.xml | 2 +- render-docx/pom.xml | 2 +- render-pdf/pom.xml | 2 +- render-pptx/pom.xml | 2 +- templates/pom.xml | 2 +- testing/pom.xml | 2 +- wrapper/pom.xml | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaeda76dd..0a7affac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to GraphCompose are documented here. Versions follow semantic versioning; release dates are ISO 8601. -## v2.2.1 β€” Planned +## v2.3.0 β€” Planned ### Build diff --git a/README.md b/README.md index fb97aa3c0..e63ee3bc8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ > **Release status** — > 🟒 **Latest stable**: [v2.2.0](https://github.com/DemchaAV/GraphCompose/releases/tag/v2.2.0) — the **right-to-left** release: Hebrew and Arabic lay out, shape, join and mirror through PDF, PowerPoint and Word — in paragraphs and in table cells — with the fonts to render them. See [CHANGELOG.md](./CHANGELOG.md). ->  Β·  🟑 **In development**: v2.2.1 on `develop` — see [CHANGELOG.md](./CHANGELOG.md). +>  Β·  🟑 **In development**: v2.3.0 on `develop` — see [CHANGELOG.md](./CHANGELOG.md).

Live Showcase diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml index 1ad6ce226..fb743b64f 100644 --- a/benchmarks/pom.xml +++ b/benchmarks/pom.xml @@ -7,7 +7,7 @@ io.github.demchaav graph-compose-build - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT ../pom.xml diff --git a/bundle/pom.xml b/bundle/pom.xml index b4f4b508b..4a6e658d4 100644 --- a/bundle/pom.xml +++ b/bundle/pom.xml @@ -32,7 +32,7 @@ graph-compose and graph-compose-templates dependencies below use ${project.version}, so they follow automatically. --> - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT jar GraphCompose Bundle diff --git a/core/pom.xml b/core/pom.xml index 2d53ee4cf..25cdb4bd4 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -6,7 +6,7 @@ io.github.demchaav graph-compose-core - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT GraphCompose Core A declarative layout engine for programmatic document generation, implemented primarily in Java. This is the lean engine coordinate; depend on the `graph-compose` artifact for the drop-in, PDF-capable install. diff --git a/coverage/pom.xml b/coverage/pom.xml index c8ffe968f..7386afe42 100644 --- a/coverage/pom.xml +++ b/coverage/pom.xml @@ -29,7 +29,7 @@ io.github.demchaav graph-compose-build - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT ../pom.xml diff --git a/docs/roadmaps/post-2.0-engineering.md b/docs/roadmaps/post-2.0-engineering.md index e04ba0994..6e5da7a1c 100644 --- a/docs/roadmaps/post-2.0-engineering.md +++ b/docs/roadmaps/post-2.0-engineering.md @@ -111,7 +111,7 @@ exercise it. Report-only; thresholds can follow after a baseline read. `japicmp` ran report-only through the 2.0 major β€” the major intentionally broke binary compatibility. With the 2.0.0 GA artifacts on Central the gate switched to per-module baselines pinned at the major's floor (2.0.0) in break-on-incompatible -mode: `graph-compose-core` first, `graph-compose-templates` since 2.2.1 β€” each +mode: `graph-compose-core` first, `graph-compose-templates` since 2.3.0 β€” each module's `japicmp` profile lives in its own pom and runs in the PR-time `Binary Compatibility` job, in `cut-release.ps1` step 5b, and in the publish workflow. The remaining published modules (`render-pdf` / `render-docx` / diff --git a/examples/pom.xml b/examples/pom.xml index 18f7234e6..ec908264c 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -7,7 +7,7 @@ io.github.demchaav graph-compose-build - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT ../pom.xml diff --git a/pom.xml b/pom.xml index 72b797b8b..09849aeff 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.demchaav graph-compose-build - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT pom GraphCompose Build Aggregator diff --git a/qa/pom.xml b/qa/pom.xml index 59fd176ce..9d7a002fe 100644 --- a/qa/pom.xml +++ b/qa/pom.xml @@ -22,7 +22,7 @@ io.github.demchaav graph-compose-build - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT ../pom.xml diff --git a/render-docx/pom.xml b/render-docx/pom.xml index 06bf51427..535f40aac 100644 --- a/render-docx/pom.xml +++ b/render-docx/pom.xml @@ -19,7 +19,7 @@ --> io.github.demchaav graph-compose-render-docx - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT GraphCompose Render β€” DOCX Semantic DOCX export backend for GraphCompose, backed by Apache POI. diff --git a/render-pdf/pom.xml b/render-pdf/pom.xml index 97a4eb8e5..ebe0860b6 100644 --- a/render-pdf/pom.xml +++ b/render-pdf/pom.xml @@ -25,7 +25,7 @@ --> io.github.demchaav graph-compose-render-pdf - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT GraphCompose Render β€” PDF The PDFBox-backed PDF render backend for GraphCompose. diff --git a/render-pptx/pom.xml b/render-pptx/pom.xml index f231910b0..e856b0a4a 100644 --- a/render-pptx/pom.xml +++ b/render-pptx/pom.xml @@ -19,7 +19,7 @@ --> io.github.demchaav graph-compose-render-pptx - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT GraphCompose Render β€” PPTX PPTX render backends for GraphCompose: the coordinate-exact fixed-layout backend (POI XSLF) and the slide-safe semantic export skeleton. diff --git a/templates/pom.xml b/templates/pom.xml index bb95bf1ea..5c8822d6e 100644 --- a/templates/pom.xml +++ b/templates/pom.xml @@ -17,7 +17,7 @@ --> io.github.demchaav graph-compose-templates - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT GraphCompose Templates Built-in CV, cover-letter, invoice, and proposal document templates for GraphCompose. diff --git a/testing/pom.xml b/testing/pom.xml index 82398d0d6..2432158e2 100644 --- a/testing/pom.xml +++ b/testing/pom.xml @@ -19,7 +19,7 @@ --> io.github.demchaav graph-compose-testing - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT GraphCompose Testing Consumer testing support for GraphCompose: layout-snapshot assertions and PDF visual regression. diff --git a/wrapper/pom.xml b/wrapper/pom.xml index b4b414829..4746605aa 100644 --- a/wrapper/pom.xml +++ b/wrapper/pom.xml @@ -22,7 +22,7 @@ --> io.github.demchaav graph-compose - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT GraphCompose The graph-compose coordinate: a drop-in aggregator over graph-compose-core for a PDF-capable install. From ae565c1fb7a256ed5ac84fc87c786088fe661da9 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 17 Aug 2026 15:10:06 +0100 Subject: [PATCH 2/4] feat(templates): assemble a CV section at runtime, not at compile time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four CvSection records each fix one shape when the code is written, which is right for a CV written in Java: you pick the record and the compiler checks it. It is the wrong model when the CV arrives as data. A user who has just chosen "Volunteering, shaped like Education, with dates" cannot instantiate a different record per choice, so every shape anyone thought of would have to become a type β€” and the shapes nobody thought of stay impossible. ModuleSection carries the choice as a value instead. One CvItem holds every optional field β€” title, link, subtitle, period, location, description β€” and a CvKind decides which of them are read: the same item renders with or without its dates depending on the kind alone. BodyStyle decides whether a description reads as prose or as bullets. SectionRole states what a section means, separately from how it draws, which is the input the sidebar routing needs and cannot get from an English keyword list; no preset reads it yet. ModuleRenderer lowers each kind onto the renderers this package already ships rather than drawing anything itself, so a module and the hand-written section carrying the same content are two spellings of one document. ModuleSectionParityTest holds that node-for-node β€” layout snapshot for structure, extracted text for content β€” for every kind. Inline versus stacked bullets is the module's choice (BULLETS, BULLETS_STACKED) rather than something inferred from how long a description happens to be: a section reads one way throughout, and an author who asked for descriptions underneath gets them whether the first entry is one line or five. Three ways a section shape a preset did not recognise used to be lost are closed with it: BlueBanner and ClassicSerif threw from a private copy of the dispatcher, EditorialBlue's had no final branch, and SectionLookup.hasContent answered false for any subtype it had not been taught β€” discarding the heading with the body, through the very fallback that exists to place unclaimed sections. An entry with no date also stops reserving an empty column for one, which its Javadoc has described since it was written; no shipped fixture has a blank date, so no existing render moves. --- CHANGELOG.md | 47 +++ docs/templates/v2-layered/using-templates.md | 63 +++- .../ModuleSectionKindCoverageTest.java | 302 +++++++++++++++ .../components/ModuleSectionParityTest.java | 349 ++++++++++++++++++ .../cv/components/EntryRenderer.java | 55 +-- .../cv/components/ModuleRenderer.java | 208 +++++++++++ .../cv/components/SectionDispatcher.java | 5 + .../cv/components/SectionLookup.java | 14 +- .../document/templates/cv/data/BodyStyle.java | 28 ++ .../document/templates/cv/data/CvItem.java | 189 ++++++++++ .../document/templates/cv/data/CvKind.java | 103 ++++++ .../document/templates/cv/data/CvSection.java | 10 +- .../templates/cv/data/ModuleSection.java | 178 +++++++++ .../templates/cv/data/SectionRole.java | 54 +++ .../templates/cv/data/package-info.java | 25 ++ .../templates/cv/presets/BlueBanner.java | 9 +- .../templates/cv/presets/ClassicSerif.java | 8 +- .../templates/cv/presets/EditorialBlue.java | 7 + .../templates/cv/data/ModuleSectionTest.java | 166 +++++++++ 19 files changed, 1786 insertions(+), 34 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionParityTest.java create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/data/BodyStyle.java create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvKind.java create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/data/SectionRole.java create mode 100644 templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a7affac0..e26d298e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ follow semantic versioning; release dates are ISO 8601. ## v2.3.0 β€” Planned +### Public API + +- **A CV section whose shape is a value, for CVs assembled at runtime.** The four + section records each fix one shape at compile time, which is right when a CV is + written in Java β€” you pick the record, the compiler checks it. It is the wrong model + when the CV arrives as data: a user who has just chosen "Volunteering, shaped like + Education, with dates" cannot instantiate a different record per choice, so every + shape somebody thought of would have to become a type. + + `ModuleSection` carries the choice instead. One `CvItem` record holds every optional + field β€” title, link, subtitle, period, location, description lines β€” and a `CvKind` + (`PARAGRAPH`, `BULLETS`, `BULLETS_STACKED`, `INLINE_LIST`, `ENTRIES`, + `ENTRIES_DATED`) decides which of them are read: the same item renders with or without its dates depending on the kind + alone. `BodyStyle` decides whether a description reads as prose or as bullets, and + `SectionRole` states what a section *means* β€” the decision multi-column presets make + by matching headings against English keywords, which a CV headed `Ausbildung` or + `Навыки` never matches. The presets do not read the role yet; it travels with the + section now so a document built today needs no rewrite when the routing work lands. + + The existing four records are untouched and mix with modules in the same document. + A module renders through the existing components rather than beside them, so one + drawn as `ENTRIES_DATED` lays out exactly like the `EntriesSection` carrying the same + content β€” held node-for-node by a parity suite, for every kind, alongside the + extracted text so structure and content are both pinned. The addition is binary- + compatible (the japicmp gate covers this module); it is a fifth permit on a sealed + interface, so a downstream `switch` over `CvSection` that was exhaustive without a + `default` needs one. + +### Fixed + +- **A section shape a preset did not recognise was lost three different ways.** + `BlueBanner` and `ClassicSerif` each kept a private copy of the section dispatcher + whose final `else` threw `IllegalStateException`; `EditorialBlue`'s had no `else` at + all; and `SectionLookup.hasContent` β€” which presets consult *before* routing, and + which `SectionAllocation.remaining()` uses to decide what still needs a home β€” + answered `false` for any subtype it had not been taught, dropping the heading along + with the body. So a section type added to the model would have crashed two presets + and vanished from several more, including through the very fallback that exists to + catch unplaced sections. All three dispatchers now delegate unfamiliar shapes to the + canonical one, and `hasContent` answers for every permit. + +- **An entry with no date no longer reserves a column for it.** `EntryRenderer` always + emitted the two-column title/date header, so an undated entry β€” a certification, a + project β€” had its title wrapped early to leave room for nothing. Its Javadoc had + described the collapsing behaviour since the entry renderer was written. No shipped + fixture has a blank date, so no existing render moves. + ### Build - **The templates module is under the binary-compatibility gate, and the gate now diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index bcfaf31fd..461bb11ca 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -19,11 +19,12 @@ it sets up the conceptual model in 5 minutes. 1. [The pieces you assemble](#the-pieces-you-assemble) 2. [Identity β€” name, contact, optional links](#identity) 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) -7. [Rendering β€” pageSize, margins, output](#rendering) -8. [Common patterns](#common-patterns) +4. [Building sections at runtime β€” `ModuleSection`](#runtime-modules) +5. [Slots β€” main vs sidebar](#slots) +6. [Picking a preset](#picking-a-preset) +7. [Customising a theme](#customising-a-theme) +8. [Rendering β€” pageSize, margins, output](#rendering) +9. [Common patterns](#common-patterns) --- @@ -161,6 +162,58 @@ paragraph beneath. --- + +## Building sections at runtime β€” `ModuleSection` + +The four types above are the right choice when you write a CV in Java: +you pick the record and the compiler checks it. They are the wrong one +when the CV is assembled from data β€” a form, a JSON payload, an LLM β€” +because the shape is not known until it arrives, and a user who picks +"dated entries" from a menu cannot instantiate a different record per +choice. + +`ModuleSection` moves that choice into a value. One item record carries +every optional field, and a `CvKind` decides which of them are read: + +```java +ModuleSection.builder("Volunteering", SectionRole.OTHER, CvKind.ENTRIES_DATED) + .item(CvItem.of("Mentor, Rails Girls") + .at("Rails Girls Berlin") // subtitle + .in("Berlin, DE") // location + .period("2019 - 2021") // read by dated kinds only + .bullets("Ran three weekend workshops")) + .build(); +``` + +| `CvKind` | Shape | Reads | +|---|---|---| +| `PARAGRAPH` | prose under the section heading | `body` | +| `BULLETS` | a bullet per item, description inline | `title`, `body` (not `link`) | +| `BULLETS_STACKED` | a bullet per item, description underneath | `title`, `link`, `body` | +| `INLINE_LIST` | `Languages: Java 21, Kotlin` | `title`, `body` (not `link`) | +| `ENTRIES` | timeline, no date column | everything but `period` | +| `ENTRIES_DATED` | timeline with dates | everything | + +Only `title` is required on an item. Whatever a kind does not read is +ignored, so the same item renders with or without its dates depending +on the kind alone β€” which is what lets a "Volunteering" module be +shaped exactly like Education without a new type. + +`SectionRole` says what a section *means*, separately from how it +draws. Multi-column presets decide what belongs in a sidebar by +matching headings against English keywords, which a CV headed +`Ausbildung` or `Навыки` never matches. The role is where that +decision belongs β€” stated by the author, who knows the answer. **The +presets do not read it yet**: today it travels with the section and is +the input the routing work will consume, so a document built now needs +no rewrite when they do. + +Modules and the four fixed types mix freely in one document, and both +render through the same components β€” a module drawn as `ENTRIES_DATED` +lays out exactly like the `EntriesSection` carrying the same content. + +--- + ## Slots β€” main vs sidebar diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java new file mode 100644 index 000000000..a362537da --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java @@ -0,0 +1,302 @@ +package com.demcha.compose.document.templates.cv.components; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.presets.BlueBanner; +import com.demcha.compose.document.templates.cv.presets.BoxedSections; +import com.demcha.compose.document.templates.cv.presets.CenteredHeadline; +import com.demcha.compose.document.templates.cv.presets.ClassicSerif; +import com.demcha.compose.document.templates.cv.presets.CompactMono; +import com.demcha.compose.document.templates.cv.presets.EditorialBlue; +import com.demcha.compose.document.templates.cv.presets.EngineeringResume; +import com.demcha.compose.document.templates.cv.presets.Executive; +import com.demcha.compose.document.templates.cv.presets.MinimalUnderlined; +import com.demcha.compose.document.templates.cv.presets.MintEditorial; +import com.demcha.compose.document.templates.cv.presets.ModernProfessional; +import com.demcha.compose.document.templates.cv.presets.MonogramSidebar; +import com.demcha.compose.document.templates.cv.presets.NordicClean; +import com.demcha.compose.document.templates.cv.presets.Panel; +import com.demcha.compose.document.templates.cv.presets.SidebarPortrait; +import com.demcha.compose.document.templates.cv.presets.TimelineMinimal; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Every {@link CvKind} reaches the page, on every preset that renders whatever + * the document hands it. + * + *

A runtime module is only as good as the weakest kind: an author who picks + * one the renderers never learned to lower gets a section that silently draws + * nothing, and the CV looks finished. Enumerating the enum rather than listing + * cases means a kind added later fails here until it is wired, which is the + * point β€” a new constant cannot ship half-rendered.

+ * + *

The ad-hoc cases matter as much as the catalogue ones: a module with + * {@link SectionRole#OTHER} and a heading in a script nobody's keyword list + * contains is exactly the CV this model exists for, and it must survive to the + * page under its own heading.

+ */ +class ModuleSectionKindCoverageTest { + + /** Presets that render every section the document carries, in order. */ + private static Stream> generalPresets() { + return Stream.of(ModernProfessional.create(), BoxedSections.create(), + MinimalUnderlined.create(), Executive.create(), + CenteredHeadline.create(), BlueBanner.create()); + } + + /** + * Presets that render whatever section they are handed β€” the six general + * loops plus the two that route by heading and then draw any shape. + */ + private static Stream> presetsThatRenderAnyShape() { + return Stream.concat(generalPresets(), + Stream.of(ClassicSerif.create(), EditorialBlue.create())); + } + + /** + * Presets whose module slots are guarded on the section's Java type β€” + * {@code if (!(section instanceof EntriesSection entries)) return;} and + * friends β€” so a module routed to one of those slots is skipped whatever + * its kind. Placing them is the routing work, not this change; what is + * pinned here is that they do not fail. + */ + private static Stream> presetsThatGuardOnSectionType() { + return Stream.of(CompactMono.create(), EngineeringResume.create(), + MintEditorial.create(), MonogramSidebar.create(), NordicClean.create(), + Panel.create(), SidebarPortrait.create(), TimelineMinimal.create()); + } + + /** Every shipped CV preset. */ + private static Stream> everyPreset() { + return Stream.concat(presetsThatRenderAnyShape(), presetsThatGuardOnSectionType()); + } + + @ParameterizedTest + @EnumSource(CvKind.class) + void everyKindPutsItsDescriptionsOnThePage(CvKind kind) throws Exception { + ModuleSection module = ModuleSection.builder("Selected Work", SectionRole.OTHER, kind) + .item(CvItem.of("First entry").at("Acme GmbH").in("Berlin") + .period("2021 - Present").paragraphs("Did the work.")) + .item(CvItem.of("Second entry").at("Northwind").period("2018 - 2021") + .bullets("Shipped it", "Measured it")) + .build(); + + String text = render(ModernProfessional.create(), module); + + assertThat(text) + .as("%s must render every description line β€” no kind may drop the body", kind) + .contains("Did the work.", "Shipped it", "Measured it"); + } + + @ParameterizedTest + @EnumSource(value = CvKind.class, names = "PARAGRAPH", mode = EnumSource.Mode.EXCLUDE) + void everyTitledKindPutsItsTitlesOnThePage(CvKind kind) throws Exception { + // PARAGRAPH is excluded on purpose, not overlooked: it renders prose under + // the section's own heading and documents that it reads the body alone β€” + // the case below pins that, so the two together cover the whole enum. + ModuleSection module = ModuleSection.builder("Selected Work", SectionRole.OTHER, kind) + .item(CvItem.of("First entry").paragraphs("Did the work.")) + .item(CvItem.of("Second entry").period("2018").paragraphs("Did more.")) + .build(); + + assertThat(render(ModernProfessional.create(), module)) + .as("%s reads the item title, so it must reach the page", kind) + .contains("First entry", "Second entry"); + } + + @Test + void proseRendersWithoutRepeatingTheHeading() throws Exception { + ModuleSection module = ModuleSection.summary("Profile", "Backend engineer."); + + String text = render(ModernProfessional.create(), module); + + assertThat(text).contains("Backend engineer."); + assertThat(text.split("Profile", -1)) + .as("the heading is the section's; PARAGRAPH must not print it a second time") + .hasSize(2); + } + + @ParameterizedTest + @MethodSource("generalPresets") + void everyGeneralPresetRendersAModule(DocumentTemplate preset) throws Exception { + ModuleSection module = ModuleSection.builder("Volunteering", SectionRole.OTHER, + CvKind.ENTRIES_DATED) + .item(CvItem.of("Mentor, Rails Girls").at("Rails Girls Berlin") + .period("2019 - 2021").bullets("Ran three weekend workshops")) + .build(); + + String text = render(preset, module); + + // Headings are the preset's to style β€” several letter-space them and + // upper-case them into "V O L U N T E E R I N G" β€” so the heading is + // matched without spacing or case. The content is matched verbatim. + assertThat(text.replace(" ", "")) + .as("%s must render an ad-hoc module under its own heading", preset.id()) + .containsIgnoringCase("Volunteering"); + assertThat(text) + .as("%s must render the module's items", preset.id()) + .contains("Mentor, Rails Girls", "Ran three weekend workshops"); + } + + @ParameterizedTest + @MethodSource("everyPreset") + void noPresetFailsOnAModule(DocumentTemplate preset) throws Exception { + // Weaker than the case above, and deliberately so: eight presets guard + // their module slots on the section's Java type, so a module routed there + // is skipped rather than drawn, and placing it is the routing work rather + // than this change. What no preset may do is throw β€” two of them did until + // this landed, each keeping a private copy of the dispatcher whose final + // else raised IllegalStateException, so the first CV built from a runtime + // module would have failed to render at all. + ModuleSection module = ModuleSection.builder("Volunteering", SectionRole.OTHER, + CvKind.ENTRIES_DATED) + .item(CvItem.of("Mentor, Rails Girls").period("2019 - 2021")) + .build(); + + assertThatCode(() -> render(preset, module)) + .as("%s must render a document containing a runtime module", preset.id()) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @MethodSource("presetsThatRenderAnyShape") + void aModuleUnderAHeadingThePresetKnowsIsRendered(DocumentTemplate preset) + throws Exception { + // Stronger than "does not throw", and the case that catches the failure + // no-throw cannot see: presets consult SectionLookup.hasContent before they + // route or render, and its default for an unrecognised subtype is false β€” + // which discarded the module's heading and body together, on presets whose + // rendering path for it was perfectly good. The heading here is one every + // preset's keyword list contains, so nothing but that gate can lose it. + ModuleSection module = ModuleSection.builder("Professional Experience", + SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) + .item(CvItem.of("Senior Backend Engineer").at("Acme GmbH") + .period("2021 - Present").paragraphs("Owned the settlement service.")) + .build(); + + assertThat(render(preset, module)) + .as("%s must render a module it routed by heading", preset.id()) + .contains("Senior Backend Engineer"); + } + + @Test + void aNonLatinHeadingReachesTheLayoutUnderItsOwnWords() throws Exception { + // Preset routing that matches English keywords against a heading has + // nothing to match here; the role carries the meaning instead and the + // heading stays the author's. Asserted against the composed layout rather + // than the PDF text layer on purpose: the CV themes draw with the + // standard-14 Helvetica, which has no Cyrillic glyphs, so the *rendered* + // page shows substitutes until the caller supplies a font that covers the + // script. What this pins is the half that is the model's to get right β€” + // the section is placed and carries its own text. + ModuleSection module = ModuleSection.builder("Навыки", SectionRole.SKILLS, + CvKind.INLINE_LIST) + .item(CvItem.of("Π―Π·Ρ‹ΠΊΠΈ").paragraphs("Java 21", "Kotlin")) + .build(); + + assertThat(composedText(ModernProfessional.create(), module)) + .contains("Навыки", "Π―Π·Ρ‹ΠΊΠΈ", "Java 21, Kotlin"); + } + + @Test + void aModuleAndTheOlderSectionTypesCoexistInOneDocument() throws Exception { + // The new permit is additive: a document may mix a runtime module with + // the hand-written records, which is what a migration looks like. + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(ModuleSection.summary("Profile", "Backend engineer.")) + .section(com.demcha.compose.document.templates.cv.data.EntriesSection + .builder("Education") + .entry("BSc Computer Science", "TU Berlin", "2014 - 2018", "") + .build()) + .section(ModuleSection.builder("Volunteering", SectionRole.OTHER, CvKind.BULLETS) + .item("Rails Girls mentor") + .build()) + .build(); + + assertThat(renderDocument(ModernProfessional.create(), doc)) + .contains("Backend engineer.", "BSc Computer Science", "Rails Girls mentor"); + } + + // -- helpers --------------------------------------------------------- + + private static String render(DocumentTemplate preset, ModuleSection module) + throws Exception { + return renderDocument(preset, CvDocument.builder() + .identity(identity()) + .sections(List.of(module)) + .build()); + } + + private static String renderDocument(DocumentTemplate preset, CvDocument doc) + throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(24, 24, 24, 24) + .create()) { + preset.compose(session, doc); + try (PDDocument pdf = Loader.loadPDF(session.toPdfBytes())) { + return new PDFTextStripper().getText(pdf).replaceAll("\\s+", " ").trim(); + } + } + } + + /** + * Every string the composed layout carries, joined β€” the text before a font + * gets a say in whether it can draw it. + */ + private static String composedText(DocumentTemplate preset, ModuleSection module) { + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(24, 24, 24, 24) + .create()) { + preset.compose(session, CvDocument.builder() + .identity(identity()).sections(List.of(module)).build()); + StringBuilder text = new StringBuilder(); + collectText(session.roots(), text); + return text.toString(); + } + } + + private static void collectText(List nodes, StringBuilder out) { + for (DocumentNode node : nodes) { + if (node instanceof ParagraphNode paragraph) { + // text() carries the plain string AND the concatenation of any + // rich runs, so it sees both a header written with .text(...) and + // a body assembled from markdown runs. + out.append(paragraph.text()).append(' '); + } + collectText(node.children(), out); + } + } + + private static CvIdentity identity() { + return CvIdentity.builder() + .name("Jordan", "Rivera") + .jobTitle("Backend Engineer") + .contact("+1 555 0100", "jordan@example.com", "Berlin, DE") + .build(); + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionParityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionParityTest.java new file mode 100644 index 000000000..eb82d249a --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionParityTest.java @@ -0,0 +1,349 @@ +package com.demcha.compose.document.templates.cv.components; + +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.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.CvSection; +import com.demcha.compose.document.templates.cv.data.EntriesSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.data.RowStyle; +import com.demcha.compose.document.templates.cv.data.RowsSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.presets.ModernProfessional; +import com.demcha.compose.testing.layout.LayoutSnapshotJson; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A {@code ModuleSection} chosen at runtime must lay out exactly like the + * section a Java author would have written by hand for the same content. + * + *

That equivalence is the whole basis of the runtime module: it renders + * through the existing components rather than beside them, so the two + * authoring routes are two spellings of one document. Left unchecked it is a + * claim in a Javadoc, and the failure it hides is silent β€” a module that + * merely looks close, on a preset nobody re-renders, in a CV nobody compares + * side by side.

+ * + *

Each case pins both halves of "the same": the layout snapshot, which + * carries node structure and bounds but not text, and the extracted PDF text, + * which carries the words but not their positions. Either alone passes + * documents the other would catch.

+ */ +class ModuleSectionParityTest { + + @Test + void datedEntriesMatchAHandWrittenEntriesSection() throws Exception { + CvSection handWritten = EntriesSection.builder("Professional Experience") + .entry("Senior Backend Engineer", "Acme GmbH", "2021 - Present", + "Cut p99 latency by 40%.") + .entry("Backend Engineer", "Northwind Systems", "2018 - 2021", + "Owned the billing service.") + .build(); + + CvSection module = ModuleSection.builder("Professional Experience", + SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) + .item(CvItem.of("Senior Backend Engineer").at("Acme GmbH") + .period("2021 - Present").paragraphs("Cut p99 latency by 40%.")) + .item(CvItem.of("Backend Engineer").at("Northwind Systems") + .period("2018 - 2021").paragraphs("Owned the billing service.")) + .build(); + + assertSameRender(handWritten, module); + } + + @Test + void anInlineListMatchesAHandWrittenPlainRowsSection() throws Exception { + CvSection handWritten = RowsSection.builder("Additional Information", RowStyle.PLAIN) + .row("Languages", "English (Fluent), German (B2)") + .row("Interests", "Chess, long-distance cycling") + .build(); + + CvSection module = ModuleSection.builder("Additional Information", + SectionRole.OTHER, CvKind.INLINE_LIST) + .item(CvItem.of("Languages").paragraphs("English (Fluent)", "German (B2)")) + .item(CvItem.of("Interests").paragraphs("Chess", "long-distance cycling")) + .build(); + + assertSameRender(handWritten, module); + } + + @Test + void oneLineBulletsMatchAHandWrittenBulletedRowsSection() throws Exception { + CvSection handWritten = RowsSection.builder("Highlights", RowStyle.BULLETED) + .row("Throughput", "Doubled it") + .row("Onboarding", "Cut to two days") + .build(); + + CvSection module = ModuleSection.builder("Highlights", SectionRole.OTHER, CvKind.BULLETS) + .item(CvItem.of("Throughput").paragraphs("Doubled it")) + .item(CvItem.of("Onboarding").paragraphs("Cut to two days")) + .build(); + + assertSameRender(handWritten, module); + } + + @Test + void stackedBulletsMatchAHandWrittenStackedRowsSection() throws Exception { + CvSection handWritten = RowsSection.builder("Projects", RowStyle.BULLETED_STACKED) + .row("GraphCompose (Java 21, PDFBox)", + "A declarative layout engine for programmatic documents.") + .row("Ledger (Kotlin)", "Double-entry bookkeeping for small studios.") + .build(); + + // paragraphs(), not bullets(): a stacked row indents its description under + // the title, which is what prose does. BodyStyle.BULLETS asks for a bullet + // on each description line instead β€” a different shape, pinned by the case + // below rather than smuggled into this comparison. + CvSection module = ModuleSection.builder("Projects", SectionRole.PROJECTS, + CvKind.BULLETS_STACKED) + .item(CvItem.of("GraphCompose (Java 21, PDFBox)") + .paragraphs("A declarative layout engine for programmatic documents.")) + .item(CvItem.of("Ledger (Kotlin)") + .paragraphs("Double-entry bookkeeping for small studios.")) + .build(); + + assertSameRender(handWritten, module); + } + + @Test + void aBulletedBodyNestsABulletUnderTheItemsOwn() throws Exception { + CvSection prose = ModuleSection.builder("Projects", SectionRole.PROJECTS, + CvKind.BULLETS_STACKED) + .item(CvItem.of("GraphCompose").paragraphs("Shipped it", "Measured it")) + .build(); + CvSection bulleted = ModuleSection.builder("Projects", SectionRole.PROJECTS, + CvKind.BULLETS_STACKED) + .item(CvItem.of("GraphCompose").bullets("Shipped it", "Measured it")) + .build(); + + assertThat(text(bulleted)) + .as("BodyStyle.BULLETS must reach the page as bullets, not as indented prose") + .isNotEqualTo(text(prose)) + .contains("β€’ Shipped it", "β€’ Measured it"); + } + + @Test + void proseMatchesAHandWrittenParagraphSection() throws Exception { + CvSection handWritten = new ParagraphSection("Professional Summary", + "Backend engineer with ten years on payment systems."); + + CvSection module = ModuleSection.summary("Professional Summary", + "Backend engineer with ten years on payment systems."); + + assertSameRender(handWritten, module); + } + + @Test + void undatedEntriesMatchAHandWrittenEntriesSectionWithBlankDates() throws Exception { + // The blank-date path is a change to EntryRenderer itself, so pin it the + // same way: an undated module and the hand-written section that has always + // been able to express one must produce the same layout. + CvSection handWritten = EntriesSection.builder("Certifications") + .entry("AWS Solutions Architect", "Amazon", "", "") + .entry("CKA", "Linux Foundation", "", "") + .build(); + + CvSection module = ModuleSection.builder("Certifications", SectionRole.OTHER, + CvKind.ENTRIES) + .item(CvItem.of("AWS Solutions Architect").at("Amazon")) + .item(CvItem.of("CKA").at("Linux Foundation")) + .build(); + + assertSameRender(handWritten, module); + } + + @Test + void anUndatedEntryDropsTheDateColumnRatherThanReservingIt() throws Exception { + // The kind's whole contract is that it ignores the period. Rendering an + // empty date column instead would still "ignore" it while narrowing every + // title on the page, so pin the shape, not just the absent text. + CvSection dated = ModuleSection.builder("Certifications", SectionRole.OTHER, + CvKind.ENTRIES_DATED) + .item(CvItem.of("AWS Solutions Architect").at("Amazon").period("2024")) + .build(); + CvSection undated = ModuleSection.builder("Certifications", SectionRole.OTHER, + CvKind.ENTRIES) + .item(CvItem.of("AWS Solutions Architect").at("Amazon").period("2024")) + .build(); + + assertThat(layoutJson(undated)) + .as("an undated entry must not lay out like a dated one") + .isNotEqualTo(layoutJson(dated)); + assertThat(text(undated)).contains("AWS Solutions Architect", "Amazon"); + assertThat(text(undated)) + .as("the period must not reach the page under CvKind.ENTRIES") + .doesNotContain("2024"); + assertThat(text(dated)).contains("2024"); + } + + @Test + void anItemLinkRendersAsAClickableTitle() throws Exception { + CvSection module = ModuleSection.builder("Projects", SectionRole.PROJECTS, + CvKind.BULLETS_STACKED) + .item(CvItem.of("GraphCompose").linkedTo("https://example.dev/gc") + .paragraphs("A layout engine.")) + .build(); + + assertThat(text(module)) + .as("the link URL is the target, not the visible text") + .contains("GraphCompose") + .doesNotContain("https://example.dev/gc"); + assertThat(text(module)) + .as("markdown markers are instructions, not content β€” none may reach the page") + .doesNotContain("*", "[", "]"); + assertThat(externalLinkTargets(module)).contains("https://example.dev/gc"); + } + + @Test + void aBracketedTitleNeverLeaksItsUrlAsVisibleText() throws Exception { + // The markdown link label admits no brackets, so wrapping this title would + // match nothing and print the whole construction. Losing the click target + // is the acceptable outcome here; printing the URL is not. + CvSection module = ModuleSection.builder("Projects", SectionRole.PROJECTS, + CvKind.BULLETS_STACKED) + .item(CvItem.of("Ledger [v2]").linkedTo("https://example.dev/ledger") + .paragraphs("Double-entry bookkeeping.")) + .build(); + + assertThat(text(module)) + .contains("Ledger [v2]", "Double-entry bookkeeping.") + .doesNotContain("https://example.dev/ledger"); + } + + @Test + void aTitleOnlyBulletHasNoColonPointingAtNothing() throws Exception { + CvSection module = ModuleSection.builder("Interests", SectionRole.OTHER, CvKind.BULLETS) + .item("Chess") + .item("Long-distance cycling") + .build(); + + assertThat(text(module)) + .contains("Chess", "Long-distance cycling") + .doesNotContain("Chess:", "cycling:"); + } + + @Test + void anInlineListWithNothingToListRendersItsLabelAlone() throws Exception { + CvSection module = ModuleSection.builder("Languages", SectionRole.LANGUAGES, + CvKind.INLINE_LIST) + .item("English") + .build(); + + assertThat(text(module)).contains("English").doesNotContain("English:"); + } + + @Test + void everyKindIgnoresExactlyTheFieldsItSaysItIgnores() throws Exception { + // The contract that makes one item record serve every module is that the + // kind decides what is read. Stated in CvKind's Javadoc and the docs table; + // pinned here, per kind, by rendering one item that carries everything. + CvItem everything = CvItem.of("Item title") + .at("SubtitleValue").in("LocationValue").period("PeriodValue") + .paragraphs("Body line."); + + assertThat(render(CvKind.PARAGRAPH, everything)) + .as("PARAGRAPH reads the body alone") + .contains("Body line.") + .doesNotContain("Item title", "SubtitleValue", "PeriodValue", "LocationValue"); + assertThat(render(CvKind.BULLETS, everything)) + .as("BULLETS reads title and body") + .contains("Item title", "Body line.") + .doesNotContain("SubtitleValue", "PeriodValue", "LocationValue"); + assertThat(render(CvKind.BULLETS_STACKED, everything)) + .as("BULLETS_STACKED reads title and body") + .contains("Item title", "Body line.") + .doesNotContain("SubtitleValue", "PeriodValue", "LocationValue"); + assertThat(render(CvKind.INLINE_LIST, everything)) + .as("INLINE_LIST reads title and body") + .contains("Item title", "Body line.") + .doesNotContain("SubtitleValue", "PeriodValue", "LocationValue"); + assertThat(render(CvKind.ENTRIES, everything)) + .as("ENTRIES reads everything but the period") + .contains("Item title", "SubtitleValue", "LocationValue", "Body line.") + .doesNotContain("PeriodValue"); + assertThat(render(CvKind.ENTRIES_DATED, everything)) + .as("ENTRIES_DATED reads every field") + .contains("Item title", "SubtitleValue", "LocationValue", "PeriodValue", + "Body line."); + } + + private static String render(CvKind kind, CvItem item) throws Exception { + return text(ModuleSection.of("Section", SectionRole.OTHER, kind, item)); + } + + // -- helpers --------------------------------------------------------- + + private static void assertSameRender(CvSection handWritten, CvSection module) throws Exception { + assertThat(layoutJson(module)) + .as("a runtime module must lay out node-for-node like the hand-written section") + .isEqualTo(layoutJson(handWritten)); + assertThat(text(module)) + .as("...and carry the same words: the snapshot above compares structure, not content") + .isEqualTo(text(handWritten)); + } + + private static String layoutJson(CvSection section) throws Exception { + try (DocumentSession session = newSession()) { + ModernProfessional.create().compose(session, docWith(section)); + return LayoutSnapshotJson.toJson(session.layoutSnapshot()); + } + } + + private static String text(CvSection section) throws Exception { + try (DocumentSession session = newSession()) { + ModernProfessional.create().compose(session, docWith(section)); + try (PDDocument pdf = Loader.loadPDF(session.toPdfBytes())) { + return new PDFTextStripper().getText(pdf).replaceAll("\\s+", " ").trim(); + } + } + } + + private static java.util.List externalLinkTargets(CvSection section) throws Exception { + try (DocumentSession session = newSession()) { + ModernProfessional.create().compose(session, docWith(section)); + try (PDDocument pdf = Loader.loadPDF(session.toPdfBytes())) { + java.util.List targets = new java.util.ArrayList<>(); + for (org.apache.pdfbox.pdmodel.PDPage page : pdf.getPages()) { + for (org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation annotation + : page.getAnnotations()) { + if (annotation instanceof org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink link + && link.getAction() + instanceof org.apache.pdfbox.pdmodel.interactive.action.PDActionURI uri) { + targets.add(uri.getURI()); + } + } + } + return targets; + } + } + } + + private static CvDocument docWith(CvSection section) { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jordan", "Rivera") + .jobTitle("Backend Engineer") + .contact("+1 555 0100", "jordan@example.com", "Berlin, DE") + .build()) + .section(section) + .build(); + } + + private static DocumentSession newSession() { + float margin = (float) ModernProfessional.RECOMMENDED_MARGIN; + return GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(margin, margin, margin, margin) + .create(); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java index 160302a03..f703bef09 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java @@ -44,28 +44,39 @@ public static void render(SectionBuilder section, CvEntry entry, BrandTheme them DocumentTextStyle subtitleStyle = theme.entrySubtitleStyle(); DocumentTextStyle bodyStyle = theme.bodyStyle(); - // -- title + date row ------------------------------------------- - // The two-column header is a row layout, not a paragraph, so it - // does not go through ParagraphPrimitive β€” its DSL shape is - // genuinely different. - section.addRow("CvV2EntryHeader", row -> row - .spacing(theme.spacing().entryHeaderRowSpacing()) - .weights(theme.spacing().entryTitleWeight(), - theme.spacing().entryDateWeight()) - .addSection("Title", titleColumn -> titleColumn - .padding(DocumentInsets.zero()) - .addParagraph(p -> p - .textStyle(titleStyle) - .align(TextAlign.LEFT) - .margin(DocumentInsets.zero()) - .rich(rich -> MarkdownInline.append(rich, entry.title(), titleStyle)))) - .addSection("Date", dateColumn -> dateColumn - .padding(DocumentInsets.zero()) - .addParagraph(p -> p - .text(entry.date()) - .textStyle(dateStyle) - .align(TextAlign.RIGHT) - .margin(DocumentInsets.zero())))); + // -- title (+ date) header -------------------------------------- + // With a date this is a two-column row, not a paragraph, so it does + // not go through ParagraphPrimitive β€” its DSL shape is genuinely + // different. Without one the row is dropped entirely rather than + // reserving an empty column: an undated entry β€” a certification, a + // project, anything a runtime module renders without dates β€” would + // otherwise have its title wrapped early to leave room for nothing. + if (entry.date().isBlank()) { + section.addParagraph(p -> p + .textStyle(titleStyle) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero()) + .rich(rich -> MarkdownInline.append(rich, entry.title(), titleStyle))); + } else { + section.addRow("CvV2EntryHeader", row -> row + .spacing(theme.spacing().entryHeaderRowSpacing()) + .weights(theme.spacing().entryTitleWeight(), + theme.spacing().entryDateWeight()) + .addSection("Title", titleColumn -> titleColumn + .padding(DocumentInsets.zero()) + .addParagraph(p -> p + .textStyle(titleStyle) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero()) + .rich(rich -> MarkdownInline.append(rich, entry.title(), titleStyle)))) + .addSection("Date", dateColumn -> dateColumn + .padding(DocumentInsets.zero()) + .addParagraph(p -> p + .text(entry.date()) + .textStyle(dateStyle) + .align(TextAlign.RIGHT) + .margin(DocumentInsets.zero())))); + } // -- italic subtitle -------------------------------------------- if (!entry.subtitle().isBlank()) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java new file mode 100644 index 000000000..9df3f1cae --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java @@ -0,0 +1,208 @@ +package com.demcha.compose.document.templates.cv.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.core.theme.BrandTheme; +import com.demcha.compose.document.templates.cv.data.BodyStyle; +import com.demcha.compose.document.templates.cv.data.CvEntry; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvRow; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.RowStyle; + +import java.util.List; + +/** + * Renders a {@link ModuleSection} by lowering it onto the renderers + * this package already ships. + * + *

Nothing here draws. Each {@code CvKind} is a rule for turning + * {@link CvItem}s into the inputs {@link ParagraphRenderer}, + * {@link RowRenderer} and {@link EntryRenderer} already take, which is + * what makes a runtime-assembled module and a hand-written + * {@code EntriesSection} carrying the same content lay out the same + * way β€” a property the parity suite checks node for node rather than + * by eye.

+ * + *

The lowering is also where a kind's documented indifference + * happens: {@code ENTRIES} builds its {@link CvEntry} with a blank + * date, so an item's {@code period} reaches no renderer at all. Every + * field a kind ignores is dropped here, in one place, rather than by + * each renderer deciding what to skip.

+ */ +public final class ModuleRenderer { + + private ModuleRenderer() { + } + + /** + * Renders every item of {@code module} into {@code host}. + * + * @param host host section receiving the body + * @param module the module supplying items, kind, and role + * @param theme the active theme supplying palette, typography, and spacing + */ + public static void render(SectionBuilder host, ModuleSection module, BrandTheme theme) { + List items = module.items(); + for (int i = 0; i < items.size(); i++) { + CvItem item = items.get(i); + switch (module.kind()) { + case PARAGRAPH -> paragraph(host, item, theme); + case BULLETS -> bullet(host, item, theme); + case BULLETS_STACKED -> stackedBullet(host, item, theme, i > 0); + case INLINE_LIST -> inlineList(host, item, theme); + case ENTRIES -> entry(host, item, "", theme, i > 0); + case ENTRIES_DATED -> entry(host, item, item.period(), theme, i > 0); + } + } + } + + /** + * Prose: one paragraph per body line, the title left out (see + * {@code CvKind.PARAGRAPH}). A bulleted body still bullets β€” the + * body style is the author's second choice, independent of kind. + */ + private static void paragraph(SectionBuilder host, CvItem item, BrandTheme theme) { + for (String line : item.body()) { + if (item.bodyStyle() == BodyStyle.BULLETS) { + bulletedLine(host, line, theme.bodyStyle(), theme); + } else { + ParagraphRenderer.render(host, line, theme); + } + } + } + + /** + * One line per item β€” bold label, the description collapsed into a + * comma-separated run after it. An item with nothing to list renders + * as its label alone: {@link RowStyle#PLAIN} would leave a colon + * pointing at nothing. + */ + private static void inlineList(SectionBuilder host, CvItem item, BrandTheme theme) { + // The title, not linkedTitle: this kind documents that it ignores the + // link, and RowRenderer bolds a label by wrapping it in markdown + // markers β€” which would nest around a link and print as literal + // asterisks. + if (item.body().isEmpty()) { + ParagraphPrimitive.writeBody(host, item.title(), theme.bodyBoldStyle(), theme); + return; + } + RowRenderer.render(host, new CvRow(item.title(), String.join(", ", item.body())), + RowStyle.PLAIN, theme); + } + + /** + * A bullet whose description shares its line + * ({@link RowStyle#BULLETED}). + * + *

The title goes in unlinked. This row bolds its label by wrapping + * it in markdown markers, which would nest around link markup and + * reach the page as literal asterisks; a module whose titles are + * links wants {@link CvKind#BULLETS_STACKED}, which bolds through the + * text style and leaves the link intact.

+ */ + private static void bullet(SectionBuilder host, CvItem item, BrandTheme theme) { + if (item.body().isEmpty()) { + // PLAIN/BULLETED end the label with a colon, which would point at + // nothing. A title-only entry is a plain bullet. + ParagraphPrimitive.writeBulleted(host, item.title(), theme.bodyBoldStyle(), + theme.decoration().bulletGlyph(), + DocumentInsets.top((float) theme.spacing().paragraphMarginTop()), theme); + return; + } + RowRenderer.render(host, new CvRow(item.title(), String.join(" ", item.body())), + RowStyle.BULLETED, theme); + } + + /** + * A bullet whose description is stacked underneath and indented to + * the title ({@link RowStyle#BULLETED_STACKED}). + */ + private static void stackedBullet(SectionBuilder host, CvItem item, BrandTheme theme, + boolean separate) { + // Stacked items are multi-line blocks, so they get the same gap the + // dispatcher puts between stacked rows β€” without it consecutive items + // read as one. + if (separate) { + host.spacer(0, theme.spacing().entrySeparation()); + } + RowRenderer.render(host, new CvRow(linkedTitle(item), ""), + RowStyle.BULLETED_STACKED, theme); + // A bulleted body nests a bullet under the item's own; prose is indented + // to the title instead of carrying a second glyph. + String glyph = item.bodyStyle() == BodyStyle.BULLETS + ? theme.decoration().stackedIndent() + theme.decoration().bulletGlyph() + : theme.decoration().stackedIndent(); + for (String line : item.body()) { + ParagraphPrimitive.writeBulleted(host, line, theme.bodyStyle(), + glyph, DocumentInsets.zero(), theme); + } + } + + /** + * A timeline entry. The header goes through {@link EntryRenderer} + * with an empty body so the title / date / subtitle zones are the + * ones every other entry uses; the description follows underneath + * in the style the item asked for. + */ + private static void entry(SectionBuilder host, CvItem item, String date, + BrandTheme theme, boolean separate) { + if (separate) { + host.spacer(0, theme.spacing().entrySeparation()); + } + EntryRenderer.render(host, + new CvEntry(linkedTitle(item), subtitleWithLocation(item), date, ""), theme); + for (String line : item.body()) { + if (item.bodyStyle() == BodyStyle.BULLETS) { + bulletedLine(host, line, theme.bodyStyle(), theme); + } else { + ParagraphPrimitive.writeBody(host, line, theme.bodyStyle(), theme); + } + } + } + + private static void bulletedLine(SectionBuilder host, String line, + DocumentTextStyle style, BrandTheme theme) { + ParagraphPrimitive.writeBulleted(host, line, style, + theme.decoration().bulletGlyph(), + DocumentInsets.top((float) theme.spacing().paragraphMarginTop()), theme); + } + + /** + * The title, wrapped in markdown link syntax when the item carries a + * link. Every renderer here already routes titles through the shared + * markdown helper, so this needs no separate link path. + * + *

A title containing a bracket is left alone. The markdown link + * pattern's label admits no brackets, so wrapping + * {@code "Ledger [v2]"} would match nothing and print the whole + * construction β€” URL included β€” as visible text. Either the title + * already carries its own {@code [text](url)}, which renders as the + * link it is, or it is prose with a bracket in it and reaches the + * page as written.

+ */ + private static String linkedTitle(CvItem item) { + if (item.link() == null + || item.title().indexOf('[') >= 0 + || item.title().indexOf(']') >= 0) { + return item.title(); + } + return "[" + item.title() + "](" + item.link().url() + ")"; + } + + /** + * The italic line under an entry title: subtitle and location joined + * when both are present, whichever exists when only one is, blank + * when neither β€” no separator left dangling. + */ + private static String subtitleWithLocation(CvItem item) { + if (item.subtitle().isBlank()) { + return item.location(); + } + if (item.location().isBlank()) { + return item.subtitle(); + } + return item.subtitle() + " Β· " + item.location(); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java index c2280c71a..10be3b78b 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java @@ -54,6 +54,11 @@ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme } RowRenderer.render(host, r.rows().get(i), r.style(), theme); } + } else if (section instanceof ModuleSection m) { + // Runtime-assembled module. The kind decides which of the + // renderers above each item lands on, so this branch draws + // nothing of its own β€” see ModuleRenderer. + ModuleRenderer.render(host, m, theme); } else if (section instanceof EntriesSection e) { // Timeline entries (Education, Experience) get a spacer // between items β€” each entry is a multi-line block diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java index c116eb34f..e5f3a96ce 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java @@ -50,9 +50,18 @@ public static CvSection firstMatching(List sections, * sealed {@code CvSection} hierarchy β€” {@code SectionLookupTest} pins the * contract.

* + *

The default is {@code false}, which makes an unlisted subtype + * invisible rather than merely unstyled: presets filter on this + * before they route or render, so a section this method does not + * recognise never reaches a dispatcher at all. Every {@code CvSection} + * permit therefore needs a case here β€” the branch below for + * {@code ModuleSection} exists because the fallback dropped the section + * heading and body together, on presets that had a perfectly good + * rendering path for it.

+ * * @param section the section to inspect; may be {@code null} * @return {@code true} if the section has non-empty body, entries, - * rows, or skill groups + * rows, skill groups, or module items */ public static boolean hasContent(CvSection section) { if (section instanceof ParagraphSection paragraph) { @@ -67,6 +76,9 @@ public static boolean hasContent(CvSection section) { if (section instanceof SkillsSection skills) { return skills.groups() != null && !skills.groups().isEmpty(); } + if (section instanceof ModuleSection module) { + return !module.items().isEmpty(); + } return false; } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/BodyStyle.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/BodyStyle.java new file mode 100644 index 000000000..8d927a479 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/BodyStyle.java @@ -0,0 +1,28 @@ +package com.demcha.compose.document.templates.cv.data; + +/** + * How one {@link CvItem}'s description lines render β€” the second, + * smaller axis next to {@link CvKind}. + * + *

The kind decides the item's shape (a bullet, a dated entry, a + * line in a list); this decides what happens to + * {@link CvItem#body()} inside it. The same experience entry can list + * its achievements as bullets or read as a paragraph without changing + * the module's kind, which is the distinction authors actually make + * when they say "this section is bulleted".

+ * + * @since 2.3.0 + */ +public enum BodyStyle { + + /** + * Each body line is a paragraph of prose. The default: an item + * built without a stated style reads as text. + */ + PARAGRAPH, + + /** + * Each body line carries a bullet glyph and a hanging indent. + */ + BULLETS +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java new file mode 100644 index 000000000..db0f1716a --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java @@ -0,0 +1,189 @@ +package com.demcha.compose.document.templates.cv.data; + +import com.demcha.compose.document.templates.core.identity.Link; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * One entry inside a {@link ModuleSection} β€” the universal record every + * runtime-assembled module is built from. + * + *

A job, a degree, a project, a skill category, a paragraph of a + * summary: all of them are a title plus some optional context plus a + * description. Rather than a record per shape, this carries every + * optional field and lets the section's {@link CvKind} decide which + * ones it reads β€” a {@code period} is drawn by + * {@link CvKind#ENTRIES_DATED} and ignored by {@link CvKind#ENTRIES}, + * with the same item on both sides. Each kind documents exactly what + * it reads.

+ * + *

Only {@code title} is required, and only because a module entry + * with nothing to name it has nothing to render. Everything else is + * blank, {@code null}, or empty when the author has nothing to say β€” + * no placeholder text, no {@code "β€”"} stand-ins.

+ * + *

Build one through {@link #of(String)} and the {@code with}-style + * methods, which read in the order the fields render:

+ * + *
{@code
+ * CvItem.of("Senior Backend Engineer")
+ *       .at("Acme GmbH")
+ *       .in("Berlin, DE")
+ *       .period("2021 - Present")
+ *       .bullets("Cut p99 latency 40%", "Led the payments migration");
+ * }
+ * + * @param title what the entry is called; required, non-blank. May + * carry inline markdown, including {@code [text](url)} + * @param link optional click target for the title; {@code null} + * when the title is not a link. A {@code link} and a + * markdown link inside {@code title} do the same job β€” + * prefer this one, which needs no escaping + * @param subtitle employer, institution, client; blank when absent + * @param period date or range as the author wants it written + * ({@code "2021 - Present"}, {@code "2019"}); blank + * when absent, and read only by dated kinds + * @param location city, country, or "Remote"; blank when absent + * @param body description lines; empty when the entry is a + * heading only. One line renders as one paragraph or + * one bullet, per {@code bodyStyle} + * @param bodyStyle whether {@code body} reads as prose or as bullets + * @since 2.3.0 + */ +public record CvItem(String title, Link link, String subtitle, String period, + String location, List body, BodyStyle bodyStyle) { + + /** + * Validates the required {@code title}, normalises every optional + * text field from {@code null} to blank, drops null or blank body + * lines, and defensively copies the body list. + */ + public CvItem { + Objects.requireNonNull(title, "title"); + if (title.isBlank()) { + throw new IllegalArgumentException("title must not be blank"); + } + title = title.trim(); + subtitle = subtitle == null ? "" : subtitle.trim(); + period = period == null ? "" : period.trim(); + location = location == null ? "" : location.trim(); + bodyStyle = bodyStyle == null ? BodyStyle.PARAGRAPH : bodyStyle; + + List cleaned = new ArrayList<>(body == null ? 0 : body.size()); + if (body != null) { + for (String line : body) { + if (line != null && !line.isBlank()) { + cleaned.add(line.trim()); + } + } + } + body = List.copyOf(cleaned); + } + + /** + * An item with nothing but its title. Chain the {@code with}-style + * methods below to add what the entry actually has. + * + * @param title what the entry is called; required, non-blank + * @return a new item carrying only {@code title} + */ + public static CvItem of(String title) { + return new CvItem(title, null, "", "", "", List.of(), BodyStyle.PARAGRAPH); + } + + /** + * Returns a copy whose title links to {@code url}. + * + * @param url click target; blank or null clears the link + * @return a copy carrying the link + */ + public CvItem linkedTo(String url) { + Link target = url == null || url.isBlank() ? null : Link.of(title, url); + return new CvItem(title, target, subtitle, period, location, body, bodyStyle); + } + + /** + * Returns a copy with the employer / institution / client line. + * + * @param value subtitle text; null or blank leaves the line out + * @return a copy carrying the subtitle + */ + public CvItem at(String value) { + return new CvItem(title, link, value, period, location, body, bodyStyle); + } + + /** + * Returns a copy with the location line. + * + * @param value city, country, or "Remote"; null or blank leaves it out + * @return a copy carrying the location + */ + public CvItem in(String value) { + return new CvItem(title, link, subtitle, period, value, body, bodyStyle); + } + + /** + * Returns a copy with the date or range, written as the author + * wants it. Read only by {@link CvKind#ENTRIES_DATED}. + * + * @param value date or range; null or blank leaves it out + * @return a copy carrying the period + */ + public CvItem period(String value) { + return new CvItem(title, link, subtitle, value, location, body, bodyStyle); + } + + /** + * Returns a copy whose description reads as prose, one paragraph + * per line. + * + * @param lines description lines; null or blank lines are dropped + * @return a copy carrying the description + */ + public CvItem paragraphs(String... lines) { + return withBody(lines, BodyStyle.PARAGRAPH); + } + + /** + * Returns a copy whose description reads as a bulleted list, one + * bullet per line. + * + * @param lines description lines; null or blank lines are dropped + * @return a copy carrying the description + */ + public CvItem bullets(String... lines) { + return withBody(lines, BodyStyle.BULLETS); + } + + /** + * Returns a copy with an explicit body list and style β€” the + * variant for callers holding a {@code List} they did not build + * literally (an import layer, a JSON mapper). + * + * @param lines description lines; null or blank lines are dropped + * @param style whether the lines read as prose or as bullets + * @return a copy carrying the description + */ + public CvItem body(List lines, BodyStyle style) { + return new CvItem(title, link, subtitle, period, location, lines, style); + } + + /** + * The click target for this item's title, if it has one. + * + * @return the link URL, or blank when the title is not a link + */ + public String url() { + return link == null ? "" : link.url(); + } + + private CvItem withBody(String[] lines, BodyStyle style) { + // Arrays.asList, not List.of: a null line is dropped by the canonical + // constructor, and List.of would throw before it ever got there. + List values = lines == null ? List.of() : Arrays.asList(lines); + return new CvItem(title, link, subtitle, period, location, values, style); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvKind.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvKind.java new file mode 100644 index 000000000..8cecabda0 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvKind.java @@ -0,0 +1,103 @@ +package com.demcha.compose.document.templates.cv.data; + +/** + * How a {@link ModuleSection}'s items are laid out β€” the presentation + * shape the author picks at runtime, independent of what the section + * means. + * + *

This is the axis that lets one {@link CvItem} record serve every + * module: the kind decides which of the item's optional fields are + * read and which are ignored. An item carrying a + * {@code period} rendered under {@link #ENTRIES} simply does not show + * a date column β€” the same data under {@link #ENTRIES_DATED} does. + * Each constant below names exactly what it reads, so "ignored" is a + * documented contract rather than a surprise.

+ * + *

Every kind lowers onto the renderers this package already ships + * (see {@code components.ModuleRenderer}); none of them draws + * anything a hand-built {@link RowsSection}, {@link EntriesSection} or + * {@link ParagraphSection} could not.

+ * + *

The orthogonal axes are {@link SectionRole} β€” what the section + * means, which is what a multi-column preset places on β€” and + * {@link BodyStyle}, which decides how one item's description lines + * render. Keeping them apart is what lets a "Volunteering" module be + * shaped exactly like Education without a new type.

+ * + * @since 2.3.0 + */ +public enum CvKind { + + /** + * Prose β€” a summary, an objective, a statement. Each item renders + * as its description, one paragraph per body line. + * + *

Reads {@code body} only. The {@code title} is ignored here on + * purpose: the section already carries a heading, and a prose block + * that repeated it would print the same words twice. For a labelled + * one-liner ({@code Languages: English, German}) reach for + * {@link #INLINE_LIST}, which is what that shape is.

+ */ + PARAGRAPH, + + /** + * A bullet per item, description on the same line β€” + * {@code β€’ Throughput: doubled it}. The shape of a short list where + * each entry is a label and a value ({@link RowStyle#BULLETED}). + * + *

Reads {@code title}, {@code link}, {@code body}. Ignores + * {@code subtitle}, {@code period}, {@code location}. A body of + * several lines is joined with spaces; if the lines are meant to + * stand apart, the module wants {@link #BULLETS_STACKED}.

+ */ + BULLETS, + + /** + * A bullet per item, description stacked underneath and indented to + * the title β€” the shape a Projects section takes when the + * description is a sentence rather than a value + * ({@link RowStyle#BULLETED_STACKED}). + * + *

Reads {@code title}, {@code link}, {@code body}. Ignores + * {@code subtitle}, {@code period}, {@code location}.

+ * + *

Inline or stacked is the module's choice, not something + * inferred from how long a description happens to be: the same + * section reads one way throughout, and an author who picked + * "bulleted list with descriptions underneath" gets it whether the + * first entry is one line or five.

+ */ + BULLETS_STACKED, + + /** + * One line per item, the description collapsed into a + * comma-separated run after a bold label β€” + * {@code Languages: Java 21, Kotlin, SQL}. The shape skills and + * languages take in a narrow column. + * + *

Reads {@code title} and {@code body}. Ignores {@code link}, + * {@code subtitle}, {@code period}, {@code location}.

+ */ + INLINE_LIST, + + /** + * Timeline entries without the date column: bold title, italic + * subtitle line, description beneath. + * + *

Reads {@code title}, {@code link}, {@code subtitle}, + * {@code location}, {@code body}. Ignores {@code period} β€” this + * is the kind to pick when the dates exist in the data but should + * not show.

+ */ + ENTRIES, + + /** + * Timeline entries with the date column right-aligned against the + * title β€” Education, Experience, and anything shaped like them. + * + *

Reads every field: {@code title}, {@code link}, + * {@code subtitle}, {@code period}, {@code location}, + * {@code body}.

+ */ + ENTRIES_DATED +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java index e3da6bbe7..c5e2d2660 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java @@ -17,13 +17,21 @@ * items with four fields (title, subtitle, date, body). *
  • {@link SkillsSection} β€” grouped skill categories where each * category owns an ordered list of skill labels.
  • + *
  • {@link ModuleSection} β€” a section whose shape is a value + * rather than a type: one {@link CvItem} record plus a + * {@link CvKind} chosen at runtime. The four above stay the + * natural choice for a CV written in Java, where the author + * picks the record and the compiler checks it; this one is for a + * CV assembled from data, where the shape is not known until it + * arrives. It renders through the same components, so both + * routes lay out the same content identically.
  • * * *

    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, SkillsSection { + permits ParagraphSection, RowsSection, EntriesSection, SkillsSection, ModuleSection { /** * Banner heading shown above this section's body. diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java new file mode 100644 index 000000000..331078a78 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java @@ -0,0 +1,178 @@ +package com.demcha.compose.document.templates.cv.data; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * A section assembled at runtime: a heading, what it means + * ({@link SectionRole}), how it draws ({@link CvKind}), and the + * {@link CvItem}s it holds. + * + *

    The other {@link CvSection} implementations each fix one shape at + * compile time β€” {@link ParagraphSection} is prose, {@link RowsSection} + * is rows, {@link EntriesSection} is a timeline. That is the right + * model for a CV written in Java, where the author picks the record and + * the compiler checks it. It is the wrong one for a CV assembled from + * data at runtime: a user who has just chosen "Volunteering, shaped + * like Education, with dates" cannot instantiate a different record per + * choice, and every new shape would mean a new type.

    + * + *

    So this record moves the choice into a value. One item type carries + * every optional field; the kind decides which are read and which are + * ignored; the role says where the section belongs without a preset + * having to recognise its heading. The result is that a module nobody + * anticipated needs no new code β€” only a different + * {@code (role, kind)} pair.

    + * + *

    It renders through the same components as everything else. Every + * kind lowers onto {@link ParagraphSection}-, {@link RowsSection}- or + * {@link EntriesSection}-shaped output, so a module drawn as + * {@link CvKind#ENTRIES_DATED} is laid out exactly like the + * {@code EntriesSection} carrying the same content β€” which the parity + * suite holds to, layout node for layout node.

    + * + *
    {@code
    + * ModuleSection.builder("Volunteering", SectionRole.OTHER, CvKind.ENTRIES_DATED)
    + *     .item(CvItem.of("Mentor, Rails Girls")
    + *                 .at("Rails Girls Berlin")
    + *                 .period("2019 - 2021")
    + *                 .bullets("Ran three weekend workshops"))
    + *     .build();
    + * }
    + * + * @param title non-blank banner heading, in the author's own words + * @param role what the section means; {@link SectionRole#OTHER} when + * the catalogue has no name for it + * @param kind how the items draw + * @param items ordered items; null entries are dropped + * @since 2.3.0 + */ +public record ModuleSection(String title, SectionRole role, CvKind kind, List items) + implements CvSection { + + /** + * Validates that every field is non-null and {@code title} is + * non-blank, drops null items, and defensively copies the list. + */ + public ModuleSection { + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(role, "role"); + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(items, "items"); + if (title.isBlank()) { + throw new IllegalArgumentException("title must not be blank"); + } + List cleaned = new ArrayList<>(items.size()); + for (CvItem item : items) { + if (item != null) { + cleaned.add(item); + } + } + items = List.copyOf(cleaned); + } + + /** + * Fluent builder seeded with the three choices that define the + * module. + * + * @param title non-blank banner heading + * @param role what the section means + * @param kind how its items draw + * @return new builder + */ + public static Builder builder(String title, SectionRole role, CvKind kind) { + return new Builder(title, role, kind); + } + + /** + * Module assembled from a fixed set of items. + * + * @param title non-blank banner heading + * @param role what the section means + * @param kind how its items draw + * @param items items in source order; null becomes empty + * @return a {@code ModuleSection} carrying the supplied items + */ + public static ModuleSection of(String title, SectionRole role, CvKind kind, CvItem... items) { + return new ModuleSection(title, role, kind, + items == null ? List.of() : Arrays.asList(items)); + } + + /** + * Prose module β€” the common case of a summary or objective, where + * the section is one block of text and naming a kind and a role + * adds nothing. + * + * @param title non-blank banner heading + * @param text the prose; each argument is its own paragraph + * @return a {@link CvKind#PARAGRAPH} module under + * {@link SectionRole#SUMMARY} + */ + public static ModuleSection summary(String title, String... text) { + // The item's title is the section's: PARAGRAPH reads only the body, so + // it names the item without reaching the page. + return of(title, SectionRole.SUMMARY, CvKind.PARAGRAPH, + CvItem.of(title).paragraphs(text)); + } + + /** + * Mutable builder. + */ + public static final class Builder { + private final String title; + private final SectionRole role; + private final CvKind kind; + private final List items = new ArrayList<>(); + + private Builder(String title, SectionRole role, CvKind kind) { + this.title = title; + this.role = role; + this.kind = kind; + } + + /** + * Appends one pre-built item. + * + * @param item the item to append (non-null) + * @return this builder for chaining + */ + public Builder item(CvItem item) { + this.items.add(Objects.requireNonNull(item, "item")); + return this; + } + + /** + * Appends a title-only item β€” the shape a bulleted list or an + * inline list of one-liners takes. + * + * @param title what the entry is called; required, non-blank + * @return this builder for chaining + */ + public Builder item(String title) { + return item(CvItem.of(title)); + } + + /** + * Appends a labelled item whose description is the given + * lines, read as prose. + * + * @param title what the entry is called; required, non-blank + * @param body description lines; null or blank lines dropped + * @return this builder for chaining + */ + public Builder item(String title, String... body) { + return item(CvItem.of(title).paragraphs(body)); + } + + /** + * Builds the immutable {@link ModuleSection}. + * + * @return the assembled section + */ + public ModuleSection build() { + return new ModuleSection(title, role, kind, items); + } + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/SectionRole.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/SectionRole.java new file mode 100644 index 000000000..de00b77ad --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/SectionRole.java @@ -0,0 +1,54 @@ +package com.demcha.compose.document.templates.cv.data; + +/** + * What a {@link ModuleSection} means, stated by the author + * instead of guessed from its heading. + * + *

    Multi-column presets have to decide what belongs in a sidebar, + * and until now they decided it by matching the section's title + * against a list of English keywords each preset kept privately. A CV + * whose headings read {@code "Ausbildung"} or {@code "Навыки"} matched + * nothing, and a heading nobody anticipated was placed by whatever the + * preset does with leftovers. The role carries that decision in the + * data, where the author already knows the answer.

    + * + *

    It is deliberately separate from {@link CvKind}: the role says + * what a section is, the kind says how it draws. A "Volunteering" + * module shaped exactly like Education is + * {@code role = OTHER, kind = ENTRIES_DATED} β€” a combination no single + * enum could express without one constant per pairing.

    + * + *

    {@link #OTHER} is the honest default and is never a second-class + * citizen: a preset that cannot place it by role falls back to the + * heading the author wrote, in document order.

    + * + * @since 2.3.0 + */ +public enum SectionRole { + + /** Profile, objective, professional summary β€” the opening prose. */ + SUMMARY, + + /** Employment history. */ + EXPERIENCE, + + /** Degrees, certifications, courses. */ + EDUCATION, + + /** Technical or professional skills, however they are grouped. */ + SKILLS, + + /** Personal or professional projects. */ + PROJECTS, + + /** Spoken languages and proficiency. */ + LANGUAGES, + + /** + * Anything else β€” awards, volunteering, publications, interests, + * references, a section this catalogue has no name for. Carries no + * placement hint, so presets fall back to the author's own + * heading. + */ + OTHER +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java index f5ff77798..54adaf5e0 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java @@ -40,8 +40,33 @@ * β€” 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. + *
  • {@link com.demcha.compose.document.templates.cv.data.ModuleSection} + * β€” the shape as a value: one + * {@link com.demcha.compose.document.templates.cv.data.CvItem} + * record carrying every optional field, plus a + * {@link com.demcha.compose.document.templates.cv.data.CvKind} + * that decides which of them are read. For CVs assembled at + * runtime, where the author picks "dated entries" or "bullets" + * from a menu and no compile-time type can be chosen per + * choice.
  • * * + *

    Which one to reach for

    + * + *

    Writing a CV in Java: the four fixed shapes. The compiler checks + * the record you picked, and a project is visibly a + * {@code RowsSection} rather than a section that happens to hold + * rows.

    + * + *

    Assembling one from data β€” a form, a JSON payload, an LLM: the + * module. The section's shape and meaning arrive as values + * ({@code CvKind}, {@code SectionRole}), so a heading nobody + * anticipated β€” "Volunteering", shaped like Education β€” needs no new + * type and no new branch. Both routes render through the same + * components, and the parity suite holds them to laying out the same + * content identically, so the choice is about how the CV is authored, + * not about what it can look like.

    + * *

    Placement

    * *

    Sections live inside a {@link com.demcha.compose.document.templates.cv.data.CvDocument} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java index 6f70fd3ab..1857b539d 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java @@ -161,8 +161,13 @@ private static void renderBody(SectionBuilder host, renderEntry(host, entry, theme); } } else { - throw new IllegalStateException( - "Unknown CvSection subtype: " + section.getClass().getName()); + // A shape this preset has no styled path for β€” today the runtime + // ModuleSection. Hand it to the canonical dispatcher rather than + // throwing: a section the author put in the document reaches the + // page, which matters more than matching this preset's flavour of + // entry. A preset that wants its own module styling overrides this + // branch, it does not lose the content by omission. + SectionDispatcher.renderBody(host, section, theme); } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java index e4dbab8da..f2671c2d0 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java @@ -242,9 +242,11 @@ private void renderDetailBody(SectionBuilder host, CvSection section) { new CvRow(group.category(), group.skillsInline())); } } else { - throw new IllegalStateException( - "Unknown CvSection subtype: " - + section.getClass().getName()); + // A shape this preset has no serif-styled path for β€” today the + // runtime ModuleSection. The canonical dispatcher renders it + // rather than the render failing on a section the author + // legitimately added. + SectionDispatcher.renderBody(host, section, theme); } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java index 42637967f..5fc39b203 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java @@ -146,6 +146,13 @@ private void renderSectionBody(SectionBuilder section, CvSection cvSection, renderEntries(section, entries); } else if (cvSection instanceof RowsSection rows) { renderRows(section, rows); + } else { + // A shape this preset has no editorial-styled path for β€” today + // the runtime ModuleSection. Without this branch the section + // would render as nothing at all: an empty heading over blank + // space, which reads as a finished CV that quietly lost a + // section. + SectionDispatcher.renderBody(section, cvSection, theme); } } diff --git a/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java new file mode 100644 index 000000000..ee4bae0d8 --- /dev/null +++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java @@ -0,0 +1,166 @@ +package com.demcha.compose.document.templates.cv.data; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The runtime-assembled section and its item record: what they require, + * what they normalise, and what they refuse. + */ +class ModuleSectionTest { + + @Test + void anItemNeedsOnlyATitle() { + CvItem item = CvItem.of("Mentor, Rails Girls"); + + assertThat(item.title()).isEqualTo("Mentor, Rails Girls"); + assertThat(item.link()).isNull(); + assertThat(item.subtitle()).isEmpty(); + assertThat(item.period()).isEmpty(); + assertThat(item.location()).isEmpty(); + assertThat(item.body()).isEmpty(); + assertThat(item.bodyStyle()).isEqualTo(BodyStyle.PARAGRAPH); + assertThat(item.url()).isEmpty(); + } + + @Test + void anItemWithoutATitleIsRejected() { + assertThatThrownBy(() -> CvItem.of(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("title"); + assertThatThrownBy(() -> CvItem.of(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("title"); + } + + @Test + void theOptionalFieldsNormaliseNullToBlank() { + // An import layer that has no value for a field passes null rather than + // inventing a placeholder; every renderer downstream tests isBlank(). + CvItem item = new CvItem("Title", null, null, null, null, null, null); + + assertThat(item.subtitle()).isEmpty(); + assertThat(item.period()).isEmpty(); + assertThat(item.location()).isEmpty(); + assertThat(item.body()).isEmpty(); + assertThat(item.bodyStyle()).isEqualTo(BodyStyle.PARAGRAPH); + } + + @Test + void blankAndNullBodyLinesAreDropped() { + CvItem item = CvItem.of("Role").bullets("Shipped it", " ", null, "Measured it"); + + assertThat(item.body()).containsExactly("Shipped it", "Measured it"); + assertThat(item.bodyStyle()).isEqualTo(BodyStyle.BULLETS); + } + + @Test + void theBodyListIsCopiedAndUnmodifiable() { + List source = new ArrayList<>(List.of("first")); + CvItem item = CvItem.of("Role").body(source, BodyStyle.PARAGRAPH); + source.add("added after the fact"); + + assertThat(item.body()).containsExactly("first"); + assertThat(item.body().getClass().getName()).doesNotContain("ArrayList"); + } + + @Test + void theWithStyleMethodsReadInRenderOrder() { + CvItem item = CvItem.of("Senior Backend Engineer") + .linkedTo("https://acme.example") + .at("Acme GmbH") + .in("Berlin, DE") + .period("2021 - Present") + .bullets("Cut p99 latency 40%"); + + assertThat(item.title()).isEqualTo("Senior Backend Engineer"); + assertThat(item.url()).isEqualTo("https://acme.example"); + assertThat(item.link().label()).isEqualTo("Senior Backend Engineer"); + assertThat(item.subtitle()).isEqualTo("Acme GmbH"); + assertThat(item.location()).isEqualTo("Berlin, DE"); + assertThat(item.period()).isEqualTo("2021 - Present"); + assertThat(item.body()).containsExactly("Cut p99 latency 40%"); + } + + @Test + void aBlankLinkTargetLeavesTheTitlePlain() { + assertThat(CvItem.of("Role").linkedTo("").link()).isNull(); + assertThat(CvItem.of("Role").linkedTo(null).link()).isNull(); + } + + @Test + void aModuleCarriesItsRoleAndKind() { + ModuleSection module = ModuleSection.builder("Volunteering", + SectionRole.OTHER, CvKind.ENTRIES_DATED) + .item(CvItem.of("Mentor").period("2019")) + .build(); + + assertThat(module.title()).isEqualTo("Volunteering"); + assertThat(module.role()).isEqualTo(SectionRole.OTHER); + assertThat(module.kind()).isEqualTo(CvKind.ENTRIES_DATED); + assertThat(module.items()).hasSize(1); + assertThat(module).isInstanceOf(CvSection.class); + } + + @Test + void aModuleWithoutATitleIsRejected() { + assertThatThrownBy(() -> ModuleSection.of(" ", SectionRole.OTHER, CvKind.BULLETS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("title"); + } + + @Test + void aModuleRejectsANullRoleOrKind() { + assertThatThrownBy(() -> ModuleSection.of("Skills", null, CvKind.BULLETS)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("role"); + assertThatThrownBy(() -> ModuleSection.of("Skills", SectionRole.SKILLS, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("kind"); + } + + @Test + void nullItemsAreDroppedAndTheListIsCopied() { + List source = new ArrayList<>( + Arrays.asList(CvItem.of("kept"), null, CvItem.of("also kept"))); + ModuleSection module = new ModuleSection("Interests", SectionRole.OTHER, + CvKind.BULLETS, source); + source.clear(); + + assertThat(module.items()).extracting(CvItem::title) + .containsExactly("kept", "also kept"); + } + + @Test + void aSummaryModuleIsProseUnderTheSummaryRole() { + ModuleSection module = ModuleSection.summary("Professional Summary", + "Backend engineer.", "Ten years of it."); + + assertThat(module.role()).isEqualTo(SectionRole.SUMMARY); + assertThat(module.kind()).isEqualTo(CvKind.PARAGRAPH); + assertThat(module.items()).singleElement() + .extracting(CvItem::body, org.assertj.core.api.InstanceOfAssertFactories.LIST) + .containsExactly("Backend engineer.", "Ten years of it."); + } + + @Test + void theBuilderShorthandsBuildTheSameItems() { + ModuleSection module = ModuleSection.builder("Interests", SectionRole.OTHER, + CvKind.BULLETS) + .item("Chess") + .item("Cycling", "Long-distance, mostly.") + .build(); + + assertThat(module.items()).extracting(CvItem::title) + .containsExactly("Chess", "Cycling"); + assertThat(module.items().get(0).body()).isEmpty(); + assertThat(module.items().get(1).body()).containsExactly("Long-distance, mostly."); + assertThat(module.items().get(1).bodyStyle()).isEqualTo(BodyStyle.PARAGRAPH); + } +} From 83527593d9a2b9a6a494354ceb25ffc89fcef5e4 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 17 Aug 2026 15:17:59 +0100 Subject: [PATCH 3/4] test(templates): assert the module's item list is handed out unmodifiable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list copy was pinned from one side only β€” a caller's list cannot reach into a built module. The other side, that the accessor hands out nothing a caller can mutate, was left to List.copyOf's reputation. --- .../compose/document/templates/cv/data/ModuleSectionTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java index ee4bae0d8..9930c6bce 100644 --- a/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java +++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java @@ -135,6 +135,10 @@ void nullItemsAreDroppedAndTheListIsCopied() { assertThat(module.items()).extracting(CvItem::title) .containsExactly("kept", "also kept"); + // Both halves of "copied": the caller's list cannot reach in (above), and + // the accessor hands out nothing a caller could reach in through. + assertThatThrownBy(() -> module.items().add(CvItem.of("smuggled"))) + .isInstanceOf(UnsupportedOperationException.class); } @Test From 13296454787366d46af9e3c9ff0ed76394fcdbab Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 17 Aug 2026 16:07:53 +0100 Subject: [PATCH 4/4] feat(templates): declare which presets can be handed a runtime module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module is only worth building if the template renders it, and not every preset can promise that. Several compose a fixed set of modules and find each by matching headings, so a section they do not recognise never reaches a renderer: the CV comes out, minus a section, looking finished. Nothing about that failure is visible at the point it happens. ModularCvTemplate is the promise and CvTemplates.modular() is the list a CV builder should offer; CvTemplates also answers byId, all, ids, and recommendedMargin, so picking a preset at runtime stops being a map kept by hand in every consumer. Declaring the interface costs something: ModularCvTemplateFidelityTest renders every kind, a section this catalogue has no name for, a heading in a script no keyword list contains, and a heading that does match one, through each template that declares it. Seven presets qualify. ClassicSerif does not β€” it draws any shape it is given but only gives itself the sections it recognises, and finding that out is what the gate is for. The promise covers Slot.MAIN and says so, rather than leaving "renders whatever it is handed" to be read generously: every shipped preset composes one main column, so a sidebar section is dropped by these templates as by every other. The gate pins that too, so the contract and the code have to change together. CvRenderKit is the three shapes a section body reduces to β€” a paragraph, a label/value row, a timeline entry β€” and a template hands back the kit it draws them with. The lowering from CvItem stays shared: what a linked title looks like, which fields a kind reads, what an empty description does to a trailing colon are the model's decisions and must not be re-made sixteen times. BlueBanner, ClassicSerif and EditorialBlue now draw modules with their own entry and project shapes. EditorialBlue also stops renaming a module's heading. Its keyword vocabulary turned "Certifications & Awards" into EDUCATION, which is the one thing the promise says cannot happen; the canonical sections keep the rename that gives the preset its voice. CvTemplatesCoverageTest derives the catalogue from the presets package rather than trusting it, so a preset that ships unregistered fails the build instead of being invisible to every caller that looks one up by id. --- CHANGELOG.md | 34 +++ docs/templates/v2-layered/using-templates.md | 35 +++ .../cv/api/ModularCvTemplateFidelityTest.java | 265 ++++++++++++++++++ .../ModuleSectionKindCoverageTest.java | 77 +---- .../templates/cv/api/ModularCvTemplate.java | 59 ++++ .../templates/cv/components/CvRenderKit.java | 94 +++++++ .../cv/components/ModuleRenderer.java | 58 ++-- .../cv/components/SectionDispatcher.java | 28 +- .../templates/cv/presets/BlueBanner.java | 35 ++- .../templates/cv/presets/BoxedSections.java | 13 +- .../cv/presets/CenteredHeadline.java | 13 +- .../templates/cv/presets/ClassicSerif.java | 37 ++- .../templates/cv/presets/CvTemplates.java | 167 +++++++++++ .../templates/cv/presets/EditorialBlue.java | 59 +++- .../templates/cv/presets/Executive.java | 13 +- .../cv/presets/MinimalUnderlined.java | 13 +- .../cv/presets/ModernProfessional.java | 13 +- .../cv/presets/CvTemplatesCoverageTest.java | 149 ++++++++++ 18 files changed, 1056 insertions(+), 106 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java create mode 100644 templates/src/test/java/com/demcha/compose/document/templates/cv/presets/CvTemplatesCoverageTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index e26d298e3..b13eed6de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,40 @@ follow semantic versioning; release dates are ISO 8601. interface, so a downstream `switch` over `CvSection` that was exhaustive without a `default` needs one. +- **Which presets can be handed a runtime module, declared rather than assumed.** A module + is only useful if the template renders it, and not every preset can promise that: + several compose a fixed set of modules and find each by matching headings, so a + section they do not recognise never reaches a renderer. The CV still comes out β€” + minus a section, looking finished β€” which is the kind of failure nobody reports. + + `ModularCvTemplate` is the promise, and `CvTemplates.modular()` is the list a CV + builder should offer; `CvTemplates` also answers `byId`, `all`, `ids`, and + `recommendedMargin`, so picking a preset at runtime stops being a hand-kept map in + every consumer. Declaring the interface is not free: `ModularCvTemplateFidelityTest` + renders a document carrying every kind, an invented heading, a heading in a + script no keyword list contains, and a heading that *does* match one, through each + template that declares it, and asserts every item reached the page under the words + the author wrote β€” the last case because `EditorialBlue` renamed any heading + matching "certification" to EDUCATION, so "Certifications & Awards" arrived as a + word nobody had written. The promise covers `Slot.MAIN`, and says so: every shipped + preset composes a single main column, so a sidebar section is dropped by these + templates as by every other. Seven presets qualify today. `ClassicSerif` does not, + and finding that out is what the gate is for β€” it draws any shape it is given, but + only gives itself the sections it recognises. + + `CvTemplatesCoverageTest` derives the catalogue from the presets package rather than + trusting it, so a preset that ships without being registered fails the build instead + of being invisible to every caller that looks a template up by id. + +- **A preset can draw runtime modules in its own style.** `CvRenderKit` is the three + shapes a section body reduces to β€” a paragraph, a label/value row, a timeline entry β€” + and a template hands back the kit it draws them with. The lowering from `CvItem` + stays shared, because deciding what a linked title looks like or which fields a kind + reads belongs to the model and must not be re-decided per preset; only the drawing is + the preset's. `BlueBanner`, `ClassicSerif`, and `EditorialBlue` now render modules + with their own entry and project shapes rather than the canonical ones β€” the + limitation the entry above left open. + ### Fixed - **A section shape a preset did not recognise was lost three different ways.** diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index 461bb11ca..c997f6c3b 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -212,6 +212,41 @@ Modules and the four fixed types mix freely in one document, and both render through the same components β€” a module drawn as `ENTRIES_DATED` lays out exactly like the `EntriesSection` carrying the same content. +### Which preset can you hand a runtime module to? + +Not every preset. Several compose a fixed set of modules and find each by +matching headings, so a section they do not recognise never reaches a +renderer; the CV still comes out, minus a section, looking finished. The +ones that render whatever they are handed say so in the type system: + +```java +List safe = CvTemplates.modular(); // offer these + +CvTemplates.byId("modern-professional") // or look one up + .orElseThrow() + .compose(session, doc); +``` + +`CvTemplates` also answers `all()`, `ids()`, and `recommendedMargin(id)` β€” +the margin a preset was designed at, which you need while building the +session, before you have a template. + +Declaring `ModularCvTemplate` is not free: a fidelity suite renders a +document carrying every kind, an invented heading, a heading in a script no +keyword list contains, and a heading that *does* match one, through each +template that declares it, and asserts every item reached the page under the +author's own words. + +The promise covers `Slot.MAIN`, which is where sections go unless you say +otherwise. Every shipped preset composes a single main column, so a section +placed in `Slot.SIDEBAR` is dropped β€” by these templates as by every other. + +A template also says *how* it draws through `CvRenderKit`. The shared +lowering turns a module into paragraphs, rows, and entries; the kit draws +them, so a preset with its own entry style renders your runtime module in +that style rather than the canonical one. Presets whose bodies already use +the shared components return `CvRenderKit.defaults()`. + --- diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java new file mode 100644 index 000000000..291e40360 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java @@ -0,0 +1,265 @@ +package com.demcha.compose.document.templates.cv.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.data.Slot; +import com.demcha.compose.document.templates.cv.presets.CvTemplates; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Every template that declares {@link ModularCvTemplate} renders every kind + * of module, under whatever heading the author wrote. + * + *

    The interface is a promise made to a caller who cannot check it: a CV + * builder offers the modular templates and trusts that whatever the user + * assembled comes out the other side. A template that quietly dropped a + * section would produce a CV that still looks finished β€” the failure has no + * symptom at the point it happens, only a missing job three weeks later. + * This is where the promise is checked, so wearing the interface costs + * something.

    + * + *

    It enumerates {@link CvTemplates#modular()} rather than a list of its + * own, and every {@link CvKind} rather than the kinds in use, so a template + * or a kind added later is covered the day it lands β€” the coverage cannot + * be forgotten, only made to pass.

    + * + *

    Text is read from the composed layout, not the PDF text layer: the CV + * themes draw with the standard-14 Helvetica, whose encoding has no + * Cyrillic, and the non-Latin heading below is the case that matters most. + * What the model owes is that the section is placed carrying its own words; + * which glyphs a font can draw is the caller's font choice.

    + */ +class ModularCvTemplateFidelityTest { + + private static Stream> modularTemplates() { + return CvTemplates.modular().stream() + .map(template -> Named.of(template.id(), template)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("modularTemplates") + void everyModularTemplateRendersEveryKind(ModularCvTemplate template) { + String text = composedText(template, everyKindDocument()); + + for (CvKind kind : CvKind.values()) { + // Case-insensitively: a preset that upper-cases its entry titles is + // styling them, not losing them. + assertThat(text) + .as("%s must render the %s module's item", template.id(), kind) + .containsIgnoringCase(itemTitle(kind)); + assertThat(text) + .as("%s must render the %s module's description", template.id(), kind) + .contains(bodyLine(kind)); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("modularTemplates") + void everyModularTemplateRendersAnAdHocSection(ModularCvTemplate template) { + // No keyword list contains "Volunteering", and no preset was written + // with it in mind. A section the author invented is the whole point of + // assembling a CV at runtime, so it is the minimum this promise means. + CvDocument doc = document(ModuleSection.builder("Volunteering", SectionRole.OTHER, + CvKind.ENTRIES_DATED) + .item(CvItem.of("Mentor, Rails Girls").at("Rails Girls Berlin") + .period("2019 - 2021").bullets("Ran three weekend workshops")) + .build()); + + String text = composedText(template, doc); + + assertThatHeading(text, "Volunteering", template); + assertThat(text) + .as("%s must render the invented section's entry", template.id()) + .containsIgnoringCase("Mentor, Rails Girls"); + assertThat(text) + .as("%s must render the invented section's description", template.id()) + .contains("Ran three weekend workshops"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("modularTemplates") + void everyModularTemplateRendersANonLatinHeading(ModularCvTemplate template) { + CvDocument doc = document(ModuleSection.builder("Навыки", SectionRole.SKILLS, + CvKind.INLINE_LIST) + .item(CvItem.of("Π―Π·Ρ‹ΠΊΠΈ").paragraphs("Java 21", "Kotlin")) + .build()); + + String text = composedText(template, doc); + + assertThatHeading(text, "Навыки", template); + assertThat(text) + .as("%s must render the section's items", template.id()) + .contains("Π―Π·Ρ‹ΠΊΠΈ", "Java 21, Kotlin"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("modularTemplates") + void aHeadingThePresetHasAWordForIsStillTheAuthorsHeading(ModularCvTemplate template) { + // The ad-hoc cases above use headings no keyword list contains, which is + // the easy half. A preset with an editorial vocabulary of its own is the + // one that rewrites: one of these renamed any heading matching + // "certification" to EDUCATION, so "Certifications & Awards" reached the + // page as a word the author never wrote and the awards lost their title. + CvDocument doc = document(ModuleSection.builder("Certifications & Awards", + SectionRole.OTHER, CvKind.BULLETS) + .item(CvItem.of("AWS Solutions Architect").paragraphs("2024")) + .build()); + + String text = composedText(template, doc); + + assertThatHeading(text, "Certifications & Awards", template); + assertThat(text) + .as("%s must render the section's item", template.id()) + .containsIgnoringCase("AWS Solutions Architect"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("modularTemplates") + void aSidebarSectionIsNotRenderedAndTheContractSaysSo(ModularCvTemplate template) { + // Pinning a limitation, not a feature. Every shipped preset composes one + // main column and reads Slot.MAIN, so a section placed in the sidebar is + // dropped β€” which ModularCvTemplate's contract states rather than leaving + // "renders whatever it is handed" to be read generously. When slots go + // live this test goes red, which is the point: the promise and the code + // change together. + CvDocument doc = CvDocument.builder() + .identity(identity()) + .section(Slot.SIDEBAR, ModuleSection.builder("Languages", + SectionRole.LANGUAGES, CvKind.INLINE_LIST) + .item(CvItem.of("Spoken").paragraphs("English", "German")) + .build()) + .build(); + + assertThat(composedText(template, doc)) + .as("%s reads Slot.MAIN, as its contract says", template.id()) + .doesNotContain("Spoken"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("modularTemplates") + void everyModularTemplateDeclaresAKit(ModularCvTemplate template) { + assertThat(template.kit()) + .as("%s must hand back a kit β€” the drawing half of the promise", template.id()) + .isNotNull(); + } + + @Test + void theModularListIsASubsetOfTheCatalogueAndNotEmpty() { + List modularIds = CvTemplates.modular().stream() + .map(DocumentTemplate::id).toList(); + + assertThat(modularIds) + .as("a promise nobody makes is a promise nobody keeps") + .isNotEmpty(); + assertThat(CvTemplates.ids()).containsAll(modularIds); + assertThat(modularIds).doesNotHaveDuplicates(); + } + + /** + * Asserts the heading reached the page, ignoring how the preset styled + * it: several letter-space and upper-case their headings, so + * "Volunteering" arrives as "V O L U N T E E R I N G". The words are the + * template's to keep; the typography is the template's to choose. + */ + private static void assertThatHeading(String text, String heading, + ModularCvTemplate template) { + assertThat(text.replace(" ", "")) + .as("%s must render the section under its own heading (%s)", + template.id(), heading) + .containsIgnoringCase(heading.replace(" ", "")); + } + + // -- fixtures -------------------------------------------------------- + + /** + * One module per kind, each carrying an item whose title and description + * name the kind β€” so a failure says which kind was dropped rather than + * that something was missing. + */ + private static CvDocument everyKindDocument() { + CvDocument.Builder builder = CvDocument.builder().identity(identity()); + for (CvKind kind : CvKind.values()) { + builder.section(ModuleSection.builder(sectionTitle(kind), SectionRole.OTHER, kind) + .item(CvItem.of(itemTitle(kind)) + .at("Acme GmbH").in("Berlin").period("2021 - Present") + .paragraphs(bodyLine(kind))) + .build()); + } + return builder.build(); + } + + private static String sectionTitle(CvKind kind) { + return "Section " + marker(kind); + } + + /** + * The kind's name with its underscore removed. An underscore is markdown + * for italic, and a fixture carrying two of them would be reporting the + * markdown parser rather than the template. + */ + private static String marker(CvKind kind) { + return kind.name().replace("_", " "); + } + + private static String itemTitle(CvKind kind) { + // PARAGRAPH reads the body alone, so its item title never reaches the + // page; the body carries the marker for that kind instead. + return kind == CvKind.PARAGRAPH ? bodyLine(kind) : "Item " + marker(kind); + } + + private static String bodyLine(CvKind kind) { + return "Body of " + marker(kind); + } + + private static CvDocument document(ModuleSection module) { + return CvDocument.builder().identity(identity()).section(module).build(); + } + + private static CvIdentity identity() { + return CvIdentity.builder() + .name("Jordan", "Rivera") + .jobTitle("Backend Engineer") + .contact("+1 555 0100", "jordan@example.com", "Berlin, DE") + .build(); + } + + /** Every string the composed layout carries, joined. */ + private static String composedText(DocumentTemplate template, CvDocument doc) { + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(24, 24, 24, 24) + .create()) { + template.compose(session, doc); + StringBuilder text = new StringBuilder(); + collectText(session.roots(), text); + return text.toString(); + } + } + + private static void collectText(List nodes, StringBuilder out) { + for (DocumentNode node : nodes) { + if (node instanceof ParagraphNode paragraph) { + out.append(paragraph.text()).append(' '); + } + collectText(node.children(), out); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java index a362537da..08defa811 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java @@ -43,8 +43,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; /** - * Every {@link CvKind} reaches the page, on every preset that renders whatever - * the document hands it. + * Every {@link CvKind} reaches the page, and no preset fails on a module. * *

    A runtime module is only as good as the weakest kind: an author who picks * one the renderers never learned to lower gets a section that silently draws @@ -52,10 +51,11 @@ * cases means a kind added later fails here until it is wired, which is the * point β€” a new constant cannot ship half-rendered.

    * - *

    The ad-hoc cases matter as much as the catalogue ones: a module with - * {@link SectionRole#OTHER} and a heading in a script nobody's keyword list - * contains is exactly the CV this model exists for, and it must survive to the - * page under its own heading.

    + *

    The per-template promise β€” every kind, an invented heading, a non-Latin + * one β€” is checked in {@code ModularCvTemplateFidelityTest}, which enumerates + * the templates that declare the capability instead of a list kept by hand. + * What stays here is the kind-level coverage and the floor every preset owes + * whether or not it declares anything.

    */ class ModuleSectionKindCoverageTest { @@ -138,48 +138,6 @@ void proseRendersWithoutRepeatingTheHeading() throws Exception { .hasSize(2); } - @ParameterizedTest - @MethodSource("generalPresets") - void everyGeneralPresetRendersAModule(DocumentTemplate preset) throws Exception { - ModuleSection module = ModuleSection.builder("Volunteering", SectionRole.OTHER, - CvKind.ENTRIES_DATED) - .item(CvItem.of("Mentor, Rails Girls").at("Rails Girls Berlin") - .period("2019 - 2021").bullets("Ran three weekend workshops")) - .build(); - - String text = render(preset, module); - - // Headings are the preset's to style β€” several letter-space them and - // upper-case them into "V O L U N T E E R I N G" β€” so the heading is - // matched without spacing or case. The content is matched verbatim. - assertThat(text.replace(" ", "")) - .as("%s must render an ad-hoc module under its own heading", preset.id()) - .containsIgnoringCase("Volunteering"); - assertThat(text) - .as("%s must render the module's items", preset.id()) - .contains("Mentor, Rails Girls", "Ran three weekend workshops"); - } - - @ParameterizedTest - @MethodSource("everyPreset") - void noPresetFailsOnAModule(DocumentTemplate preset) throws Exception { - // Weaker than the case above, and deliberately so: eight presets guard - // their module slots on the section's Java type, so a module routed there - // is skipped rather than drawn, and placing it is the routing work rather - // than this change. What no preset may do is throw β€” two of them did until - // this landed, each keeping a private copy of the dispatcher whose final - // else raised IllegalStateException, so the first CV built from a runtime - // module would have failed to render at all. - ModuleSection module = ModuleSection.builder("Volunteering", SectionRole.OTHER, - CvKind.ENTRIES_DATED) - .item(CvItem.of("Mentor, Rails Girls").period("2019 - 2021")) - .build(); - - assertThatCode(() -> render(preset, module)) - .as("%s must render a document containing a runtime module", preset.id()) - .doesNotThrowAnyException(); - } - @ParameterizedTest @MethodSource("presetsThatRenderAnyShape") void aModuleUnderAHeadingThePresetKnowsIsRendered(DocumentTemplate preset) @@ -198,26 +156,9 @@ void aModuleUnderAHeadingThePresetKnowsIsRendered(DocumentTemplate p assertThat(render(preset, module)) .as("%s must render a module it routed by heading", preset.id()) - .contains("Senior Backend Engineer"); - } - - @Test - void aNonLatinHeadingReachesTheLayoutUnderItsOwnWords() throws Exception { - // Preset routing that matches English keywords against a heading has - // nothing to match here; the role carries the meaning instead and the - // heading stays the author's. Asserted against the composed layout rather - // than the PDF text layer on purpose: the CV themes draw with the - // standard-14 Helvetica, which has no Cyrillic glyphs, so the *rendered* - // page shows substitutes until the caller supplies a font that covers the - // script. What this pins is the half that is the model's to get right β€” - // the section is placed and carries its own text. - ModuleSection module = ModuleSection.builder("Навыки", SectionRole.SKILLS, - CvKind.INLINE_LIST) - .item(CvItem.of("Π―Π·Ρ‹ΠΊΠΈ").paragraphs("Java 21", "Kotlin")) - .build(); - - assertThat(composedText(ModernProfessional.create(), module)) - .contains("Навыки", "Π―Π·Ρ‹ΠΊΠΈ", "Java 21, Kotlin"); + // Case-insensitively: a preset that upper-cases entry titles β€” and + // one now does, through its own kit β€” is styling, not dropping. + .containsIgnoringCase("Senior Backend Engineer"); } @Test diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java new file mode 100644 index 000000000..8d25a9f62 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java @@ -0,0 +1,59 @@ +package com.demcha.compose.document.templates.cv.api; + +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.components.CvRenderKit; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.Slot; + +/** + * A CV template that renders every section placed in {@link Slot#MAIN} β€” + * every {@link CvKind}, under whatever heading the author wrote. + * + *

    The promise is exactly that, and the slot is part of it. + * Every shipped preset composes a single main column and reads + * {@code sectionsIn(Slot.MAIN)}; a section placed in {@link Slot#SIDEBAR} or + * {@link Slot#FOOTER} is dropped, by these templates as by every other, which + * is the behaviour {@link com.demcha.compose.document.templates.cv.data.CvDocument} + * has always documented. Saying so here rather than leaving "whatever the + * document hands it" to be read generously is the difference between a + * contract and a slogan β€” a caller assembling a CV at runtime needs to know + * that placing a module in a sidebar loses it today.

    + * + *

    This is the promise a CV assembled at runtime needs, and it is not one + * every preset can make. Several place their sections into fixed slots and + * guard each slot on the section's Java type, so a module routed to one is + * skipped rather than drawn; the CV still renders, minus a section, and + * looks finished. That failure is invisible from the outside, which is why + * the capability is declared in the type system rather than assumed: a + * constructor asks {@link com.demcha.compose.document.templates.cv.presets.CvTemplates#modular()} + * for the templates it may offer, and the rest stay available to callers + * who build the canonical sections by hand.

    + * + *

    Declaring it is not enough to have it. {@code ModularCvTemplateFidelityTest} + * enumerates the implementations and renders a document carrying every kind, + * a section this catalogue has no name for, a heading in a script no keyword + * list contains, and a heading that does match a keyword list β€” the + * last because a preset with an editorial vocabulary of its own is the one + * likely to rename what the author wrote. Each item must reach the page, so + * the interface cannot be worn by a template that would drop or retitle + * one.

    + * + *

    {@link #kit()} is how the promise stays compatible with a preset's own + * look: the shared lowering turns a {@link ModuleSection} into paragraphs, + * rows, and entries, and the kit draws them the way this template draws + * everything else.

    + * + * @since 2.3.0 + */ +public interface ModularCvTemplate extends DocumentTemplate { + + /** + * How this template draws the shapes a module lowers to. + * + * @return this template's kit; {@link CvRenderKit#defaults()} for a + * template whose modules look like the canonical components + */ + CvRenderKit kit(); +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java new file mode 100644 index 000000000..5391265ef --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java @@ -0,0 +1,94 @@ +package com.demcha.compose.document.templates.cv.components; + +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.templates.core.theme.BrandTheme; +import com.demcha.compose.document.templates.cv.data.CvEntry; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.CvRow; +import com.demcha.compose.document.templates.cv.data.RowStyle; + +/** + * How one template draws the three shapes a CV section body reduces to: + * a paragraph of prose, a label/value row, a timeline entry. + * + *

    A preset that wants runtime {@code ModuleSection}s to look like the + * rest of its own document implements this and hands it back through + * {@link com.demcha.compose.document.templates.cv.api.ModularCvTemplate}; + * {@link #defaults()} draws them the canonical way, and every method has a + * default, so a preset overrides only the shapes it actually styles + * differently.

    + * + *

    Why the primitives and not the kinds. The obvious + * alternative is a function per {@link CvKind}. It puts the wrong work on + * the preset: turning a {@link CvItem} into an entry or a row means + * deciding what a linked title looks like, how a subtitle and a location + * join, which fields the kind ignores, what an empty description does to a + * trailing colon β€” rules that belong to the model and must not be + * re-decided sixteen times. {@link ModuleRenderer} keeps that lowering and + * asks the kit only to draw what came out of it, which is exactly the part + * a preset has an opinion about. It is also the shape the presets already + * have: their private renderers take a {@code CvEntry} or a {@code CvRow} + * today.

    + * + *

    Implementations draw into the host and return; they do not set the + * host's spacing or padding, which the caller has already settled, and + * they do not insert separators between items β€” {@code ModuleRenderer} + * owns the gaps so that spacing stays uniform whoever is drawing.

    + * + * @since 2.3.0 + */ +public interface CvRenderKit { + + /** + * The canonical kit: every shape drawn by the shared components, which + * is what a section rendered through + * {@link SectionDispatcher#renderBody(SectionBuilder, com.demcha.compose.document.templates.cv.data.CvSection, BrandTheme)} + * has always produced. + * + * @return a kit that draws every shape the canonical way + */ + static CvRenderKit defaults() { + return DEFAULTS; + } + + /** The canonical kit. Stateless, so one instance serves every caller. */ + CvRenderKit DEFAULTS = new CvRenderKit() { + }; + + /** + * Draws one paragraph of prose. Blank text draws nothing. + * + * @param host host section receiving the paragraph + * @param text the prose; may carry inline markdown + * @param theme the active theme + */ + default void paragraph(SectionBuilder host, String text, BrandTheme theme) { + ParagraphRenderer.render(host, text, theme); + } + + /** + * Draws one label/value row with the given decoration. + * + * @param host host section receiving the row + * @param row label and body + * @param style plain, bulleted, or bulleted with the body stacked under + * the label + * @param theme the active theme + */ + default void row(SectionBuilder host, CvRow row, RowStyle style, BrandTheme theme) { + RowRenderer.render(host, row, style, theme); + } + + /** + * Draws one timeline entry. A blank {@code date} collapses the date + * column rather than reserving an empty one. + * + * @param host host section receiving the entry + * @param entry title, subtitle, date, and body + * @param theme the active theme + */ + default void entry(SectionBuilder host, CvEntry entry, BrandTheme theme) { + EntryRenderer.render(host, entry, theme); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java index 9df3f1cae..9d2a52726 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java @@ -37,23 +37,43 @@ private ModuleRenderer() { } /** - * Renders every item of {@code module} into {@code host}. + * Renders every item of {@code module} into {@code host}, drawing the + * canonical way. * * @param host host section receiving the body * @param module the module supplying items, kind, and role * @param theme the active theme supplying palette, typography, and spacing */ public static void render(SectionBuilder host, ModuleSection module, BrandTheme theme) { + render(host, module, theme, CvRenderKit.defaults()); + } + + /** + * Renders every item of {@code module} into {@code host}, drawing + * through {@code kit}. + * + *

    The lowering below is the same whoever draws: which fields a kind + * reads, how a linked title is spelled, what an empty description does + * to a trailing colon. Only the three drawing calls go to the kit, so a + * preset can restyle its modules without re-deciding any of that.

    + * + * @param host host section receiving the body + * @param module the module supplying items, kind, and role + * @param theme the active theme supplying palette, typography, and spacing + * @param kit how this template draws paragraphs, rows, and entries + */ + public static void render(SectionBuilder host, ModuleSection module, BrandTheme theme, + CvRenderKit kit) { List items = module.items(); for (int i = 0; i < items.size(); i++) { CvItem item = items.get(i); switch (module.kind()) { - case PARAGRAPH -> paragraph(host, item, theme); - case BULLETS -> bullet(host, item, theme); - case BULLETS_STACKED -> stackedBullet(host, item, theme, i > 0); - case INLINE_LIST -> inlineList(host, item, theme); - case ENTRIES -> entry(host, item, "", theme, i > 0); - case ENTRIES_DATED -> entry(host, item, item.period(), theme, i > 0); + case PARAGRAPH -> paragraph(host, item, theme, kit); + case BULLETS -> bullet(host, item, theme, kit); + case BULLETS_STACKED -> stackedBullet(host, item, theme, kit, i > 0); + case INLINE_LIST -> inlineList(host, item, theme, kit); + case ENTRIES -> entry(host, item, "", theme, kit, i > 0); + case ENTRIES_DATED -> entry(host, item, item.period(), theme, kit, i > 0); } } } @@ -63,12 +83,13 @@ public static void render(SectionBuilder host, ModuleSection module, BrandTheme * {@code CvKind.PARAGRAPH}). A bulleted body still bullets β€” the * body style is the author's second choice, independent of kind. */ - private static void paragraph(SectionBuilder host, CvItem item, BrandTheme theme) { + private static void paragraph(SectionBuilder host, CvItem item, BrandTheme theme, + CvRenderKit kit) { for (String line : item.body()) { if (item.bodyStyle() == BodyStyle.BULLETS) { bulletedLine(host, line, theme.bodyStyle(), theme); } else { - ParagraphRenderer.render(host, line, theme); + kit.paragraph(host, line, theme); } } } @@ -79,7 +100,8 @@ private static void paragraph(SectionBuilder host, CvItem item, BrandTheme theme * as its label alone: {@link RowStyle#PLAIN} would leave a colon * pointing at nothing. */ - private static void inlineList(SectionBuilder host, CvItem item, BrandTheme theme) { + private static void inlineList(SectionBuilder host, CvItem item, BrandTheme theme, + CvRenderKit kit) { // The title, not linkedTitle: this kind documents that it ignores the // link, and RowRenderer bolds a label by wrapping it in markdown // markers β€” which would nest around a link and print as literal @@ -88,7 +110,7 @@ private static void inlineList(SectionBuilder host, CvItem item, BrandTheme them ParagraphPrimitive.writeBody(host, item.title(), theme.bodyBoldStyle(), theme); return; } - RowRenderer.render(host, new CvRow(item.title(), String.join(", ", item.body())), + kit.row(host, new CvRow(item.title(), String.join(", ", item.body())), RowStyle.PLAIN, theme); } @@ -102,7 +124,8 @@ private static void inlineList(SectionBuilder host, CvItem item, BrandTheme them * links wants {@link CvKind#BULLETS_STACKED}, which bolds through the * text style and leaves the link intact.

    */ - private static void bullet(SectionBuilder host, CvItem item, BrandTheme theme) { + private static void bullet(SectionBuilder host, CvItem item, BrandTheme theme, + CvRenderKit kit) { if (item.body().isEmpty()) { // PLAIN/BULLETED end the label with a colon, which would point at // nothing. A title-only entry is a plain bullet. @@ -111,7 +134,7 @@ private static void bullet(SectionBuilder host, CvItem item, BrandTheme theme) { DocumentInsets.top((float) theme.spacing().paragraphMarginTop()), theme); return; } - RowRenderer.render(host, new CvRow(item.title(), String.join(" ", item.body())), + kit.row(host, new CvRow(item.title(), String.join(" ", item.body())), RowStyle.BULLETED, theme); } @@ -120,15 +143,14 @@ private static void bullet(SectionBuilder host, CvItem item, BrandTheme theme) { * the title ({@link RowStyle#BULLETED_STACKED}). */ private static void stackedBullet(SectionBuilder host, CvItem item, BrandTheme theme, - boolean separate) { + CvRenderKit kit, boolean separate) { // Stacked items are multi-line blocks, so they get the same gap the // dispatcher puts between stacked rows β€” without it consecutive items // read as one. if (separate) { host.spacer(0, theme.spacing().entrySeparation()); } - RowRenderer.render(host, new CvRow(linkedTitle(item), ""), - RowStyle.BULLETED_STACKED, theme); + kit.row(host, new CvRow(linkedTitle(item), ""), RowStyle.BULLETED_STACKED, theme); // A bulleted body nests a bullet under the item's own; prose is indented // to the title instead of carrying a second glyph. String glyph = item.bodyStyle() == BodyStyle.BULLETS @@ -147,11 +169,11 @@ private static void stackedBullet(SectionBuilder host, CvItem item, BrandTheme t * in the style the item asked for. */ private static void entry(SectionBuilder host, CvItem item, String date, - BrandTheme theme, boolean separate) { + BrandTheme theme, CvRenderKit kit, boolean separate) { if (separate) { host.spacer(0, theme.spacing().entrySeparation()); } - EntryRenderer.render(host, + kit.entry(host, new CvEntry(linkedTitle(item), subtitleWithLocation(item), date, ""), theme); for (String line : item.body()) { if (item.bodyStyle() == BodyStyle.BULLETS) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java index 10be3b78b..b450b8e67 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java @@ -34,11 +34,31 @@ private SectionDispatcher() { * @throws IllegalStateException if the section subtype is unhandled */ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme theme) { + renderBody(host, section, theme, CvRenderKit.defaults()); + } + + /** + * Renders the section body, drawing through {@code kit}. + * + *

    The routing is identical to the three-argument form; only who draws + * differs. A preset with its own entry or row style passes its kit here + * so a runtime module looks like the rest of its document instead of + * like the canonical components.

    + * + * @param host host section receiving the body + * @param section the section whose subtype selects the renderer + * @param theme the active theme supplying palette, typography, and spacing + * @param kit how this template draws paragraphs, rows, and entries + * @throws IllegalStateException if the section subtype is unhandled + * @since 2.3.0 + */ + public static void renderBody(SectionBuilder host, CvSection section, BrandTheme theme, + CvRenderKit kit) { host.spacing(theme.spacing().sectionBodySpacing()) .padding(theme.spacing().sectionBodyPadding()); if (section instanceof ParagraphSection p) { - ParagraphRenderer.render(host, p.body(), theme); + kit.paragraph(host, p.body(), theme); } else if (section instanceof SkillsSection s) { SkillsRenderer.render(host, s, theme); } else if (section instanceof RowsSection r) { @@ -52,13 +72,13 @@ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme if (i > 0 && stackedNeedsSeparator) { host.spacer(0, theme.spacing().entrySeparation()); } - RowRenderer.render(host, r.rows().get(i), r.style(), theme); + kit.row(host, r.rows().get(i), r.style(), theme); } } else if (section instanceof ModuleSection m) { // Runtime-assembled module. The kind decides which of the // renderers above each item lands on, so this branch draws // nothing of its own β€” see ModuleRenderer. - ModuleRenderer.render(host, m, theme); + ModuleRenderer.render(host, m, theme, kit); } else if (section instanceof EntriesSection e) { // Timeline entries (Education, Experience) get a spacer // between items β€” each entry is a multi-line block @@ -68,7 +88,7 @@ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme if (i > 0) { host.spacer(0, theme.spacing().entrySeparation()); } - EntryRenderer.render(host, e.entries().get(i), theme); + kit.entry(host, e.entries().get(i), theme); } } else { throw new IllegalStateException( diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java index 1857b539d..37287a37c 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java @@ -10,6 +10,7 @@ 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.api.ModularCvTemplate; import com.demcha.compose.document.templates.cv.components.*; import com.demcha.compose.document.templates.cv.data.*; import com.demcha.compose.document.templates.core.theme.BrandTheme; @@ -93,7 +94,7 @@ public static DocumentTemplate create(BrandTheme theme) { return new Template(theme); } - private record Template(BrandTheme theme) implements DocumentTemplate { + private record Template(BrandTheme theme) implements ModularCvTemplate { @Override public String id() { @@ -105,6 +106,11 @@ public String displayName() { return DISPLAY_NAME; } + @Override + public CvRenderKit kit() { + return KIT; + } + @Override public void compose(DocumentSession document, CvDocument doc) { Objects.requireNonNull(document, "document"); @@ -167,7 +173,7 @@ private static void renderBody(SectionBuilder host, // page, which matters more than matching this preset's flavour of // entry. A preset that wants its own module styling overrides this // branch, it does not lose the content by omission. - SectionDispatcher.renderBody(host, section, theme); + SectionDispatcher.renderBody(host, section, theme, KIT); } } @@ -185,6 +191,31 @@ private static void renderRows(SectionBuilder host, } } + /** + * This preset's own drawing, so a runtime module gets the upper-cased + * two-column entry and the dash-joined project row the rest of the + * document uses rather than the canonical ones. + * + *

    Stateless: every method takes its theme, so one instance serves + * every {@code create(theme)}.

    + */ + private static final CvRenderKit KIT = new CvRenderKit() { + + @Override + public void entry(SectionBuilder host, CvEntry entry, BrandTheme theme) { + renderEntry(host, entry, theme); + } + + @Override + public void row(SectionBuilder host, CvRow row, RowStyle style, BrandTheme theme) { + if (style == RowStyle.BULLETED_STACKED) { + renderPlainProjectRow(host, row, theme); + return; + } + RowRenderer.render(host, row, style, theme); + } + }; + private static void renderPlainProjectRow(SectionBuilder host, CvRow row, BrandTheme theme) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java index 9cbdd108e..269ac35c7 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java @@ -3,6 +3,8 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; +import com.demcha.compose.document.templates.cv.components.CvRenderKit; import com.demcha.compose.document.templates.cv.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.data.CvDocument; import com.demcha.compose.document.templates.cv.data.CvSection; @@ -83,7 +85,7 @@ public static DocumentTemplate create(BrandTheme theme) { return new Template(theme); } - private record Template(BrandTheme theme) implements DocumentTemplate { + private record Template(BrandTheme theme) implements ModularCvTemplate { @Override public String id() { @@ -95,6 +97,13 @@ public String displayName() { return DISPLAY_NAME; } + @Override + public CvRenderKit kit() { + // This preset renders bodies through the shared dispatcher, so a + // runtime module already looks like the rest of its document. + return CvRenderKit.defaults(); + } + @Override public void compose(DocumentSession document, CvDocument doc) { Objects.requireNonNull(document, "document"); @@ -128,7 +137,7 @@ public void compose(DocumentSession document, CvDocument doc) { SectionHeader.banner(host, sec.title(), theme); }); pageFlow.addSection("CvV2Body_" + idx, - host -> SectionDispatcher.renderBody(host, sec, theme)); + host -> SectionDispatcher.renderBody(host, sec, theme, kit())); } pageFlow.build(); diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java index 0cf25e9b3..bb501bd66 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java @@ -8,7 +8,9 @@ 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.api.ModularCvTemplate; import com.demcha.compose.document.templates.cv.components.ProjectRenderer; +import com.demcha.compose.document.templates.cv.components.CvRenderKit; import com.demcha.compose.document.templates.cv.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.data.*; import com.demcha.compose.document.templates.core.theme.BrandTheme; @@ -109,7 +111,7 @@ public static DocumentTemplate create(BrandTheme theme) { return new Template(theme); } - private record Template(BrandTheme theme) implements DocumentTemplate { + private record Template(BrandTheme theme) implements ModularCvTemplate { @Override public String id() { @@ -121,6 +123,13 @@ public String displayName() { return DISPLAY_NAME; } + @Override + public CvRenderKit kit() { + // This preset renders bodies through the shared dispatcher, so a + // runtime module already looks like the rest of its document. + return CvRenderKit.defaults(); + } + @Override public void compose(DocumentSession document, CvDocument doc) { Objects.requireNonNull(document, "document"); @@ -184,7 +193,7 @@ private void renderBody(SectionBuilder host, CvSection sec) { } return; } - SectionDispatcher.renderBody(host, sec, theme); + SectionDispatcher.renderBody(host, sec, theme, kit()); } private void renderStackedProject(SectionBuilder host, CvRow row) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java index f2671c2d0..ff0e4c52b 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java @@ -246,10 +246,45 @@ private void renderDetailBody(SectionBuilder host, CvSection section) { // runtime ModuleSection. The canonical dispatcher renders it // rather than the render failing on a section the author // legitimately added. - SectionDispatcher.renderBody(host, section, theme); + SectionDispatcher.renderBody(host, section, theme, kit()); } } + /** + * This preset's own drawing, so a runtime module routed into one + * of its modules gets the serif entry and the project row the + * rest of the document uses. + * + *

    Not a {@code ModularCvTemplate}: this preset composes six + * fixed modules and finds each by matching headings, so a section + * it does not recognise never reaches a renderer at all. Drawing + * modules well and rendering every module are different promises, + * and it can only make the first.

    + * + *

    Built per call rather than cached: a record's methods are + * only reachable from an instance, and the kit closes over this + * template's theme.

    + */ + private CvRenderKit kit() { + return new CvRenderKit() { + + @Override + public void entry(SectionBuilder host, CvEntry entry, BrandTheme unused) { + renderEntry(host, entry); + } + + @Override + public void row(SectionBuilder host, CvRow row, RowStyle style, + BrandTheme unused) { + if (style == RowStyle.BULLETED_STACKED) { + renderProject(host, row); + return; + } + renderKeyValue(host, row); + } + }; + } + private void renderEntries(SectionBuilder host, EntriesSection entries) { for (int i = 0; i < entries.entries().size(); i++) { if (i > 0) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java new file mode 100644 index 000000000..a6d6417ff --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java @@ -0,0 +1,167 @@ +package com.demcha.compose.document.templates.cv.presets; + +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Every shipped CV preset, by the id it publishes. + * + *

    A preset is a class, and picking one at compile time is a constructor + * call. Picking one at runtime β€” from a dropdown, a config file, a + * request field β€” is a lookup, and until now every caller wrote its own: + * a switch, a map, a list that has to be remembered when a preset ships. + * The consumer this model exists for keeps exactly such a map in another + * repository, where nothing tells it a preset was added.

    + * + *
    {@code
    + * CvTemplates.byId("modern-professional")
    + *            .orElseThrow()
    + *            .compose(session, doc);
    + * }
    + * + *

    {@link #modular()} is the list to offer when the document is assembled + * at runtime: the presets that promise to render whatever they are handed + * (see {@link ModularCvTemplate}). The rest stay in {@link #all()} for + * callers who build the canonical sections by hand β€” they are not lesser + * templates, they are templates with a fixed idea of what a CV contains.

    + * + *

    Every lookup builds a fresh template with the preset's own default + * theme; a caller wanting a variant calls that preset's + * {@code create(BrandTheme)} directly. {@code CvTemplatesCoverageTest} holds + * this catalogue to the presets package, so a preset added and not + * registered fails the build rather than staying invisible to every runtime + * caller.

    + * + * @since 2.3.0 + */ +public final class CvTemplates { + + /** + * One catalogue entry. Keeping the id, the margin, and the factory + * together is what makes the catalogue impossible to half-update: there + * is one list, and a preset is in it or it is not. + */ + private record Preset(String id, double recommendedMargin, + Supplier> factory) { + } + + /** The catalogue, in the order a gallery shows it. */ + private static final List PRESETS = List.of( + new Preset(ModernProfessional.ID, ModernProfessional.RECOMMENDED_MARGIN, + ModernProfessional::create), + new Preset(BoxedSections.ID, BoxedSections.RECOMMENDED_MARGIN, + BoxedSections::create), + new Preset(MinimalUnderlined.ID, MinimalUnderlined.RECOMMENDED_MARGIN, + MinimalUnderlined::create), + new Preset(Executive.ID, Executive.RECOMMENDED_MARGIN, Executive::create), + new Preset(CenteredHeadline.ID, CenteredHeadline.RECOMMENDED_MARGIN, + CenteredHeadline::create), + new Preset(BlueBanner.ID, BlueBanner.RECOMMENDED_MARGIN, BlueBanner::create), + new Preset(ClassicSerif.ID, ClassicSerif.RECOMMENDED_MARGIN, ClassicSerif::create), + new Preset(EditorialBlue.ID, EditorialBlue.RECOMMENDED_MARGIN, + EditorialBlue::create), + new Preset(CompactMono.ID, CompactMono.RECOMMENDED_MARGIN, CompactMono::create), + new Preset(EngineeringResume.ID, EngineeringResume.RECOMMENDED_MARGIN, + EngineeringResume::create), + new Preset(NordicClean.ID, NordicClean.RECOMMENDED_MARGIN, NordicClean::create), + new Preset(Panel.ID, Panel.RECOMMENDED_MARGIN, Panel::create), + new Preset(TimelineMinimal.ID, TimelineMinimal.RECOMMENDED_MARGIN, + TimelineMinimal::create), + new Preset(MonogramSidebar.ID, MonogramSidebar.RECOMMENDED_MARGIN, + MonogramSidebar::create), + new Preset(SidebarPortrait.ID, SidebarPortrait.RECOMMENDED_MARGIN, + SidebarPortrait::create), + new Preset(MintEditorial.ID, MintEditorial.RECOMMENDED_MARGIN, + MintEditorial::create)); + + private CvTemplates() { + } + + /** + * The template published under {@code id}, built with its own default + * theme. + * + * @param id a preset id such as {@code "modern-professional"}; leading + * and trailing whitespace is ignored. An unknown or null id + * yields an empty result rather than an exception β€” an id + * arriving from a config file or a request is input to + * validate, not a programming error + * @return the template, or empty when no preset publishes that id + */ + public static Optional> byId(String id) { + return find(id).map(preset -> preset.factory().get()); + } + + /** + * Every shipped preset, freshly built, in gallery order. + * + * @return one template per preset + */ + public static List> all() { + List> templates = new ArrayList<>(PRESETS.size()); + for (Preset preset : PRESETS) { + templates.add(preset.factory().get()); + } + return List.copyOf(templates); + } + + /** + * The presets that render whatever the document hands them β€” the ones + * to offer for a CV assembled at runtime. + * + * @return one template per preset implementing {@link ModularCvTemplate}, + * in gallery order + */ + public static List modular() { + List templates = new ArrayList<>(); + for (DocumentTemplate template : all()) { + if (template instanceof ModularCvTemplate modular) { + templates.add(modular); + } + } + return List.copyOf(templates); + } + + /** + * Every registered preset id, in gallery order. + * + * @return the ids {@link #byId(String)} answers to + */ + public static List ids() { + List ids = new ArrayList<>(PRESETS.size()); + for (Preset preset : PRESETS) { + ids.add(preset.id()); + } + return List.copyOf(ids); + } + + /** + * The page margin the preset was designed at, in points β€” which a caller + * needs while building the session, before it has a template. + * + * @param id a preset id + * @return the margin, or empty when no preset publishes that id + */ + public static Optional recommendedMargin(String id) { + return find(id).map(Preset::recommendedMargin); + } + + private static Optional find(String id) { + if (id == null) { + return Optional.empty(); + } + String wanted = id.trim(); + for (Preset preset : PRESETS) { + if (preset.id().equals(wanted)) { + return Optional.of(preset); + } + } + return Optional.empty(); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java index 5fc39b203..c8cb9efe9 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java @@ -12,6 +12,7 @@ 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.api.ModularCvTemplate; import com.demcha.compose.document.templates.cv.components.*; import com.demcha.compose.document.templates.cv.data.*; import com.demcha.compose.document.templates.core.theme.BrandTheme; @@ -87,7 +88,7 @@ public static DocumentTemplate create(BrandTheme theme) { return new Template(theme); } - private record Template(BrandTheme theme) implements DocumentTemplate { + private record Template(BrandTheme theme) implements ModularCvTemplate { @Override public String id() { @@ -121,7 +122,7 @@ public void compose(DocumentSession document, CvDocument doc) { CvSection section = sections.get(i); String name = "CvV2EditorialBlue_" + i; FlowSectionHeader.label(pageFlow, name + "_Title", - displayTitle(section.title()), width, theme, + headingFor(section), width, theme, sectionTitleStyle(), new DocumentInsets(8, 0, 0, 0), new DocumentInsets(7, 0, 5, 0), DocumentInsets.zero(), true); @@ -152,7 +153,7 @@ private void renderSectionBody(SectionBuilder section, CvSection cvSection, // would render as nothing at all: an empty heading over blank // space, which reads as a finished CV that quietly lost a // section. - SectionDispatcher.renderBody(section, cvSection, theme); + SectionDispatcher.renderBody(section, cvSection, theme, kit()); } } @@ -172,6 +173,43 @@ private void renderEntries(SectionBuilder section, EntriesSection entries) { } } + /** + * This preset's own drawing, so a runtime module gets the + * editorial entry, project, and key/value shapes. + * + *

    Entries take the experience styling. The preset picks + * between its experience and education variants by sniffing a + * section's heading, which is exactly what a module carries a + * role to avoid; until the kit is handed that role, one of the + * two has to be the answer, and experience is the shape most + * modules take.

    + */ + @Override + public CvRenderKit kit() { + return new CvRenderKit() { + + @Override + public void entry(SectionBuilder host, CvEntry entry, BrandTheme unused) { + renderExperienceEntry(host, entry); + } + + @Override + public void row(SectionBuilder host, CvRow row, RowStyle style, + BrandTheme unused) { + if (style == RowStyle.BULLETED_STACKED) { + renderProject(host, row); + return; + } + renderKeyValue(host, row); + } + + @Override + public void paragraph(SectionBuilder host, String text, BrandTheme unused) { + renderParagraph(host, text, 1.6); + } + }; + } + private void renderExperienceEntry(SectionBuilder section, CvEntry entry) { DocumentTextStyle titleStyle = TextStyles.of(FontName.HELVETICA, 11.0, DocumentTextDecoration.BOLD, NAME_COLOR); @@ -281,6 +319,21 @@ private void addFooter(PageFlowBuilder pageFlow, double width) { .margin(DocumentInsets.top(2)))); } + /** + * The heading to print. A module carries a heading the author + * chose and a role that already says what the section is, so it + * prints as written; the keyword rename below exists to give the + * canonical sections this preset's editorial vocabulary, and + * applying it to a module would retitle "Certifications & Awards" + * as "EDUCATION" β€” which is exactly the promise + * {@code ModularCvTemplate} makes it must not do. + */ + private String headingFor(CvSection section) { + return section instanceof ModuleSection + ? section.title().toUpperCase(Locale.ROOT) + : displayTitle(section.title()); + } + private String displayTitle(String title) { String normalized = SectionLookup.normalize(title); if (normalized.contains("summary") || normalized.contains("profile")) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java index 95ae92d35..7842cdabf 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java @@ -12,7 +12,9 @@ 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.api.ModularCvTemplate; import com.demcha.compose.document.templates.core.text.TextStyles; +import com.demcha.compose.document.templates.cv.components.CvRenderKit; import com.demcha.compose.document.templates.cv.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.data.*; import com.demcha.compose.document.templates.core.theme.BrandTheme; @@ -96,7 +98,7 @@ public static DocumentTemplate create(BrandTheme theme) { return new Template(theme); } - private record Template(BrandTheme theme) implements DocumentTemplate { + private record Template(BrandTheme theme) implements ModularCvTemplate { @Override public String id() { @@ -108,6 +110,13 @@ public String displayName() { return DISPLAY_NAME; } + @Override + public CvRenderKit kit() { + // This preset renders bodies through the shared dispatcher, so a + // runtime module already looks like the rest of its document. + return CvRenderKit.defaults(); + } + @Override public void compose(DocumentSession document, CvDocument doc) { Objects.requireNonNull(document, "document"); @@ -132,7 +141,7 @@ public void compose(DocumentSession document, CvDocument doc) { ACCENT, theme); }); flow.addSection("CvV2ExecutiveBody_" + idx, host -> - SectionDispatcher.renderBody(host, sec, theme)); + SectionDispatcher.renderBody(host, sec, theme, kit())); } flow.build(); diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java index a93206767..720fa0820 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java @@ -3,6 +3,8 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; +import com.demcha.compose.document.templates.cv.components.CvRenderKit; import com.demcha.compose.document.templates.cv.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.data.CvDocument; import com.demcha.compose.document.templates.cv.data.CvSection; @@ -83,7 +85,7 @@ public static DocumentTemplate create(BrandTheme theme) { return new Template(theme); } - private record Template(BrandTheme theme) implements DocumentTemplate { + private record Template(BrandTheme theme) implements ModularCvTemplate { @Override public String id() { @@ -95,6 +97,13 @@ public String displayName() { return DISPLAY_NAME; } + @Override + public CvRenderKit kit() { + // This preset renders bodies through the shared dispatcher, so a + // runtime module already looks like the rest of its document. + return CvRenderKit.defaults(); + } + @Override public void compose(DocumentSession document, CvDocument doc) { Objects.requireNonNull(document, "document"); @@ -123,7 +132,7 @@ public void compose(DocumentSession document, CvDocument doc) { SectionHeader.underlined(host, sec.title(), theme); }); pageFlow.addSection("Body_" + idx, host -> - SectionDispatcher.renderBody(host, sec, theme)); + SectionDispatcher.renderBody(host, sec, theme, kit())); } pageFlow.build(); diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java index b1ab62a94..4c2a81191 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java @@ -6,6 +6,8 @@ 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.api.ModularCvTemplate; +import com.demcha.compose.document.templates.cv.components.CvRenderKit; import com.demcha.compose.document.templates.cv.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.data.CvDocument; import com.demcha.compose.document.templates.cv.data.CvSection; @@ -109,7 +111,7 @@ public static DocumentTemplate create(BrandTheme theme) { return new Template(theme); } - private record Template(BrandTheme theme) implements DocumentTemplate { + private record Template(BrandTheme theme) implements ModularCvTemplate { @Override public String id() { @@ -121,6 +123,13 @@ public String displayName() { return DISPLAY_NAME; } + @Override + public CvRenderKit kit() { + // This preset renders bodies through the shared dispatcher, so a + // runtime module already looks like the rest of its document. + return CvRenderKit.defaults(); + } + @Override public void compose(DocumentSession document, CvDocument doc) { Objects.requireNonNull(document, "document"); @@ -178,7 +187,7 @@ public void compose(DocumentSession document, CvDocument doc) { SectionHeader.flat(host, sec.title(), SECTION_TITLE_COLOR, theme); }); pageFlow.addSection("Body_" + idx, host -> - SectionDispatcher.renderBody(host, sec, theme)); + SectionDispatcher.renderBody(host, sec, theme, kit())); } pageFlow.build(); diff --git a/templates/src/test/java/com/demcha/compose/document/templates/cv/presets/CvTemplatesCoverageTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/presets/CvTemplatesCoverageTest.java new file mode 100644 index 000000000..3d96b7866 --- /dev/null +++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/presets/CvTemplatesCoverageTest.java @@ -0,0 +1,149 @@ +package com.demcha.compose.document.templates.cv.presets; + +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The catalogue lists every preset in the package. + * + *

    A registry is only useful while it is complete, and the way it stops + * being complete is that someone ships a preset and forgets the one line. + * Nothing about that fails: the preset works, its tests pass, its example + * renders β€” it is merely invisible to every caller that picks a template by + * id, which is the whole audience the catalogue exists for. So the list is + * derived from the package rather than trusted, by reading the directory + * the presets live in.

    + * + *

    Reading source files rather than scanning the classpath is deliberate: + * it needs no reflection dependency, and the failure message can name the + * file to add.

    + */ +class CvTemplatesCoverageTest { + + /** The presets package, relative to this module's directory. */ + private static final Path PRESETS = Path.of( + "src/main/java/com/demcha/compose/document/templates/cv/presets"); + + @Test + void everyPresetInThePackageIsInTheCatalogue() throws IOException { + List missing = new ArrayList<>(); + for (String preset : presetClassNames()) { + if (CvTemplates.all().stream().noneMatch(t -> declaredBy(t, preset))) { + missing.add(preset); + } + } + + assertThat(missing) + .as("every preset in %s must be registered in CvTemplates β€” a preset " + + "missing from the catalogue is invisible to every caller that " + + "picks a template by id", PRESETS) + .isEmpty(); + } + + @Test + void theCatalogueListsNothingTwice() { + assertThat(CvTemplates.ids()).doesNotHaveDuplicates(); + assertThat(CvTemplates.all()).hasSameSizeAs(CvTemplates.ids()); + } + + @Test + void everyRegisteredIdResolvesToTheTemplateThatPublishesIt() { + for (String id : CvTemplates.ids()) { + assertThat(CvTemplates.byId(id)) + .as("byId(%s) must resolve", id) + .isPresent() + .get() + .extracting(DocumentTemplate::id) + .as("byId(%s) must return the template publishing that id", id) + .isEqualTo(id); + assertThat(CvTemplates.recommendedMargin(id)) + .as("recommendedMargin(%s) must be known", id) + .isPresent(); + } + } + + @Test + void anUnknownOrNullIdIsAnEmptyResultNotAnException() { + // The id arrives from a config file or a request; a caller validating + // input should not have to catch anything. + assertThat(CvTemplates.byId("no-such-preset")).isEmpty(); + assertThat(CvTemplates.byId(null)).isEmpty(); + assertThat(CvTemplates.byId("")).isEmpty(); + assertThat(CvTemplates.recommendedMargin("no-such-preset")).isEmpty(); + } + + @Test + void surroundingWhitespaceInAnIdIsIgnored() { + assertThat(CvTemplates.byId(" modern-professional ")) + .get() + .extracting(DocumentTemplate::id) + .isEqualTo(ModernProfessional.ID); + } + + @Test + void everyTemplateBuildsAFreshInstance() { + // all() hands each caller its own template rather than a shared one, + // so a caller cannot be surprised by another's theme. + List> first = CvTemplates.all(); + List> second = CvTemplates.all(); + + for (int i = 0; i < first.size(); i++) { + assertThat(first.get(i)).isNotSameAs(second.get(i)); + assertThat(first.get(i).id()).isEqualTo(second.get(i).id()); + } + } + + /** + * The class names in the presets package that are actually presets: + * public types with a factory. {@code package-info} carries no class, and + * {@code ColumnPagination} is a package-private helper rather than a + * template, so neither belongs in a catalogue of templates. + */ + private static List presetClassNames() throws IOException { + assertThat(PRESETS).as("presets package must exist at %s", PRESETS).exists(); + try (Stream files = Files.list(PRESETS)) { + List names = new ArrayList<>(); + for (Path file : files.toList()) { + String name = file.getFileName().toString(); + if (!name.endsWith(".java") || name.equals("package-info.java")) { + continue; + } + String simpleName = name.substring(0, name.length() - ".java".length()); + String source = Files.readString(file); + if (source.contains("public final class " + simpleName) + && source.contains("public static final String ID")) { + names.add(simpleName); + } + } + assertThat(names) + .as("the scan must find the presets it is meant to guard") + .hasSizeGreaterThan(10); + return names; + } + } + + /** + * Whether {@code template} is the one {@code presetClass} publishes, + * decided by the id constant that class declares. + */ + private static boolean declaredBy(DocumentTemplate template, String presetClass) { + try { + Class type = Class.forName( + "com.demcha.compose.document.templates.cv.presets." + presetClass); + Object id = type.getField("ID").get(null); + return template.id().equals(id); + } catch (ReflectiveOperationException e) { + throw new AssertionError("preset " + presetClass + " must publish a public ID", e); + } + } +}