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 @@
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 StreamThat 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.ListNothing 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) { + ListThe 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(ListThe 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, ListThis 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). *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, ListMulti-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. + *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.
+ * *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