From d4e2b45d4261a767db6dfb6c7b1176bd12c45422 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 17 Aug 2026 14:23:40 +0100 Subject: [PATCH 1/3] 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 eaeda76d..0a7affac 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 fb97aa3c..e63ee3bc 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 1ad6ce22..fb743b64 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 b4f4b508..4a6e658d 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 2d53ee4c..25cdb4bd 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 c8ffe968..7386afe4 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 e04ba099..6e5da7a1 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 18f7234e..ec908264 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 72b797b8..09849aef 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 59fd176c..9d7a002f 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 06bf5142..535f40aa 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 97a4eb8e..ebe0860b 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 f231910b..e856b0a4 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 bb95bf1e..5c8822d6 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 82398d0d..2432158e 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 b4b41482..4746605a 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/3] 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 0a7affac..e26d298e 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 bcfaf31f..461bb11c 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 00000000..a362537d --- /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 00000000..eb82d249 --- /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 160302a0..f703bef0 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 00000000..9df3f1ca --- /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 c2280c71..10be3b78 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 c116eb34..e5f3a96c 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 00000000..8d927a47 --- /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 00000000..db0f1716 --- /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 00000000..8cecabda --- /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 e3da6bbe..c5e2d266 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 00000000..331078a7 --- /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 00000000..de00b77a --- /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 f5ff7779..54adaf5e 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 6f70fd3a..1857b539 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 e4dbab8d..f2671c2d 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 42637967..5fc39b20 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 00000000..ee4bae0d --- /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/3] 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 ee4bae0d..9930c6bc 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