diff --git a/CHANGELOG.md b/CHANGELOG.md index eaeda76dd..e26d298e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,54 @@ 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 + +### 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 diff --git a/README.md b/README.md index fb97aa3c0..e63ee3bc8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ > **Release status** — > 🟢 **Latest stable**: [v2.2.0](https://github.com/DemchaAV/GraphCompose/releases/tag/v2.2.0) — the **right-to-left** release: Hebrew and Arabic lay out, shape, join and mirror through PDF, PowerPoint and Word — in paragraphs and in table cells — with the fonts to render them. See [CHANGELOG.md](./CHANGELOG.md). ->  ·  🟡 **In development**: v2.2.1 on `develop` — see [CHANGELOG.md](./CHANGELOG.md). +>  ·  🟡 **In development**: v2.3.0 on `develop` — see [CHANGELOG.md](./CHANGELOG.md).

Live Showcase diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml index 1ad6ce226..fb743b64f 100644 --- a/benchmarks/pom.xml +++ b/benchmarks/pom.xml @@ -7,7 +7,7 @@ io.github.demchaav graph-compose-build - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT ../pom.xml diff --git a/bundle/pom.xml b/bundle/pom.xml index b4f4b508b..4a6e658d4 100644 --- a/bundle/pom.xml +++ b/bundle/pom.xml @@ -32,7 +32,7 @@ graph-compose and graph-compose-templates dependencies below use ${project.version}, so they follow automatically. --> - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT jar GraphCompose Bundle diff --git a/core/pom.xml b/core/pom.xml index 2d53ee4cf..25cdb4bd4 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -6,7 +6,7 @@ io.github.demchaav graph-compose-core - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT GraphCompose Core A declarative layout engine for programmatic document generation, implemented primarily in Java. This is the lean engine coordinate; depend on the `graph-compose` artifact for the drop-in, PDF-capable install. diff --git a/coverage/pom.xml b/coverage/pom.xml index c8ffe968f..7386afe42 100644 --- a/coverage/pom.xml +++ b/coverage/pom.xml @@ -29,7 +29,7 @@ io.github.demchaav graph-compose-build - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT ../pom.xml diff --git a/docs/roadmaps/post-2.0-engineering.md b/docs/roadmaps/post-2.0-engineering.md index e04ba0994..6e5da7a1c 100644 --- a/docs/roadmaps/post-2.0-engineering.md +++ b/docs/roadmaps/post-2.0-engineering.md @@ -111,7 +111,7 @@ exercise it. Report-only; thresholds can follow after a baseline read. `japicmp` ran report-only through the 2.0 major — the major intentionally broke binary compatibility. With the 2.0.0 GA artifacts on Central the gate switched to per-module baselines pinned at the major's floor (2.0.0) in break-on-incompatible -mode: `graph-compose-core` first, `graph-compose-templates` since 2.2.1 — each +mode: `graph-compose-core` first, `graph-compose-templates` since 2.3.0 — each module's `japicmp` profile lives in its own pom and runs in the PR-time `Binary Compatibility` job, in `cut-release.ps1` step 5b, and in the publish workflow. The remaining published modules (`render-pdf` / `render-docx` / diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index bcfaf31fd..461bb11ca 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -19,11 +19,12 @@ it sets up the conceptual model in 5 minutes. 1. [The pieces you assemble](#the-pieces-you-assemble) 2. [Identity — name, contact, optional links](#identity) 3. [Section types](#section-types) -4. [Slots — main vs sidebar](#slots) -5. [Picking a preset](#picking-a-preset) -6. [Customising a theme](#customising-a-theme) -7. [Rendering — pageSize, margins, output](#rendering) -8. [Common patterns](#common-patterns) +4. [Building sections at runtime — `ModuleSection`](#runtime-modules) +5. [Slots — main vs sidebar](#slots) +6. [Picking a preset](#picking-a-preset) +7. [Customising a theme](#customising-a-theme) +8. [Rendering — pageSize, margins, output](#rendering) +9. [Common patterns](#common-patterns) --- @@ -161,6 +162,58 @@ paragraph beneath. --- + +## Building sections at runtime — `ModuleSection` + +The four types above are the right choice when you write a CV in Java: +you pick the record and the compiler checks it. They are the wrong one +when the CV is assembled from data — a form, a JSON payload, an LLM — +because the shape is not known until it arrives, and a user who picks +"dated entries" from a menu cannot instantiate a different record per +choice. + +`ModuleSection` moves that choice into a value. One item record carries +every optional field, and a `CvKind` decides which of them are read: + +```java +ModuleSection.builder("Volunteering", SectionRole.OTHER, CvKind.ENTRIES_DATED) + .item(CvItem.of("Mentor, Rails Girls") + .at("Rails Girls Berlin") // subtitle + .in("Berlin, DE") // location + .period("2019 - 2021") // read by dated kinds only + .bullets("Ran three weekend workshops")) + .build(); +``` + +| `CvKind` | Shape | Reads | +|---|---|---| +| `PARAGRAPH` | prose under the section heading | `body` | +| `BULLETS` | a bullet per item, description inline | `title`, `body` (not `link`) | +| `BULLETS_STACKED` | a bullet per item, description underneath | `title`, `link`, `body` | +| `INLINE_LIST` | `Languages: Java 21, Kotlin` | `title`, `body` (not `link`) | +| `ENTRIES` | timeline, no date column | everything but `period` | +| `ENTRIES_DATED` | timeline with dates | everything | + +Only `title` is required on an item. Whatever a kind does not read is +ignored, so the same item renders with or without its dates depending +on the kind alone — which is what lets a "Volunteering" module be +shaped exactly like Education without a new type. + +`SectionRole` says what a section *means*, separately from how it +draws. Multi-column presets decide what belongs in a sidebar by +matching headings against English keywords, which a CV headed +`Ausbildung` or `Навыки` never matches. The role is where that +decision belongs — stated by the author, who knows the answer. **The +presets do not read it yet**: today it travels with the section and is +the input the routing work will consume, so a document built now needs +no rewrite when they do. + +Modules and the four fixed types mix freely in one document, and both +render through the same components — a module drawn as `ENTRIES_DATED` +lays out exactly like the `EntriesSection` carrying the same content. + +--- + ## Slots — main vs sidebar diff --git a/examples/pom.xml b/examples/pom.xml index 18f7234e6..ec908264c 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -7,7 +7,7 @@ io.github.demchaav graph-compose-build - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT ../pom.xml diff --git a/pom.xml b/pom.xml index 72b797b8b..09849aeff 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.demchaav graph-compose-build - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT pom GraphCompose Build Aggregator diff --git a/qa/pom.xml b/qa/pom.xml index 59fd176ce..9d7a002fe 100644 --- a/qa/pom.xml +++ b/qa/pom.xml @@ -22,7 +22,7 @@ io.github.demchaav graph-compose-build - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT ../pom.xml diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java new file mode 100644 index 000000000..a362537da --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleSectionKindCoverageTest.java @@ -0,0 +1,302 @@ +package com.demcha.compose.document.templates.cv.components; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.presets.BlueBanner; +import com.demcha.compose.document.templates.cv.presets.BoxedSections; +import com.demcha.compose.document.templates.cv.presets.CenteredHeadline; +import com.demcha.compose.document.templates.cv.presets.ClassicSerif; +import com.demcha.compose.document.templates.cv.presets.CompactMono; +import com.demcha.compose.document.templates.cv.presets.EditorialBlue; +import com.demcha.compose.document.templates.cv.presets.EngineeringResume; +import com.demcha.compose.document.templates.cv.presets.Executive; +import com.demcha.compose.document.templates.cv.presets.MinimalUnderlined; +import com.demcha.compose.document.templates.cv.presets.MintEditorial; +import com.demcha.compose.document.templates.cv.presets.ModernProfessional; +import com.demcha.compose.document.templates.cv.presets.MonogramSidebar; +import com.demcha.compose.document.templates.cv.presets.NordicClean; +import com.demcha.compose.document.templates.cv.presets.Panel; +import com.demcha.compose.document.templates.cv.presets.SidebarPortrait; +import com.demcha.compose.document.templates.cv.presets.TimelineMinimal; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Every {@link CvKind} reaches the page, on every preset that renders whatever + * the document hands it. + * + *

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

+ * + *

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

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

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

+ * + *

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

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

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

+ * + *

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

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

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

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

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

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

* + *

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

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

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

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

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

+ * + *

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

+ * + *

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

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

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

+ * + *

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

+ * + *

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

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

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

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

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

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

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

+ * + *

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

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

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

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

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

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

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

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

    Every implementation carries a {@code title} — the banner text * the renderer wraps in a styled panel above the section body.

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

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

    + * + *

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

    + * + *

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

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

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

    + * + *

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

    + * + *

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

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

    Which one to reach for

    + * + *

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

    + * + *

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

    + * *

    Placement

    * *

    Sections live inside a {@link com.demcha.compose.document.templates.cv.data.CvDocument} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java index 6f70fd3ab..1857b539d 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java @@ -161,8 +161,13 @@ private static void renderBody(SectionBuilder host, renderEntry(host, entry, theme); } } else { - throw new IllegalStateException( - "Unknown CvSection subtype: " + section.getClass().getName()); + // A shape this preset has no styled path for — today the runtime + // ModuleSection. Hand it to the canonical dispatcher rather than + // throwing: a section the author put in the document reaches the + // page, which matters more than matching this preset's flavour of + // entry. A preset that wants its own module styling overrides this + // branch, it does not lose the content by omission. + SectionDispatcher.renderBody(host, section, theme); } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java index e4dbab8da..f2671c2d0 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java @@ -242,9 +242,11 @@ private void renderDetailBody(SectionBuilder host, CvSection section) { new CvRow(group.category(), group.skillsInline())); } } else { - throw new IllegalStateException( - "Unknown CvSection subtype: " - + section.getClass().getName()); + // A shape this preset has no serif-styled path for — today the + // runtime ModuleSection. The canonical dispatcher renders it + // rather than the render failing on a section the author + // legitimately added. + SectionDispatcher.renderBody(host, section, theme); } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java index 42637967f..5fc39b203 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EditorialBlue.java @@ -146,6 +146,13 @@ private void renderSectionBody(SectionBuilder section, CvSection cvSection, renderEntries(section, entries); } else if (cvSection instanceof RowsSection rows) { renderRows(section, rows); + } else { + // A shape this preset has no editorial-styled path for — today + // the runtime ModuleSection. Without this branch the section + // would render as nothing at all: an empty heading over blank + // space, which reads as a finished CV that quietly lost a + // section. + SectionDispatcher.renderBody(section, cvSection, theme); } } diff --git a/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java new file mode 100644 index 000000000..9930c6bce --- /dev/null +++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/data/ModuleSectionTest.java @@ -0,0 +1,170 @@ +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"); + // 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 + 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); + } +} diff --git a/testing/pom.xml b/testing/pom.xml index 82398d0d6..2432158e2 100644 --- a/testing/pom.xml +++ b/testing/pom.xml @@ -19,7 +19,7 @@ --> io.github.demchaav graph-compose-testing - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT GraphCompose Testing Consumer testing support for GraphCompose: layout-snapshot assertions and PDF visual regression. diff --git a/wrapper/pom.xml b/wrapper/pom.xml index b4b414829..4746605aa 100644 --- a/wrapper/pom.xml +++ b/wrapper/pom.xml @@ -22,7 +22,7 @@ --> io.github.demchaav graph-compose - 2.2.1-SNAPSHOT + 2.3.0-SNAPSHOT GraphCompose The graph-compose coordinate: a drop-in aggregator over graph-compose-core for a PDF-capable install.