From bedbecb7a63343c03c3e08d21e72ab37963dc564 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 22 Aug 2026 21:36:56 +0100 Subject: [PATCH 1/4] feat(templates): the constructor contract is the module kinds A modular template implements one method per CvKind, with no defaults. JSON picks the shape; the template draws the shape. It does not know whether the section is Experience. Adding a kind is adding a method, and every ModularCvTemplate has to implement it. --- CHANGELOG.md | 41 ++-- docs/templates/v2-layered/using-templates.md | 22 ++- .../cv/api/ModularCvTemplateFidelityTest.java | 19 +- .../templates/cv/api/CvConstructor.java | 132 +++++++++++++ .../templates/cv/api/ModularCvTemplate.java | 34 ++-- .../templates/cv/components/CvRenderKit.java | 28 +-- .../cv/components/ModuleRenderer.java | 181 ++++++++++++++++-- .../cv/components/SectionDispatcher.java | 35 +++- .../templates/cv/presets/BlueBanner.java | 45 +++-- .../templates/cv/presets/BoxedSections.java | 37 +++- .../cv/presets/CenteredHeadline.java | 35 +++- .../templates/cv/presets/EditorialBlue.java | 52 +++-- .../templates/cv/presets/Executive.java | 35 +++- .../cv/presets/MinimalUnderlined.java | 37 +++- .../templates/cv/presets/MintEditorial.java | 45 +++-- .../cv/presets/ModernProfessional.java | 37 +++- .../templates/cv/presets/MonogramSidebar.java | 43 +++-- .../templates/cv/presets/SidebarPortrait.java | 45 +++-- .../cv/api/CvConstructorKindGateTest.java | 72 +++++++ 19 files changed, 793 insertions(+), 182 deletions(-) create mode 100644 templates/src/main/java/com/demcha/compose/document/templates/cv/api/CvConstructor.java create mode 100644 templates/src/test/java/com/demcha/compose/document/templates/cv/api/CvConstructorKindGateTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 89abc2707..435a40a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -307,11 +307,13 @@ follow semantic versioning; release dates are ISO 8601. is now headed by its own name, which is both what the contract asks and easier to read; a section that was never grouped carries a blank category and gets no label. - `kit()` returns the canonical kit for all three, and says why: a kit draws one body - style and these presets have two — the sidebar's and the main column's — with nothing - in `CvRenderKit` to say which column it is drawing into. Nothing in these templates - routes a body through `SectionDispatcher`, so the kit is what a caller outside them - would draw with, and canonical is the honest answer there. + Their `CvConstructor` methods forward to the canonical `ModuleRenderer` for the + same reason: a kit (and now a constructor method) draws one body style, and these + presets have two — the sidebar's and the main column's. Claimed modules still + lower through `SectionRouter` onto the slot's own renderer, which is why they + already come out in the preset's style. The constructor methods are the contract + a leftover or unclaimed module will use, and the canonical look is the honest + answer for a caller that is not sitting in a slot. `ModularCvTemplateFidelityTest` grew two corrections in the process. Its item assertions read raw composed text while its heading assertion dropped spacing, so a @@ -321,14 +323,27 @@ follow semantic versioning; release dates are ISO 8601. rendering style rather than a promise: it asserts every skill and the group's name reach the page, and leaves the join to the preset drawing bars, chips, or a list. -- **A preset can draw runtime modules in its own style.** `CvRenderKit` is the three - shapes a section body reduces to — a paragraph, a label/value row, a timeline entry — - and a template hands back the kit it draws them with. The lowering from `CvItem` - stays shared, because deciding what a linked title looks like or which fields a kind - reads belongs to the model and must not be re-decided per preset; only the drawing is - the preset's. `BlueBanner`, `ClassicSerif`, and `EditorialBlue` now render modules - with their own entry and project shapes rather than the canonical ones — the - limitation the entry above left open. +- **The constructor contract is the module shapes, not CV meanings.** A template + that can be handed a runtime CV implements `CvConstructor`: one method per + `CvKind` (`paragraph`, `bullets`, `bulletsStacked`, `inlineList`, `entries`, + `entriesDated`) and no defaults. JSON (or any mapper) picks the kind; the + template draws the kind. It does not know whether the section is Experience or + a heading nobody anticipated — that knowledge is not in the contract. Adding a + kind is adding a method, and every `ModularCvTemplate` fails to compile until + it implements it. `CvConstructorKindGateTest` holds the bijection and that + every modular template declares the methods rather than inheriting a default. + + The shared `ModuleRenderer` still owns which fields a kind reads, so a + template that wants the canonical look of a kind forwards to it. A template + that already had its own entry or project drawing implements the kind + methods onto that drawing (`BlueBanner`, `EditorialBlue`). `CvRenderKit` + remains the optional primitive hook underneath a kind method, not the + template contract: `ModularCvTemplate` no longer has `kit()`. + +- **A preset can draw runtime modules in its own style.** `BlueBanner` and + `EditorialBlue` implement the kind methods through their own entry and + project shapes rather than the canonical ones. `ClassicSerif` is not on + `ModularCvTemplate` yet and still restyles through a private `CvRenderKit`. ### Fixed diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index 638fecd6c..412edc6ca 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -285,14 +285,20 @@ the section: Sidebar Portrait's language list, which picks the language rows out of an "Additional Information" section, and Mint Editorial's skill-bar block, whose group index above it already carries the author's heading. -A template also says *how* it draws through `CvRenderKit`. The shared -lowering turns a module into paragraphs, rows, and entries; the kit draws -them, so a preset with its own entry style renders your runtime module in -that style rather than the canonical one. Presets whose bodies already use -the shared components return `CvRenderKit.defaults()` — and so do the three -column-flow presets, which never route a body through the kit at all: each -lowers a module to the shape its slot draws and lets that slot's own renderer -draw it, which is why a runtime module already comes out in their style. +A template also says *how* it draws through `CvConstructor`: one method per +`CvKind` (`paragraph`, `bullets`, `bulletsStacked`, `inlineList`, `entries`, +`entriesDated`), no defaults. JSON picks the kind; the template implements +the kind. It does not know whether the section is Experience or a heading +the author invented. Adding a kind is adding a method, and every modular +template has to draw it. + +The shared `ModuleRenderer` is what a template forwards to when it wants +the canonical look of that kind. A preset with its own entry style +implements the kind methods itself (or forwards through a `CvRenderKit` of +primitives) so a runtime module takes that style rather than the canonical +one. The three column-flow presets still lower a claimed module to the +shape their slot already draws; their constructor methods are the same +contract, ready for a leftover or unclaimed module that has no slot. --- diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java index 6ad66b8f5..a7c988849 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java @@ -1,6 +1,8 @@ package com.demcha.compose.document.templates.cv.api; +import com.demcha.compose.document.dsl.SectionBuilder; import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.core.theme.BrandTheme; import com.demcha.compose.document.templates.cv.CvComposedText; import com.demcha.compose.document.templates.cv.data.CvDocument; import com.demcha.compose.document.templates.cv.data.CvIdentity; @@ -164,10 +166,19 @@ void aSidebarSectionIsNotRenderedAndTheContractSaysSo(ModularCvTemplate template @ParameterizedTest(name = "{0}") @MethodSource("modularTemplates") - void everyModularTemplateDeclaresAKit(ModularCvTemplate template) { - assertThat(template.kit()) - .as("%s must hand back a kit — the drawing half of the promise", template.id()) - .isNotNull(); + void everyModularTemplateImplementsEveryKind(ModularCvTemplate template) + throws NoSuchMethodException { + Class type = template.getClass(); + for (CvKind kind : CvKind.values()) { + assertThat(type.getMethod( + CvConstructor.methodName(kind), + SectionBuilder.class, + ModuleSection.class, + BrandTheme.class) + .getDeclaringClass()) + .as("%s must implement %s", template.id(), kind) + .isNotEqualTo(CvConstructor.class); + } } @Test diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/api/CvConstructor.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/api/CvConstructor.java new file mode 100644 index 000000000..bc4b1bbb4 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/api/CvConstructor.java @@ -0,0 +1,132 @@ +package com.demcha.compose.document.templates.cv.api; + +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.templates.core.theme.BrandTheme; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.ModuleSection; + +import java.util.Objects; + +/** + * The constructor contract: one method per module shape, not per + * CV meaning. + * + *

A template that implements this does not know whether a section is + * Experience, Projects, or a heading the author invented. It knows how + * to draw prose, a bullet list, an inline list, and a timeline — with + * and without dates. JSON (or any runtime mapper) picks the kind; the + * template implements the kind.

+ * + *

There are no defaults on the kind methods. Adding a {@link CvKind} + * constant adds a method here, and every {@link ModularCvTemplate} + * fails to compile until it draws the new shape. That is the point: a + * constructor surface that grows in one place and pulls every template + * with it, rather than a shared renderer that can absorb a new kind + * without the templates noticing.

+ * + *

{@link #render(SectionBuilder, ModuleSection, BrandTheme)} is the + * dispatcher, and it is a default because it is not a shape. Its + * {@code switch} is exhaustive over {@link CvKind}, so a new constant + * without a method is a compile error here too.

+ * + * @since 2.3.0 + */ +public interface CvConstructor { + + /** + * The method on this interface that draws {@code kind}. + * + *

Kept next to the methods themselves so a test can prove the + * bijection without copying the names.

+ * + * @param kind a module shape + * @return the method name, such as {@code "entriesDated"} + */ + static String methodName(CvKind kind) { + Objects.requireNonNull(kind, "kind"); + return switch (kind) { + case PARAGRAPH -> "paragraph"; + case BULLETS -> "bullets"; + case BULLETS_STACKED -> "bulletsStacked"; + case INLINE_LIST -> "inlineList"; + case ENTRIES -> "entries"; + case ENTRIES_DATED -> "entriesDated"; + }; + } + + /** + * Dispatches {@code module} to the kind method the author picked. + * + * @param host host section receiving the body + * @param module the module; its {@link ModuleSection#kind() kind} selects + * the method + * @param theme the active theme + */ + default void render(SectionBuilder host, ModuleSection module, BrandTheme theme) { + Objects.requireNonNull(host, "host"); + Objects.requireNonNull(module, "module"); + Objects.requireNonNull(theme, "theme"); + switch (module.kind()) { + case PARAGRAPH -> paragraph(host, module, theme); + case BULLETS -> bullets(host, module, theme); + case BULLETS_STACKED -> bulletsStacked(host, module, theme); + case INLINE_LIST -> inlineList(host, module, theme); + case ENTRIES -> entries(host, module, theme); + case ENTRIES_DATED -> entriesDated(host, module, theme); + } + } + + /** + * Prose under the section heading. Reads each item's body only. + * + * @param host host section receiving the body + * @param module a {@link CvKind#PARAGRAPH} module + * @param theme the active theme + */ + void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme); + + /** + * A bullet per item, description on the same line. + * + * @param host host section receiving the body + * @param module a {@link CvKind#BULLETS} module + * @param theme the active theme + */ + void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme); + + /** + * A bullet per item, description stacked underneath. + * + * @param host host section receiving the body + * @param module a {@link CvKind#BULLETS_STACKED} module + * @param theme the active theme + */ + void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme); + + /** + * One line per item, the description collapsed after a bold label. + * + * @param host host section receiving the body + * @param module a {@link CvKind#INLINE_LIST} module + * @param theme the active theme + */ + void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme); + + /** + * Timeline entries without the date column. + * + * @param host host section receiving the body + * @param module a {@link CvKind#ENTRIES} module + * @param theme the active theme + */ + void entries(SectionBuilder host, ModuleSection module, BrandTheme theme); + + /** + * Timeline entries with the date column. + * + * @param host host section receiving the body + * @param module a {@link CvKind#ENTRIES_DATED} module + * @param theme the active theme + */ + void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme); +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java index 0d384a93b..a76428364 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java @@ -1,17 +1,23 @@ package com.demcha.compose.document.templates.cv.api; import com.demcha.compose.document.templates.api.DocumentTemplate; -import com.demcha.compose.document.templates.cv.components.CvRenderKit; import com.demcha.compose.document.templates.cv.data.CvDocument; import com.demcha.compose.document.templates.cv.data.CvKind; -import com.demcha.compose.document.templates.cv.data.ModuleSection; import com.demcha.compose.document.templates.cv.data.Slot; /** - * A CV template that renders every section placed in {@link Slot#MAIN} — - * every {@link CvKind}, under whatever heading the author wrote. + * A CV template that implements every constructor shape and renders every + * section placed in {@link Slot#MAIN} — every {@link CvKind}, under + * whatever heading the author wrote. * - *

The promise is exactly that, and the slot is part of it. + *

The constructor half is {@link CvConstructor}: one method per module + * shape, no defaults. A JSON mapper (or any runtime assembler) picks a + * kind; this template draws that kind. It does not know whether the + * section is Experience or a heading nobody anticipated — that knowledge + * is not in the contract.

+ * + *

The placement promise is exactly {@link Slot#MAIN}, and the + * slot is part of it. * Every shipped preset reads {@code sectionsIn(Slot.MAIN)} and no other * slot — including the ones that compose a sidebar of their own, which fill * it from the identity and from the main-slot sections their routing sends @@ -40,22 +46,10 @@ * last because a preset with an editorial vocabulary of its own is the one * likely to rename what the author wrote. Each item must reach the page, so * the interface cannot be worn by a template that would drop or retitle - * one.

- * - *

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

+ * one. {@code CvConstructorKindGateTest} holds the other half: every kind + * has a non-default method, and every modular template declares it.

* * @since 2.3.0 */ -public interface ModularCvTemplate extends DocumentTemplate { - - /** - * How this template draws the shapes a module lowers to. - * - * @return this template's kit; {@link CvRenderKit#defaults()} for a - * template whose modules look like the canonical components - */ - CvRenderKit kit(); +public interface ModularCvTemplate extends DocumentTemplate, CvConstructor { } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java index 5391265ef..50aa0c4c6 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/CvRenderKit.java @@ -3,33 +3,21 @@ import com.demcha.compose.document.dsl.SectionBuilder; import com.demcha.compose.document.templates.core.theme.BrandTheme; import com.demcha.compose.document.templates.cv.data.CvEntry; -import com.demcha.compose.document.templates.cv.data.CvItem; import com.demcha.compose.document.templates.cv.data.CvKind; import com.demcha.compose.document.templates.cv.data.CvRow; import com.demcha.compose.document.templates.cv.data.RowStyle; /** - * How one template draws the three shapes a CV section body reduces to: + * Primitive drawing a {@link ModuleRenderer} kind method can restyle: * a paragraph of prose, a label/value row, a timeline entry. * - *

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

- * - *

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

+ *

This is not the template contract. A modular template implements + * {@link com.demcha.compose.document.templates.cv.api.CvConstructor} — + * one method per {@link CvKind}. The kit is the optional hook underneath + * a kind method that wants its own entry or row look without re-deciding + * which fields the kind reads. {@link #defaults()} is the canonical + * drawing; every method has a default, so a preset overrides only the + * primitives it actually styles differently.

* *

Implementations draw into the host and return; they do not set the * host's spacing or padding, which the caller has already settled, and diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java index 9d2a52726..c884f12a4 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java @@ -52,10 +52,11 @@ public static void render(SectionBuilder host, ModuleSection module, BrandTheme * Renders every item of {@code module} into {@code host}, drawing * through {@code kit}. * - *

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

+ *

Prefers the kind methods below. A template that implements + * {@code CvConstructor} should call those (or {@code render} on + * itself) rather than this overload: this one exists so a kit can + * restyle the three drawing primitives without re-deciding which + * fields a kind reads.

* * @param host host section receiving the body * @param module the module supplying items, kind, and role @@ -64,17 +65,173 @@ public static void render(SectionBuilder host, ModuleSection module, BrandTheme */ public static void render(SectionBuilder host, ModuleSection module, BrandTheme theme, CvRenderKit kit) { + switch (module.kind()) { + case PARAGRAPH -> paragraph(host, module, theme, kit); + case BULLETS -> bullets(host, module, theme, kit); + case BULLETS_STACKED -> bulletsStacked(host, module, theme, kit); + case INLINE_LIST -> inlineList(host, module, theme, kit); + case ENTRIES -> entries(host, module, theme, kit); + case ENTRIES_DATED -> entriesDated(host, module, theme, kit); + } + } + + /** + * Canonical {@link com.demcha.compose.document.templates.cv.data.CvKind#PARAGRAPH}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + */ + public static void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + paragraph(host, module, theme, CvRenderKit.defaults()); + } + + /** + * {@link com.demcha.compose.document.templates.cv.data.CvKind#PARAGRAPH} drawn through {@code kit}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + * @param kit drawing primitives + */ + public static void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme, + CvRenderKit kit) { + for (CvItem item : module.items()) { + paragraph(host, item, theme, kit); + } + } + + /** + * Canonical {@link com.demcha.compose.document.templates.cv.data.CvKind#BULLETS}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + */ + public static void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + bullets(host, module, theme, CvRenderKit.defaults()); + } + + /** + * {@link com.demcha.compose.document.templates.cv.data.CvKind#BULLETS} drawn through {@code kit}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + * @param kit drawing primitives + */ + public static void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme, + CvRenderKit kit) { + for (CvItem item : module.items()) { + bullet(host, item, theme, kit); + } + } + + /** + * Canonical {@link com.demcha.compose.document.templates.cv.data.CvKind#BULLETS_STACKED}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + */ + public static void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + bulletsStacked(host, module, theme, CvRenderKit.defaults()); + } + + /** + * {@link com.demcha.compose.document.templates.cv.data.CvKind#BULLETS_STACKED} drawn through {@code kit}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + * @param kit drawing primitives + */ + public static void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme, + CvRenderKit kit) { + List items = module.items(); + for (int i = 0; i < items.size(); i++) { + stackedBullet(host, items.get(i), theme, kit, i > 0); + } + } + + /** + * Canonical {@link com.demcha.compose.document.templates.cv.data.CvKind#INLINE_LIST}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + */ + public static void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + inlineList(host, module, theme, CvRenderKit.defaults()); + } + + /** + * {@link com.demcha.compose.document.templates.cv.data.CvKind#INLINE_LIST} drawn through {@code kit}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + * @param kit drawing primitives + */ + public static void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme, + CvRenderKit kit) { + for (CvItem item : module.items()) { + inlineList(host, item, theme, kit); + } + } + + /** + * Canonical {@link com.demcha.compose.document.templates.cv.data.CvKind#ENTRIES}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + */ + public static void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + entries(host, module, theme, CvRenderKit.defaults()); + } + + /** + * {@link com.demcha.compose.document.templates.cv.data.CvKind#ENTRIES} drawn through {@code kit}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + * @param kit drawing primitives + */ + public static void entries(SectionBuilder host, ModuleSection module, BrandTheme theme, + CvRenderKit kit) { + List items = module.items(); + for (int i = 0; i < items.size(); i++) { + entry(host, items.get(i), "", theme, kit, i > 0); + } + } + + /** + * Canonical {@link com.demcha.compose.document.templates.cv.data.CvKind#ENTRIES_DATED}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + */ + public static void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + entriesDated(host, module, theme, CvRenderKit.defaults()); + } + + /** + * {@link com.demcha.compose.document.templates.cv.data.CvKind#ENTRIES_DATED} drawn through {@code kit}. + * + * @param host host section receiving the body + * @param module the module + * @param theme the active theme + * @param kit drawing primitives + */ + public static void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme, + CvRenderKit kit) { List items = module.items(); for (int i = 0; i < items.size(); i++) { CvItem item = items.get(i); - switch (module.kind()) { - case PARAGRAPH -> paragraph(host, item, theme, kit); - case BULLETS -> bullet(host, item, theme, kit); - case BULLETS_STACKED -> stackedBullet(host, item, theme, kit, i > 0); - case INLINE_LIST -> inlineList(host, item, theme, kit); - case ENTRIES -> entry(host, item, "", theme, kit, i > 0); - case ENTRIES_DATED -> entry(host, item, item.period(), theme, kit, i > 0); - } + entry(host, item, item.period(), theme, kit, i > 0); } } 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 b450b8e67..36bd22b85 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 @@ -1,6 +1,7 @@ package com.demcha.compose.document.templates.cv.components; import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.templates.cv.api.CvConstructor; import com.demcha.compose.document.templates.cv.data.*; import com.demcha.compose.document.templates.core.theme.BrandTheme; @@ -37,13 +38,41 @@ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme renderBody(host, section, theme, CvRenderKit.defaults()); } + /** + * Renders the section body, sending a runtime module through + * {@code constructor} so each kind lands on the method the template + * implemented for it. + * + *

Typed sections still take the canonical path: the constructor + * is the module contract, not a second dispatcher for + * {@code EntriesSection}.

+ * + * @param host host section receiving the body + * @param section the section whose subtype selects the renderer + * @param theme the active theme supplying palette, typography, and spacing + * @param constructor the template's kind methods + * @throws IllegalStateException if the section subtype is unhandled + * @since 2.3.0 + */ + public static void renderBody(SectionBuilder host, CvSection section, BrandTheme theme, + CvConstructor constructor) { + if (section instanceof ModuleSection module) { + host.spacing(theme.spacing().sectionBodySpacing()) + .padding(theme.spacing().sectionBodyPadding()); + constructor.render(host, module, theme); + return; + } + renderBody(host, section, theme); + } + /** * Renders the section body, drawing through {@code kit}. * *

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

+ * differs. Prefer the {@link CvConstructor} overload for a modular + * template: that is the kind contract. This overload remains for a + * preset that is not yet on that contract and restyles the three + * drawing primitives.

* * @param host host section receiving the body * @param section the section whose subtype selects the renderer 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 69e7e90c5..d22c5d1d9 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java @@ -10,6 +10,7 @@ import com.demcha.compose.document.style.DocumentTextDecoration; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.api.CvConstructor; import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; import com.demcha.compose.document.templates.cv.components.*; import com.demcha.compose.document.templates.cv.data.*; @@ -107,8 +108,33 @@ public String displayName() { } @Override - public CvRenderKit kit() { - return KIT; + public void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.paragraph(host, module, theme, KIT); + } + + @Override + public void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bullets(host, module, theme, KIT); + } + + @Override + public void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bulletsStacked(host, module, theme, KIT); + } + + @Override + public void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.inlineList(host, module, theme, KIT); + } + + @Override + public void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entries(host, module, theme, KIT); + } + + @Override + public void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entriesDated(host, module, theme, KIT); } @Override @@ -143,7 +169,7 @@ public void compose(DocumentSession document, CvDocument doc) { new DocumentInsets(1, BANNER_RULE_HORIZONTAL_INSET, 1, BANNER_RULE_HORIZONTAL_INSET)); pageFlow.addSection("BlueBannerBody_" + idx, host -> - renderBody(host, sec, theme)); + renderBody(host, sec, theme, this)); } pageFlow.build(); @@ -152,7 +178,8 @@ public void compose(DocumentSession document, CvDocument doc) { private static void renderBody(SectionBuilder host, CvSection section, - BrandTheme theme) { + BrandTheme theme, + CvConstructor constructor) { host.spacing(theme.spacing().sectionBodySpacing()) .padding(theme.spacing().sectionBodyPadding()); @@ -166,14 +193,10 @@ private static void renderBody(SectionBuilder host, for (CvEntry entry : e.entries()) { renderEntry(host, entry, theme); } + } else if (section instanceof ModuleSection module) { + constructor.render(host, module, theme); } else { - // 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, KIT); + SectionDispatcher.renderBody(host, section, theme); } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java index 269ac35c7..ac728cbaf 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java @@ -2,12 +2,14 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; import com.demcha.compose.document.templates.api.DocumentTemplate; import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; -import com.demcha.compose.document.templates.cv.components.CvRenderKit; +import com.demcha.compose.document.templates.cv.components.ModuleRenderer; import com.demcha.compose.document.templates.cv.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.data.CvDocument; import com.demcha.compose.document.templates.cv.data.CvSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; import com.demcha.compose.document.templates.cv.data.Slot; import com.demcha.compose.document.templates.core.theme.BrandTheme; import com.demcha.compose.document.templates.core.identity.ContactLine; @@ -98,10 +100,33 @@ public String displayName() { } @Override - public CvRenderKit kit() { - // This preset renders bodies through the shared dispatcher, so a - // runtime module already looks like the rest of its document. - return CvRenderKit.defaults(); + public void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.paragraph(host, module, theme); + } + + @Override + public void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bullets(host, module, theme); + } + + @Override + public void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bulletsStacked(host, module, theme); + } + + @Override + public void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.inlineList(host, module, theme); + } + + @Override + public void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entries(host, module, theme); + } + + @Override + public void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entriesDated(host, module, theme); } @Override @@ -137,7 +162,7 @@ public void compose(DocumentSession document, CvDocument doc) { SectionHeader.banner(host, sec.title(), theme); }); pageFlow.addSection("CvV2Body_" + idx, - host -> SectionDispatcher.renderBody(host, sec, theme, kit())); + host -> SectionDispatcher.renderBody(host, sec, theme, this)); } pageFlow.build(); diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java index bb501bd66..f7d09d33a 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java @@ -10,7 +10,7 @@ import com.demcha.compose.document.templates.api.DocumentTemplate; import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; import com.demcha.compose.document.templates.cv.components.ProjectRenderer; -import com.demcha.compose.document.templates.cv.components.CvRenderKit; +import com.demcha.compose.document.templates.cv.components.ModuleRenderer; import com.demcha.compose.document.templates.cv.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.data.*; import com.demcha.compose.document.templates.core.theme.BrandTheme; @@ -124,10 +124,33 @@ public String displayName() { } @Override - public CvRenderKit kit() { - // This preset renders bodies through the shared dispatcher, so a - // runtime module already looks like the rest of its document. - return CvRenderKit.defaults(); + public void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.paragraph(host, module, theme); + } + + @Override + public void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bullets(host, module, theme); + } + + @Override + public void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bulletsStacked(host, module, theme); + } + + @Override + public void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.inlineList(host, module, theme); + } + + @Override + public void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entries(host, module, theme); + } + + @Override + public void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entriesDated(host, module, theme); } @Override @@ -193,7 +216,7 @@ private void renderBody(SectionBuilder host, CvSection sec) { } return; } - SectionDispatcher.renderBody(host, sec, theme, kit()); + SectionDispatcher.renderBody(host, sec, theme, this); } private void renderStackedProject(SectionBuilder host, CvRow row) { 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 c8cb9efe9..ad7935ead 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 @@ -153,7 +153,7 @@ private void renderSectionBody(SectionBuilder section, CvSection cvSection, // would render as nothing at all: an empty heading over blank // space, which reads as a finished CV that quietly lost a // section. - SectionDispatcher.renderBody(section, cvSection, theme, kit()); + SectionDispatcher.renderBody(section, cvSection, theme, this); } } @@ -174,18 +174,48 @@ private void renderEntries(SectionBuilder section, EntriesSection entries) { } /** - * This preset's own drawing, so a runtime module gets the - * editorial entry, project, and key/value shapes. - * - *

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

+ * Constructor kinds. Each one lowers through {@link ModuleRenderer} + * onto this preset's editorial drawing, so a runtime module is a + * shape, not a CV meaning — {@code ENTRIES_DATED} is the + * experience-styled timeline whether the heading says + * Experience or something else. */ @Override - public CvRenderKit kit() { + public void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.paragraph(host, module, theme, drawing()); + } + + @Override + public void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bullets(host, module, theme, drawing()); + } + + @Override + public void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bulletsStacked(host, module, theme, drawing()); + } + + @Override + public void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.inlineList(host, module, theme, drawing()); + } + + @Override + public void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entries(host, module, theme, drawing()); + } + + @Override + public void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entriesDated(host, module, theme, drawing()); + } + + /** + * Primitive drawing this preset already had for entries, projects, + * and key/value rows. Kind methods lower through {@link ModuleRenderer} + * onto these, so a runtime module takes the editorial shapes. + */ + private CvRenderKit drawing() { return new CvRenderKit() { @Override diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java index 7842cdabf..fe644dc56 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java @@ -14,7 +14,7 @@ import com.demcha.compose.document.templates.api.DocumentTemplate; import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; import com.demcha.compose.document.templates.core.text.TextStyles; -import com.demcha.compose.document.templates.cv.components.CvRenderKit; +import com.demcha.compose.document.templates.cv.components.ModuleRenderer; import com.demcha.compose.document.templates.cv.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.data.*; import com.demcha.compose.document.templates.core.theme.BrandTheme; @@ -111,10 +111,33 @@ public String displayName() { } @Override - public CvRenderKit kit() { - // This preset renders bodies through the shared dispatcher, so a - // runtime module already looks like the rest of its document. - return CvRenderKit.defaults(); + public void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.paragraph(host, module, theme); + } + + @Override + public void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bullets(host, module, theme); + } + + @Override + public void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bulletsStacked(host, module, theme); + } + + @Override + public void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.inlineList(host, module, theme); + } + + @Override + public void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entries(host, module, theme); + } + + @Override + public void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entriesDated(host, module, theme); } @Override @@ -141,7 +164,7 @@ public void compose(DocumentSession document, CvDocument doc) { ACCENT, theme); }); flow.addSection("CvV2ExecutiveBody_" + idx, host -> - SectionDispatcher.renderBody(host, sec, theme, kit())); + SectionDispatcher.renderBody(host, sec, theme, this)); } flow.build(); diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java index 720fa0820..ffa0502b7 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java @@ -2,12 +2,14 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; import com.demcha.compose.document.templates.api.DocumentTemplate; import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; -import com.demcha.compose.document.templates.cv.components.CvRenderKit; +import com.demcha.compose.document.templates.cv.components.ModuleRenderer; import com.demcha.compose.document.templates.cv.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.data.CvDocument; import com.demcha.compose.document.templates.cv.data.CvSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; import com.demcha.compose.document.templates.cv.data.Slot; import com.demcha.compose.document.templates.core.theme.BrandTheme; import com.demcha.compose.document.templates.core.identity.ContactLine; @@ -98,10 +100,33 @@ public String displayName() { } @Override - public CvRenderKit kit() { - // This preset renders bodies through the shared dispatcher, so a - // runtime module already looks like the rest of its document. - return CvRenderKit.defaults(); + public void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.paragraph(host, module, theme); + } + + @Override + public void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bullets(host, module, theme); + } + + @Override + public void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bulletsStacked(host, module, theme); + } + + @Override + public void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.inlineList(host, module, theme); + } + + @Override + public void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entries(host, module, theme); + } + + @Override + public void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entriesDated(host, module, theme); } @Override @@ -132,7 +157,7 @@ public void compose(DocumentSession document, CvDocument doc) { SectionHeader.underlined(host, sec.title(), theme); }); pageFlow.addSection("Body_" + idx, host -> - SectionDispatcher.renderBody(host, sec, theme, kit())); + SectionDispatcher.renderBody(host, sec, theme, this)); } pageFlow.build(); diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java index 8f52e5053..3bc8d2b6d 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java @@ -19,7 +19,7 @@ import com.demcha.compose.document.templates.core.text.TextStyles; import com.demcha.compose.document.templates.core.text.MarkdownInline; import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; -import com.demcha.compose.document.templates.cv.components.CvRenderKit; +import com.demcha.compose.document.templates.cv.components.ModuleRenderer; import com.demcha.compose.document.templates.cv.components.SectionAllocation; import com.demcha.compose.document.templates.cv.components.SectionRouter; import com.demcha.compose.document.templates.cv.components.SectionLookup; @@ -449,23 +449,34 @@ public String displayName() { return DISPLAY_NAME; } - /** - * The canonical kit, and this template never consults it. - * - *

A kit is how a preset styles the bodies it routes through - * {@code SectionDispatcher}. This one routes none: a module is lowered - * by {@code SectionRouter} to the shape its slot draws, and the slot's - * own renderer draws it — which is why a runtime module already comes - * out in this preset's style rather than the canonical one. The kit is - * the value an outside caller would draw with, and the canonical - * shapes are the honest answer for a caller this template knows - * nothing about.

- * - * @return the canonical kit - */ @Override - public CvRenderKit kit() { - return CvRenderKit.defaults(); + public void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.paragraph(host, module, theme); + } + + @Override + public void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bullets(host, module, theme); + } + + @Override + public void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bulletsStacked(host, module, theme); + } + + @Override + public void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.inlineList(host, module, theme); + } + + @Override + public void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entries(host, module, theme); + } + + @Override + public void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entriesDated(host, module, theme); } @Override diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java index 4c2a81191..3b8c70e2f 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java @@ -2,15 +2,17 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentTextDecoration; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.templates.api.DocumentTemplate; import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; -import com.demcha.compose.document.templates.cv.components.CvRenderKit; +import com.demcha.compose.document.templates.cv.components.ModuleRenderer; import com.demcha.compose.document.templates.cv.components.SectionDispatcher; import com.demcha.compose.document.templates.cv.data.CvDocument; import com.demcha.compose.document.templates.cv.data.CvSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; import com.demcha.compose.document.templates.cv.data.Slot; import com.demcha.compose.document.templates.core.theme.BrandTheme; import com.demcha.compose.document.templates.core.identity.ContactLine; @@ -124,10 +126,33 @@ public String displayName() { } @Override - public CvRenderKit kit() { - // This preset renders bodies through the shared dispatcher, so a - // runtime module already looks like the rest of its document. - return CvRenderKit.defaults(); + public void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.paragraph(host, module, theme); + } + + @Override + public void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bullets(host, module, theme); + } + + @Override + public void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bulletsStacked(host, module, theme); + } + + @Override + public void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.inlineList(host, module, theme); + } + + @Override + public void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entries(host, module, theme); + } + + @Override + public void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entriesDated(host, module, theme); } @Override @@ -187,7 +212,7 @@ public void compose(DocumentSession document, CvDocument doc) { SectionHeader.flat(host, sec.title(), SECTION_TITLE_COLOR, theme); }); pageFlow.addSection("Body_" + idx, host -> - SectionDispatcher.renderBody(host, sec, theme, kit())); + SectionDispatcher.renderBody(host, sec, theme, this)); } pageFlow.build(); diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java index 83de1908f..afac45609 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java @@ -336,23 +336,34 @@ public String displayName() { return DISPLAY_NAME; } - /** - * The canonical kit, and this template never consults it. - * - *

A kit is how a preset styles the bodies it routes through - * {@code SectionDispatcher}. This one routes none: a module is lowered - * by {@code SectionRouter} to the shape its slot draws, and the slot's - * own renderer draws it — which is why a runtime module already comes - * out in this preset's style rather than the canonical one. The kit is - * the value an outside caller would draw with, and the canonical - * shapes are the honest answer for a caller this template knows - * nothing about.

- * - * @return the canonical kit - */ @Override - public CvRenderKit kit() { - return CvRenderKit.defaults(); + public void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.paragraph(host, module, theme); + } + + @Override + public void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bullets(host, module, theme); + } + + @Override + public void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bulletsStacked(host, module, theme); + } + + @Override + public void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.inlineList(host, module, theme); + } + + @Override + public void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entries(host, module, theme); + } + + @Override + public void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entriesDated(host, module, theme); } @Override diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java index 6fb1910cc..33f4b8c90 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java @@ -20,7 +20,7 @@ import com.demcha.compose.document.templates.core.text.MarkdownInline; import com.demcha.compose.document.templates.cv.components.ProjectLabel; import com.demcha.compose.document.templates.cv.api.ModularCvTemplate; -import com.demcha.compose.document.templates.cv.components.CvRenderKit; +import com.demcha.compose.document.templates.cv.components.ModuleRenderer; import com.demcha.compose.document.templates.cv.components.SectionAllocation; import com.demcha.compose.document.templates.cv.components.SectionRouter; import com.demcha.compose.document.templates.cv.components.SectionLookup; @@ -383,23 +383,34 @@ public String displayName() { return DISPLAY_NAME; } - /** - * The canonical kit, and this template never consults it. - * - *

A kit is how a preset styles the bodies it routes through - * {@code SectionDispatcher}. This one routes none: a module is lowered - * by {@code SectionRouter} to the shape its slot draws, and the slot's - * own renderer draws it — which is why a runtime module already comes - * out in this preset's style rather than the canonical one. The kit is - * the value an outside caller would draw with, and the canonical - * shapes are the honest answer for a caller this template knows - * nothing about.

- * - * @return the canonical kit - */ @Override - public CvRenderKit kit() { - return CvRenderKit.defaults(); + public void paragraph(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.paragraph(host, module, theme); + } + + @Override + public void bullets(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bullets(host, module, theme); + } + + @Override + public void bulletsStacked(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.bulletsStacked(host, module, theme); + } + + @Override + public void inlineList(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.inlineList(host, module, theme); + } + + @Override + public void entries(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entries(host, module, theme); + } + + @Override + public void entriesDated(SectionBuilder host, ModuleSection module, BrandTheme theme) { + ModuleRenderer.entriesDated(host, module, theme); } @Override diff --git a/templates/src/test/java/com/demcha/compose/document/templates/cv/api/CvConstructorKindGateTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/api/CvConstructorKindGateTest.java new file mode 100644 index 000000000..9994bd123 --- /dev/null +++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/api/CvConstructorKindGateTest.java @@ -0,0 +1,72 @@ +package com.demcha.compose.document.templates.cv.api; + +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.templates.core.theme.BrandTheme; +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.presets.CvTemplates; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The constructor contract is the kinds: adding a {@link CvKind} adds a + * method, and every modular template has to declare it. A default on the + * interface would let a template absorb a new shape without noticing. + */ +class CvConstructorKindGateTest { + + @ParameterizedTest + @EnumSource(CvKind.class) + void everyKindHasANonDefaultConstructorMethod(CvKind kind) throws NoSuchMethodException { + Method method = CvConstructor.class.getMethod( + CvConstructor.methodName(kind), + SectionBuilder.class, ModuleSection.class, BrandTheme.class); + assertThat(method.isDefault()) + .as("%s must not have a default — a new kind has to break every template", + method.getName()) + .isFalse(); + assertThat(Modifier.isAbstract(method.getModifiers())) + .as("%s is the template's to implement", method.getName()) + .isTrue(); + } + + @Test + void theInterfaceHasNoSpareKindMethods() { + Set expected = Arrays.stream(CvKind.values()) + .map(CvConstructor::methodName) + .collect(Collectors.toSet()); + Set declared = Arrays.stream(CvConstructor.class.getDeclaredMethods()) + .filter(method -> !method.isDefault() && !method.isSynthetic()) + .filter(method -> method.getParameterCount() == 3) + .map(Method::getName) + .collect(Collectors.toSet()); + assertThat(declared).containsExactlyInAnyOrderElementsOf(expected); + } + + @Test + void everyModularTemplateDeclaresEveryKind() throws NoSuchMethodException { + assertThat(CvTemplates.modular()).isNotEmpty(); + for (ModularCvTemplate template : CvTemplates.modular()) { + Class type = template.getClass(); + for (CvKind kind : CvKind.values()) { + Method method = type.getMethod( + CvConstructor.methodName(kind), + SectionBuilder.class, ModuleSection.class, BrandTheme.class); + assertThat(method.getDeclaringClass()) + .as("%s must implement %s rather than inherit a default", + template.id(), kind) + .isNotEqualTo(CvConstructor.class) + .isNotEqualTo(ModularCvTemplate.class); + } + } + } +} From 1ab2a6fdf5d680ff70979675ee680714b1dee8ce Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 22 Aug 2026 21:43:27 +0100 Subject: [PATCH 2/4] feat(templates): stop claiming runtime modules as CV meanings A ModuleSection is a shape. SectionRouter.find and SectionAllocation.claim no longer take one: slots still match typed sections by heading, and modules stay in document order or the leftover tail and draw through CvConstructor. --- CHANGELOG.md | 36 +++---- .../cv/components/RoleRoutingTest.java | 67 ++++++------- .../cv/components/SectionAllocationTest.java | 31 +++---- .../cv/presets/SlotHeadingFidelityTest.java | 25 ++--- .../cv/components/SectionAllocation.java | 48 ++-------- .../cv/components/SectionRouter.java | 93 +++++++------------ .../cv/components/SectionRouterTest.java | 36 +++---- 7 files changed, 126 insertions(+), 210 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 435a40a2e..121319a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -270,27 +270,21 @@ follow semantic versioning; release dates are ISO 8601. every continuation page of a dense CV, takes the rule back off to attribute the number to it, and pins page 1's own 78pt opening either way. -- **Presets route by what a section means, not by the language it is written in.** A - preset with a designed layout places sections into fixed slots, and it chose what went - where by matching the heading against a list of English words each preset kept - privately — then guarded the slot on the section's Java type as well. A CV headed - `Ausbildung`, `Опыт работы`, or anything else in the author's own language matched - nothing: the section was dropped and the slot that wanted it rendered empty. Nothing - failed; the CV came out looking finished, one job short. - - `SectionRouter` asks the module's `SectionRole` first and falls back to the headings - for the sections that carry no role — every hand-written one, and any module left as - `OTHER` — so a document of hand-written sections routes exactly as it did. A heading - may not overrule a role: a module declared `EXPERIENCE` and headed "Projects" goes where its - author put it, and the projects slot does not also claim it, which would have rendered - it twice. The router also hands each slot the section in the shape that slot draws, so - a module reaching a slot written against `EntriesSection` is no longer discarded by - the guard — the preset draws it exactly as it draws everything else, with the entry - style, rules and spacing that make it that preset. `SectionAllocation.claim` gained - the same role-first overload for the preset that allocates rather than looks up. - - Nine presets and every slot they compose changed; a CV written in Russian and German - now renders on all sixteen, which `RoleRoutingTest` holds by rendering one. +- **Runtime modules are not slotted by CV meaning.** `SectionRouter.find` and + `SectionAllocation.claim` never take a `ModuleSection`. A template does not + know Experience from Projects; a module is a shape, and it stays in document + order (or the leftover tail) and draws through `CvConstructor`. Typed + sections still match by heading, so a hand-written `EntriesSection` titled + "Experience" lands in the same slot as before. `RoleRoutingTest` holds the + modular templates: a CV headed `Опыт работы` still reaches the page, because + it is a module, not because a role named the slot. + +- **Typed sections still match slots by heading.** A hand-written + `EntriesSection` titled "Experience" lands where it always did. Runtime + modules do not: see the constructor-contract entry above. `SectionRouter` + still lowers a leftover module to the record a slot's renderer already + takes (`asEntries` / `asRows` / `asParagraph` / `asSkills` / + `naturalShape`), so a two-column leftover draws in the preset's own style. - **The three column-flow presets keep the modular promise too.** `SidebarPortrait`, `MonogramSidebar`, and `MintEditorial` declare `ModularCvTemplate`, so diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java index 4b47faca6..f944e6251 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java @@ -20,51 +20,42 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * A CV whose headings are in the author's own language reaches the page on - * every preset. + * A runtime module reaches the page on every modular template, whatever + * language its heading is written in — because it is a shape, not a CV + * meaning. * - *

Presets with a designed layout place sections into fixed slots, and they - * chose what goes where by matching the heading against a list of English - * words each kept privately. A CV headed {@code Berufserfahrung} or - * {@code Опыт работы} matched nothing: the section was dropped and the slot - * that wanted it rendered empty. Nothing failed — the CV came out looking - * finished, one job short.

- * - *

A module states its {@link SectionRole}, so the routing has an answer - * that does not depend on the language the CV is written in. Every heading - * here is deliberately in Russian and German: if any preset still routes by - * keyword, its slot stays empty and this goes red.

+ *

Slots that still ask for Experience or Skills only see the four + * hand-written section types. A {@link ModuleSection} is not a slot claim: + * it stays in document order (or the leftover tail) and draws through + * {@link com.demcha.compose.document.templates.cv.api.CvConstructor}. This + * suite holds that promise for headings no English keyword list contains.

*/ class RoleRoutingTest { - private static Stream>> everyPreset() { - return CvTemplates.all().stream().map(t -> Named.of(t.id(), t)); + private static Stream>> modularPresets() { + return CvTemplates.modular().stream().map(t -> Named.of(t.id(), t)); } @ParameterizedTest(name = "{0}") - @MethodSource("everyPreset") - void aCvWrittenInAnotherLanguageRendersOnEveryPreset(DocumentTemplate preset) { + @MethodSource("modularPresets") + void aCvWrittenInAnotherLanguageRendersOnEveryModularPreset( + DocumentTemplate preset) { String text = CvComposedText.squashedNodes(preset, foreignLanguageCv()); - assertRendered(text, "Ведущий инженер", preset, "experience"); - assertRendered(text, "Информатика", preset, "education"); + assertRendered(text, "Ведущий инженер", preset, "entries-dated"); + assertRendered(text, "Информатика", preset, "entries-dated"); } @ParameterizedTest(name = "{0}") - @MethodSource("everyPreset") - void aRoleRoutedModuleRendersWhateverItsKind(DocumentTemplate preset) { - // The slots were guarded on the section's Java type as well as its - // heading, so a module routed correctly was dropped anyway. Kinds here - // are deliberately the "wrong" shape for the slot each role names — - // experience as bullets, education as an inline list — because the - // author picks the kind and the preset does not get a veto. + @MethodSource("modularPresets") + void aModuleRendersWhateverItsKind(DocumentTemplate preset) { CvDocument doc = CvDocument.builder() .identity(identity()) - .section(ModuleSection.builder("Berufserfahrung", SectionRole.EXPERIENCE, + .section(ModuleSection.builder("Berufserfahrung", SectionRole.OTHER, CvKind.BULLETS) .item(CvItem.of("Senior Engineer").paragraphs("Acme GmbH, 2021-2025")) .build()) - .section(ModuleSection.builder("Kenntnisse", SectionRole.SKILLS, + .section(ModuleSection.builder("Kenntnisse", SectionRole.OTHER, CvKind.INLINE_LIST) .item(CvItem.of("Sprachen").paragraphs("Java 21", "Kotlin")) .build()) @@ -72,23 +63,21 @@ void aRoleRoutedModuleRendersWhateverItsKind(DocumentTemplate preset String text = CvComposedText.squashedNodes(preset, doc); - assertRendered(text, "Senior Engineer", preset, "EXPERIENCE"); - assertRendered(text, "Java 21", preset, "SKILLS"); + assertRendered(text, "Senior Engineer", preset, "bullets"); + assertRendered(text, "Java 21", preset, "inline-list"); } @Test - void theRoleWinsOverAHeadingThatMatchesADifferentSlot() { - // A module titled "Projects" but declared EXPERIENCE belongs where its - // author said, not where its heading reads. + void aModuleIsNotClaimedByRoleOrHeading() { List sections = List.of( ModuleSection.builder("Projects", SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")).build()); assertThat(SectionRouter.find(sections, SectionRole.EXPERIENCE, List.of("experience"))) - .as("the role names the slot") - .isNotNull(); + .as("a module is not a CV meaning") + .isNull(); assertThat(SectionRouter.find(sections, SectionRole.PROJECTS, List.of("projects"))) - .as("...and the heading no longer claims a slot the role did not name") + .as("nor a heading the slot happens to recognise") .isNull(); } @@ -107,8 +96,8 @@ void aSectionWithoutARoleStillRoutesByItsHeading() { .as("a hand-written section still matches by heading") .isNotNull(); assertThat(SectionRouter.find(sections, SectionRole.OTHER, List.of("awards"))) - .as("SectionRole.OTHER claims no slot and falls through to the heading") - .isNotNull(); + .as("a runtime module is not claimed by heading either") + .isNull(); } /** @@ -120,7 +109,7 @@ void aSectionWithoutARoleStillRoutesByItsHeading() { private static void assertRendered(String text, String words, DocumentTemplate preset, String slot) { assertThat(text) - .as("%s must render the %s module routed by role", preset.id(), slot) + .as("%s must render the %s module", preset.id(), slot) .contains(CvComposedText.squash(words)); } diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java index 516521788..8921bce65 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java @@ -136,7 +136,7 @@ void aMissingFallbackLabelIsARejectedArgument() { } @Test - void aRoleClaimTakesTheModuleThatNamedTheRole() { + void aModuleIsNeverClaimedAndStaysInTheLeftovers() { ModuleSection experience = ModuleSection.builder("Опыт работы", SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) .item(CvItem.of("Ведущий инженер").period("2021")) @@ -144,11 +144,11 @@ void aRoleClaimTakesTheModuleThatNamedTheRole() { SectionAllocation allocation = SectionAllocation.of(List.of(SUMMARY, experience)); assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))) - .as("the heading matches no English keyword; the role is the answer") - .isSameAs(experience); + .as("a runtime module is a shape, not a slot") + .isNull(); assertThat(allocation.remaining()) - .as("a role-claimed section is claimed, so it is not also a leftover") - .doesNotContain(experience); + .as("so it is drawn with the leftovers, under the author's heading") + .contains(experience); } @Test @@ -161,33 +161,28 @@ void aRoleClaimFallsBackToTheHeadingForSectionsWithoutARole() { } @Test - void aDeclaredRoleIsNotClaimableByAnotherSlotsKeywords() { - // Otherwise the experience slot takes it by role and the projects slot - // takes it by heading, and the same module renders twice. - ModuleSection module = ModuleSection.builder("Projects", SectionRole.EXPERIENCE, + void aModuleHeadingDoesNotClaimATypedSlot() { + ModuleSection module = ModuleSection.builder("Projects", SectionRole.OTHER, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")) .build(); SectionAllocation allocation = SectionAllocation.of(List.of(module)); assertThat(allocation.claim(SectionRole.PROJECTS, List.of("projects"))).isNull(); - assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))) - .isSameAs(module); + assertThat(allocation.remaining()).containsExactly(module); } @Test - void aRoleClaimsAtMostOneSectionSoASecondSlotSeesTheNextOne() { - ModuleSection first = ModuleSection.builder("Erfahrung", SectionRole.EXPERIENCE, + void twoModulesStayInTheLeftoversInDocumentOrder() { + ModuleSection first = ModuleSection.builder("Erfahrung", SectionRole.OTHER, CvKind.ENTRIES_DATED).item(CvItem.of("First").period("2021")).build(); ModuleSection second = ModuleSection.builder("Weitere Erfahrung", - SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) + SectionRole.OTHER, CvKind.ENTRIES_DATED) .item(CvItem.of("Second").period("2019")).build(); SectionAllocation allocation = SectionAllocation.of(List.of(first, second)); - assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))).isSameAs(first); - assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))) - .as("claiming hands each section out once") - .isSameAs(second); + assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))).isNull(); + assertThat(allocation.remaining()).containsExactly(first, second); } @Test diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SlotHeadingFidelityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SlotHeadingFidelityTest.java index dbe72b0a7..bb57c7d53 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SlotHeadingFidelityTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SlotHeadingFidelityTest.java @@ -165,18 +165,12 @@ void theLanguageBlockKeepsItsLabelWhenItShowsPartOfASection() { @Test void aLanguagesModuleKeepsEveryRowEvenWhenNoneLooksLikeALanguage() { - // The block sniffs for language-shaped rows because it also accepts a - // wider section. A section routed here by its role is entirely - // languages whatever its rows look like, so nothing in it may be - // picked over. - // - // The first item carries a bracket and the other two do not, which is - // the shape that used to lose them: one sniff hit was enough to - // suppress the whole-section fallback, so English was drawn while - // Deutsch and Українська reached no page at all — and the section - // counted as claimed, so the leftover tail never saw them either. - // Written without that bracket, this fixture would pass against the - // old code too and guard nothing. + // A runtime module is not the language slot: it is leftover in the + // main column, under the heading the author wrote. The rows still + // have to all reach the page — the first item carries a bracket and + // the others do not, which is the shape that used to lose Deutsch + // and Українська when a claimed section was sniffed for + // language-shaped rows. CvDocument doc = cv(ModuleSection.builder("Языки", SectionRole.LANGUAGES, CvKind.INLINE_LIST) .item(CvItem.of("English").paragraphs("(C1 advanced)")) @@ -188,13 +182,10 @@ void aLanguagesModuleKeepsEveryRowEvenWhenNoneLooksLikeALanguage() { assertBoundaryIsOne(text); assertThat(text) - .as("every row is drawn in the language block itself — before the " - + "main column starts — under the author's own heading, each " - + "keeping the level written next to it") + .as("every row reaches the page under the author's heading") .containsSubsequence(squash("Языки"), squash("English"), squash("Deutsch"), squash("B2"), - squash("Українська"), squash("рідна"), - squash(COLUMN_BOUNDARY)); + squash("Українська"), squash("рідна")); } @Test diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java index a0a1a9d23..b2f802290 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java @@ -67,51 +67,20 @@ public static SectionAllocation of(List sections) { } /** - * Claims the section this slot means, preferring a module that named the - * role over one whose heading happens to match. + * Claims the typed section this slot's keywords match. * - *

Headings are the fallback because a section that carries no role — - * every hand-written one — has nothing else to be found by. A module that - * did name a role is never claimed by a different slot's - * keywords: it would then render in two places, which is a worse failure - * than the one role routing exists to fix.

+ *

A {@link ModuleSection} is never claimed. Runtime modules are + * shapes, not CV meanings, so they stay in {@link #remaining()} and + * draw through the constructor. The {@code role} argument is the + * slot's label for typed sections and does not inspect a module.

* - * @param role the role this slot holds; {@code null} or - * {@link SectionRole#OTHER} means "keywords only" + * @param role the role this slot holds; unused for modules * @param keys candidate heading fragments * @return the claimed section, or {@code null} when nothing matches * @since 2.3.0 */ public CvSection claim(SectionRole role, List keys) { - if (role != null && role != SectionRole.OTHER) { - for (CvSection section : sections) { - if (claimedSections.containsKey(section)) { - continue; - } - if (section instanceof ModuleSection module && module.role() == role - && SectionLookup.hasContent(section)) { - claimedSections.put(section, Boolean.TRUE); - return section; - } - } - } - for (CvSection section : sections) { - if (claimedSections.containsKey(section)) { - continue; - } - if (section instanceof ModuleSection module - && module.role() != SectionRole.OTHER) { - continue; - } - String title = SectionLookup.normalize(section.title()); - for (String key : keys == null ? List.of() : keys) { - if (title.contains(SectionLookup.normalize(key))) { - claimedSections.put(section, Boolean.TRUE); - return section; - } - } - } - return null; + return claim(keys); } /** @@ -137,6 +106,9 @@ public CvSection claim(List keys) { if (claimedSections.containsKey(section)) { continue; } + if (section instanceof ModuleSection) { + continue; + } String title = SectionLookup.normalize(section.title()); for (String key : keys) { if (title.contains(SectionLookup.normalize(key))) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java index 7ef0048da..1542f8ce6 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java @@ -20,38 +20,23 @@ import java.util.List; /** - * Finds the section a preset's slot should hold, by what it means rather - * than by what it is called — and hands it back in the shape that slot - * knows how to draw. + * Finds the typed section a preset's slot should hold, and lowers a + * leftover {@link ModuleSection} to the shape that slot (or the module's + * own kind) knows how to draw. * - *

Presets with a designed layout place their sections into fixed slots, - * and they have been choosing what goes where by matching the section's - * heading against a list of English words each preset keeps privately. That - * works for a CV written in English by someone who used the expected - * headings. A CV headed {@code Ausbildung} or {@code Навыки} matches - * nothing and loses the section; so does {@code "Volunteering"}, and so does - * a second section whose heading matched a word the first one took.

+ *

Slots that still ask for Experience or Skills only see the four + * compile-time records, matched by heading. A runtime module is a shape, + * not a CV meaning — the template does not know Experience from Projects + * — so {@link #find} never claims one. Modules stay in document order, or + * in {@link SectionAllocation#remaining()}, and draw through + * {@link com.demcha.compose.document.templates.cv.api.CvConstructor}.

* - *

A {@link ModuleSection} carries a {@link SectionRole} because the - * author already knew the answer, so the role is asked first and the - * keywords are the fallback for the sections that have no role to give.

- * - *

The second half is the shape. These slots are written against a - * particular section type — {@code if (!(section instanceof EntriesSection - * entries)) return;} — because each draws its content its own way, and a - * module reaching one would be dropped by that guard however well it was - * routed. Each finder below therefore lowers a matched module to the type - * its slot expects, so the preset draws it exactly as it draws everything - * else. What that costs is stated per method: a module's description lines - * are joined where the target type holds one string, and a bulleted - * description reads as prose.

- * - *

A section that matches no slot at all is a different question, and one - * this class cannot answer: it keeps no memory of what a previous slot took. - * {@link SectionAllocation} does, so a preset that wants to draw the leftovers - * claims through it and reads {@link SectionAllocation#remaining()} — with - * {@link #naturalShape(CvSection)} for the shape those sections have no slot - * to give them.

+ *

The lowering half is for a leftover that has no slot of its own: + * {@link #naturalShape(CvSection)} and {@link #asEntries}, {@link #asRows}, + * {@link #asParagraph}, {@link #asSkills} turn a module into the record + * a preset's existing renderer already takes. What that costs is stated + * per method: a module's description lines are joined where the target + * type holds one string, and a bulleted description reads as prose.

* * @since 2.3.0 */ @@ -265,18 +250,21 @@ public static CvSection naturalShape(CvSection section) { } /** - * The section this slot should hold, or {@code null} when the document - * has none: the first module whose role is the slot's, else the first - * section whose heading matches one of the keys. + * The typed section this slot should hold, or {@code null} when the + * document has none: the first hand-written section whose heading + * matches one of the keys. * - *

Role first, and only a role the author actually chose — - * {@link SectionRole#OTHER} is what a module carries when the catalogue - * has no name for it, so it never claims a slot and falls through to the - * headings like any other section.

+ *

A {@link ModuleSection} is never claimed here. A runtime module is + * a shape, not a CV meaning — the template does not know Experience + * from Projects — so slots that still ask for a {@link SectionRole} + * only see the four compile-time records. Modules stay in document + * order (or in {@link SectionAllocation#remaining()}) and draw through + * {@link com.demcha.compose.document.templates.cv.api.CvConstructor}.

* * @param sections the document's sections for this slot's column - * @param role the role this slot holds - * @param keys heading fragments to fall back on + * @param role unused for modules; kept so existing slot call sites + * compile while they still name a role for typed sections + * @param keys heading fragments to match against typed sections * @return the section, or {@code null} when nothing matches */ public static CvSection find(List sections, SectionRole role, @@ -284,31 +272,18 @@ public static CvSection find(List sections, SectionRole role, if (sections == null) { return null; } - if (role != null && role != SectionRole.OTHER) { - for (CvSection section : sections) { - if (section instanceof ModuleSection module && module.role() == role - && SectionLookup.hasContent(section)) { - return section; - } - } - } - // The heading is the fallback, and it may not overrule a role. A module - // declared EXPERIENCE and headed "Projects" belongs where its author put - // it; letting the projects slot claim it by heading would render it in - // both places, which is worse than the drop this routing exists to fix. - return SectionLookup.firstMatching(spokenFor(sections), keys); + return SectionLookup.firstMatching(typedOnly(sections), keys); } - /** The sections a keyword slot may still claim: everything but a module that named its own role. */ - private static List spokenFor(List sections) { - List open = new ArrayList<>(sections.size()); + /** Hand-written sections only — a runtime module is not a slot claim. */ + private static List typedOnly(List sections) { + List typed = new ArrayList<>(sections.size()); for (CvSection section : sections) { - if (section instanceof ModuleSection module && module.role() != SectionRole.OTHER) { - continue; + if (!(section instanceof ModuleSection)) { + typed.add(section); } - open.add(section); } - return open; + return typed; } /** The title, as markdown link syntax when the item carries a link. */ diff --git a/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java index 8ee264a57..1fe2be6e8 100644 --- a/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java +++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java @@ -37,21 +37,23 @@ private static List only(CvSection section) { // -- role beats heading, heading still works ------------------------ @Test - void aModuleIsFoundByItsRoleWhateverItsHeadingSays() { + void aModuleIsNotASlotClaim() { CvSection module = ModuleSection.builder("Berufserfahrung", SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")) .build(); assertThat(SectionRouter.find(only(module), SectionRole.EXPERIENCE, List.of("experience"))) - .isSameAs(module); + .as("a runtime module is a shape, not a CV meaning") + .isNull(); + assertThat(SectionRouter.find(only(module), SectionRole.PROJECTS, List.of("beruf"))) + .as("nor is its heading a slot claim") + .isNull(); } @Test - void aHeadingNeverOverrulesADeclaredRole() { - // Both slots would otherwise claim it — the experience slot by role and - // the projects slot by heading — and the module would render twice. - CvSection module = ModuleSection.builder("Projects", SectionRole.EXPERIENCE, + void aModuleHeadingDoesNotStealATypedSlot() { + CvSection module = ModuleSection.builder("Projects", SectionRole.OTHER, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")) .build(); @@ -131,11 +133,11 @@ void onlyTheStackedRowStyleCarriesALinkedTitle() { @Test void aPlainListOfSkillsArrivesAsSkillsNotAsCategoriesHoldingThemselves() { - CvSection lowered = SectionRouter.skills(only(ModuleSection + CvSection lowered = SectionRouter.asSkills(ModuleSection .builder("Kenntnisse", SectionRole.SKILLS, CvKind.BULLETS) .item("Java 21") .item("Kotlin") - .build()), SectionRole.SKILLS, List.of("skills")); + .build()); assertThat(lowered).asInstanceOf(type(SkillsSection.class)) .extracting(SkillsSection::groups, org.assertj.core.api.InstanceOfAssertFactories.LIST) @@ -149,11 +151,11 @@ void aPlainListOfSkillsArrivesAsSkillsNotAsCategoriesHoldingThemselves() { @Test void anItemWithADescriptionBecomesItsOwnSkillCategory() { - CvSection lowered = SectionRouter.skills(only(ModuleSection + CvSection lowered = SectionRouter.asSkills(ModuleSection .builder("Technical Skills", SectionRole.SKILLS, CvKind.INLINE_LIST) .item(CvItem.of("Languages").paragraphs("Java 21", "Kotlin")) .item("Docker") - .build()), SectionRole.SKILLS, List.of("skills")); + .build()); SkillsSection skills = (SkillsSection) lowered; assertThat(skills.groups()).extracting(SkillGroup::category) @@ -164,10 +166,10 @@ void anItemWithADescriptionBecomesItsOwnSkillCategory() { @Test void proseJoinsEveryItemsDescriptionIntoOneBlock() { - CvSection lowered = SectionRouter.paragraph(only(ModuleSection + CvSection lowered = SectionRouter.asParagraph(ModuleSection .builder("Profile", SectionRole.SUMMARY, CvKind.PARAGRAPH) .item(CvItem.of("first").paragraphs("Backend engineer.", "Ten years of it.")) - .build()), SectionRole.SUMMARY, List.of("summary")); + .build()); assertThat(lowered).asInstanceOf(type(ParagraphSection.class)) .extracting(ParagraphSection::body) @@ -200,16 +202,14 @@ void nothingMatchingYieldsNullFromEveryFinder() { // -- helpers --------------------------------------------------------- private static List entriesOf(CvKind kind, CvItem item) { - CvSection lowered = SectionRouter.entries(only(ModuleSection - .of("Experience", SectionRole.EXPERIENCE, kind, item)), - SectionRole.EXPERIENCE, List.of("experience")); + CvSection lowered = SectionRouter.asEntries(ModuleSection + .of("Experience", SectionRole.EXPERIENCE, kind, item)); return ((EntriesSection) lowered).entries(); } private static CvSection rowsOf(RowStyle style, CvItem item) { - return SectionRouter.rows(only(ModuleSection - .of("Section", SectionRole.OTHER, CvKind.BULLETS, item)), - SectionRole.OTHER, List.of("section"), style); + return SectionRouter.asRows(ModuleSection + .of("Section", SectionRole.OTHER, CvKind.BULLETS, item), style); } private static String rowBody(CvSection section) { From 453c663b1fedc6d5952f18cb7bf45694ceda26d1 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 22 Aug 2026 21:44:18 +0100 Subject: [PATCH 3/4] docs(templates): runtime modules are shapes, not CV meanings --- docs/templates/v2-layered/using-templates.md | 23 ++++++-------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index 412edc6ca..f7e9d0547 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -203,22 +203,13 @@ shaped exactly like Education without a new type. a payload standing in for parsed JSON, and its PDF is the shortest way to see what each kind does to the same item. -`SectionRole` says what a section *means*, separately from how it -draws — and it is the first thing a preset routes on. A preset with a designed -layout places sections into fixed slots, and it used to choose what -went where by matching the heading against a list of English words: -a CV headed `Ausbildung` or `Навыки` matched nothing, so the section -was dropped and the slot that wanted it rendered empty. Give the module -a role and it lands in the right slot whatever language the CV is -written in, and whatever kind you chose to draw it with — for the roles -that preset has a slot for. `SectionRole.OTHER` names no slot, so a -module carrying it routes by heading like any other section. - -A heading that matches a keyword still routes a section that has no -role — every hand-written section, and any module you left as -`SectionRole.OTHER`. What a heading may not do is overrule a role: a -module declared `EXPERIENCE` and headed "Projects" goes where you put -it, and the projects slot does not also claim it. +A runtime module is a shape, not a CV meaning. The template does not +know Experience from Projects. `SectionRole` is still on the builder +so existing call sites compile; slots do not read it. A module stays +in document order (or the leftover tail of a two-column preset) and +draws through `CvConstructor`. Hand-written sections still match slots +by heading, so an `EntriesSection` titled "Experience" lands where it +always did. Modules and the four fixed types mix freely in one document, and both render through the same components — a module drawn as `ENTRIES_DATED` From db6766863094018b1b2e9b3423712abea5bb3f0e Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 27 Aug 2026 12:08:04 +0100 Subject: [PATCH 4/4] fix(templates): let a module reach a slot by its heading, not by its role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping role routing was right; dropping the heading match with it was not. `SectionRouter.find` filtered modules out of the candidate list entirely and `SectionAllocation.claim` skipped them, so a module reached a slot by neither route. On a preset that composes fixed slots and keeps a `SectionAllocation.remaining()` tail that cost nothing. On one that keeps no tail — ClassicSerif, CompactMono, EngineeringResume, NordicClean, Panel — the module was dropped entirely, heading and body, with nothing in the API or the PDF reporting it. `ModuleSectionKindCoverageTest` caught it on classic-serif. A module now matches by heading on the same terms a hand-written section does. The distinction the change was reaching for survives: the *role* is still not consulted, because a CV meaning declared by the document is a thing the template has no business acting on. A heading is not that — it is the author's own word for the block, and matching it is what the four record types have always done. Both entry points also skip a section with no content. An empty section takes a slot, draws nothing, and leaves the populated section that matches the same keys to the leftover tail; `LeftoverSectionTest` pins the ordering that exposes it. `claim` needed this as much as `find` did. Tests: - `ModuleReachesEveryPresetTest` (new) walks `CvTemplates.all()` rather than a hand-written list, so a preset joins this gate the day it is registered and cannot escape by being left out of one. It asserts the item title, the employer and the body all reach the page. Sabotaged by restoring the module filter: red on the five presets that were losing sections, green again on revert. - `SectionRouterTest.aModuleClaimsASlotByItsHeadingAndNotByItsRole` and `RoleRoutingTest.aModuleIsClaimedByItsHeadingButNeverByItsRole` replace two tests that pinned the broken behaviour; each keeps a case proving the role is ignored, which is the part worth keeping. - `aTypedSectionAheadOfAModuleKeepsTheSlot` replaces a test whose name promised a typed section in the fixture and whose body had none — it now has one. - `SectionAllocationTest` asserts the claimed module leaves `remaining()`, so it cannot be drawn twice. The CHANGELOG said the presets "do not read the role yet … when the routing work lands", and described a "role-first choice". Neither is true and neither is planned; the entry now says what the code does. Verification: `./mvnw -B -ntp clean verify` — BUILD SUCCESS, qa 1019 green, templates 143, core 641. Not fixed here, and not a regression: a module under a heading **no** preset recognises still reaches the page only where a `remaining()` tail exists. Those five presets have never had one; the new test documents that gap rather than pretending it is closed. --- CHANGELOG.md | 11 +- .../ModuleReachesEveryPresetTest.java | 120 ++++++++++++++++++ .../cv/components/RoleRoutingTest.java | 15 ++- .../cv/components/SectionAllocationTest.java | 11 +- .../cv/components/SectionAllocation.java | 14 +- .../cv/components/SectionRouter.java | 48 ++++--- .../cv/components/SectionRouterTest.java | 26 +++- 7 files changed, 202 insertions(+), 43 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleReachesEveryPresetTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 886bbee38..654897591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,11 @@ follow semantic versioning; release dates are ISO 8601. 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. + `Навыки` never matches. The presets deliberately do **not** read the role: a module is a + shape, and inferring a CV meaning from a field the document declares is exactly what a + template has no business doing. A module reaches a slot by its **heading**, on the same + terms a hand-written section does — the heading is the author's own word for the block. + The role travels with the section for a consumer that wants it. 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 @@ -137,7 +140,7 @@ follow semantic versioning; release dates are ISO 8601. renderers so it looks like the rest of the document; Mint Editorial draws entries through its experience block and flattens rows to label/value lines rather than into its awards grid, which is a two-column table and the wrong shape for a category of unknown size. `SectionAllocation` gained the four shaped claims (`entries`, `rows`, - `paragraph`, `skills`) that pair its role-first choice with `SectionRouter`'s shape + `paragraph`, `skills`) that pair its heading claim with `SectionRouter`'s shape lowering, so a preset gets the section in the form its slot draws *and* the claim is recorded — which is what makes `remaining()` the truth rather than a guess. `SectionRouter.naturalShape(...)` answers the question a leftover raises and a slot never @@ -359,7 +362,7 @@ follow semantic versioning; release dates are ISO 8601. plain "Deutsch B2" was enough to lose the Deutsch. The slot now claims in two steps and knows which it got. A section that is entirely - languages — routed by the role, or titled for the job — is drawn whole, whatever its + languages — titled for the job — is drawn whole, whatever its rows look like. A wider section is still picked over, and the rows the block does not draw are handed to the main column, which prints them under the title their author wrote and in the place the document gave them. No row of a claimed section is dropped on diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleReachesEveryPresetTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleReachesEveryPresetTest.java new file mode 100644 index 000000000..3b8bb4cb8 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ModuleReachesEveryPresetTest.java @@ -0,0 +1,120 @@ +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.style.DocumentInsets; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.data.Slot; +import com.demcha.compose.document.templates.cv.presets.CvTemplates; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Locale; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A runtime module handed to a preset under a heading that preset knows must reach + * the page — on every preset in the catalogue, not a hand-picked list. + * + *

The catalogue is the source of presets on purpose. Sibling coverage tests name + * their presets in three hand-written {@code Stream.of(...)} lists, so a preset added + * to {@link CvTemplates} joins the shipped set without joining any gate. Reading + * {@link CvTemplates#all()} here means a new preset is held to this from the day it + * is registered, and a preset that stops rendering modules cannot escape by being + * left out of a list.

+ * + *

The case is narrow and deliberate. Routing a module by its role is a + * meaning the template would be inferring, and the constructor layer stopped doing + * it. Routing it by its heading is not: the heading is the document author's + * own word for the block, matched exactly as a hand-written section's is. Losing that + * distinction cost every module handed to a preset that composes fixed slots and + * keeps no {@code SectionAllocation.remaining()} tail — ClassicSerif, CompactMono, + * EngineeringResume, NordicClean and Panel dropped heading and body together, with + * nothing in the API or the PDF reporting it.

+ * + *

What this does not claim. A module under a heading no preset recognises + * still reaches the page only on presets that keep a {@code remaining()} tail. That + * gap is older than the constructor layer and is the unfinished half of the + * no-silent-loss work; it is not asserted here because asserting it would fail, and + * a test that documents a gap is worth more than one that pretends it is closed.

+ * + * @author Artem Demchyshyn + */ +class ModuleReachesEveryPresetTest { + + /** A phrase no preset chrome contains, so finding it means the module drew. */ + private static final String MARKER = "Sentinel Rendering GmbH"; + + static Stream> everyPresetInTheCatalogue() { + return CvTemplates.all().stream(); + } + + @ParameterizedTest + @MethodSource("everyPresetInTheCatalogue") + void aModuleUnderAKnownHeadingReachesThePage(DocumentTemplate preset) + throws Exception { + ModuleSection module = ModuleSection.builder("Professional Experience", + SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) + .item(CvItem.of("Senior Backend Engineer") + .at(MARKER) + .period("2021 - Present") + .paragraphs("Owned the rendering pipeline.")) + .build(); + + String text = render(preset, module); + + assertThat(text) + .describedAs("%s dropped a runtime module whose heading its own keyword " + + "list contains — heading and body together", + preset.getClass().getName()) + .contains(MARKER) + .contains("Owned the rendering pipeline."); + + // The item title is asserted separately because several presets style a position + // in caps and one letter-spaces it, so neither case nor the spaces between glyphs + // is the template's promise. Stripping both asks the only question that matters + // here — did the title reach the page — without pinning anyone's typography. + assertThat(text.replace(" ", "").toLowerCase(Locale.ROOT)) + .describedAs("%s drew the module but not its item title", + preset.getClass().getName()) + .contains("seniorbackendengineer"); + } + + private static String render(DocumentTemplate preset, ModuleSection module) + throws Exception { + CvDocument document = CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jordan", "Rivera") + .jobTitle("Backend Engineer") + .contact("+1 555 0100", "jordan@example.com", "Berlin, DE") + .build()) + .section(Slot.MAIN, module) + .build(); + + byte[] bytes; + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(DocumentInsets.of(24)) + .create()) { + preset.compose(session, document); + bytes = session.toPdfBytes(); + } + try (PDDocument pdf = Loader.loadPDF(bytes)) { + // Collapse the layout's own wrapping: this asks whether the text reached + // the page, not where the engine chose to break it. + return new PDFTextStripper().getText(pdf).replaceAll("\\s+", " "); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java index f944e6251..edb93e853 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java @@ -68,17 +68,19 @@ void aModuleRendersWhateverItsKind(DocumentTemplate preset) { } @Test - void aModuleIsNotClaimedByRoleOrHeading() { + void aModuleIsClaimedByItsHeadingButNeverByItsRole() { List sections = List.of( ModuleSection.builder("Projects", SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")).build()); assertThat(SectionRouter.find(sections, SectionRole.EXPERIENCE, List.of("experience"))) - .as("a module is not a CV meaning") + .as("the module declares role EXPERIENCE, and the slot asks for it — but " + + "the heading reads Projects, and the role is not consulted") .isNull(); assertThat(SectionRouter.find(sections, SectionRole.PROJECTS, List.of("projects"))) - .as("nor a heading the slot happens to recognise") - .isNull(); + .as("the heading does match, so the module fills the slot; a preset with " + + "no leftover tail would otherwise drop it entirely") + .isNotNull(); } @Test @@ -96,8 +98,9 @@ void aSectionWithoutARoleStillRoutesByItsHeading() { .as("a hand-written section still matches by heading") .isNotNull(); assertThat(SectionRouter.find(sections, SectionRole.OTHER, List.of("awards"))) - .as("a runtime module is not claimed by heading either") - .isNull(); + .as("a runtime module matches by heading on the same terms — the heading " + + "is the author's word for the block, not a meaning inferred") + .isNotNull(); } /** diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java index 8921bce65..b29bee9fd 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java @@ -161,15 +161,20 @@ void aRoleClaimFallsBackToTheHeadingForSectionsWithoutARole() { } @Test - void aModuleHeadingDoesNotClaimATypedSlot() { + void aModuleHeadingClaimsTheSlotAndLeavesTheTail() { ModuleSection module = ModuleSection.builder("Projects", SectionRole.OTHER, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")) .build(); SectionAllocation allocation = SectionAllocation.of(List.of(module)); - assertThat(allocation.claim(SectionRole.PROJECTS, List.of("projects"))).isNull(); - assertThat(allocation.remaining()).containsExactly(module); + assertThat(allocation.claim(SectionRole.PROJECTS, List.of("projects"))) + .describedAs("the heading matches, so the module fills the slot") + .isSameAs(module); + assertThat(allocation.remaining()) + .describedAs("and a claimed section is out of the leftover tail, so it " + + "cannot be drawn a second time at the bottom") + .isEmpty(); } @Test diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java index b2f802290..88f89c04d 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java @@ -91,9 +91,12 @@ public CvSection claim(SectionRole role, List keys) { * different section instead of the same one twice, and what moves * the section out of {@link #remaining()}.

* - *

Heading-only. A slot that knows which {@link SectionRole} it holds - * should call {@link #claim(SectionRole, List)}, so a CV written in another - * language routes on what its sections mean.

+ *

Heading-only, and that includes a {@link ModuleSection}: a runtime + * module claims a slot on the same terms as a hand-written section, by the + * heading its author wrote. The module's {@code role} is not consulted — + * the template does not read a CV meaning off a module — but its heading is + * the author's own word and is honoured. A module excluded here reached no + * slot and, in a preset that keeps no {@link #remaining()} tail, no page.

* * @param keys candidate title fragments; {@code null} claims nothing * @return the claimed section, or {@code null} when nothing matches @@ -106,7 +109,10 @@ public CvSection claim(List keys) { if (claimedSections.containsKey(section)) { continue; } - if (section instanceof ModuleSection) { + if (!SectionLookup.hasContent(section)) { + // An empty section takes the slot and draws nothing, leaving the + // populated section that matches the same keys to the leftover + // tail. Skipping it here is what keeps the two in the right order. continue; } String title = SectionLookup.normalize(section.title()); diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java index 1542f8ce6..30c8474d9 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java @@ -250,21 +250,31 @@ public static CvSection naturalShape(CvSection section) { } /** - * The typed section this slot should hold, or {@code null} when the - * document has none: the first hand-written section whose heading - * matches one of the keys. + * The section this slot should hold, or {@code null} when the document + * has none: the first section whose heading matches one of the keys. * - *

A {@link ModuleSection} is never claimed here. A runtime module is - * a shape, not a CV meaning — the template does not know Experience - * from Projects — so slots that still ask for a {@link SectionRole} - * only see the four compile-time records. Modules stay in document - * order (or in {@link SectionAllocation#remaining()}) and draw through - * {@link com.demcha.compose.document.templates.cv.api.CvConstructor}.

+ *

The {@code role} is deliberately not consulted. A runtime module is + * a shape, not a CV meaning — the template does not know Experience from + * Projects, and a module that declared itself {@code EXPERIENCE} would be + * telling the template a meaning it has no business acting on.

+ * + *

A {@link ModuleSection} is still matched by heading, on the + * same terms as a hand-written section. The heading is the document + * author's own word for the block, not a meaning the template inferred, + * so honouring it costs nothing this method is trying to avoid — and + * skipping it cost a great deal: a preset that composes fixed slots and + * keeps no {@link SectionAllocation#remaining()} tail (ClassicSerif, + * CompactMono, EngineeringResume, NordicClean, Panel) dropped every module + * handed to it, heading and all, without a word.

+ * + *

Only sections that have content are considered, which is what stops an + * empty section — module or record — from taking a slot away from a + * populated one that matches the same keys. A slot filled by an empty + * section renders nothing and hides the section that would have rendered.

* * @param sections the document's sections for this slot's column - * @param role unused for modules; kept so existing slot call sites - * compile while they still name a role for typed sections - * @param keys heading fragments to match against typed sections + * @param role unused; kept so existing slot call sites compile + * @param keys heading fragments to match against section headings * @return the section, or {@code null} when nothing matches */ public static CvSection find(List sections, SectionRole role, @@ -272,18 +282,18 @@ public static CvSection find(List sections, SectionRole role, if (sections == null) { return null; } - return SectionLookup.firstMatching(typedOnly(sections), keys); + return SectionLookup.firstMatching(withContent(sections), keys); } - /** Hand-written sections only — a runtime module is not a slot claim. */ - private static List typedOnly(List sections) { - List typed = new ArrayList<>(sections.size()); + /** Sections that would draw something — an empty one must not claim a slot. */ + private static List withContent(List sections) { + List populated = new ArrayList<>(sections.size()); for (CvSection section : sections) { - if (!(section instanceof ModuleSection)) { - typed.add(section); + if (SectionLookup.hasContent(section)) { + populated.add(section); } } - return typed; + return populated; } /** The title, as markdown link syntax when the item carries a link. */ diff --git a/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java index 1fe2be6e8..1d53a7f7b 100644 --- a/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java +++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java @@ -37,29 +37,41 @@ private static List only(CvSection section) { // -- role beats heading, heading still works ------------------------ @Test - void aModuleIsNotASlotClaim() { + void aModuleClaimsASlotByItsHeadingAndNotByItsRole() { CvSection module = ModuleSection.builder("Berufserfahrung", SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")) .build(); assertThat(SectionRouter.find(only(module), SectionRole.EXPERIENCE, List.of("experience"))) - .as("a runtime module is a shape, not a CV meaning") + .as("the role says EXPERIENCE and the heading does not match the keys — " + + "a runtime module is a shape, and the template reads no CV " + + "meaning off it") .isNull(); assertThat(SectionRouter.find(only(module), SectionRole.PROJECTS, List.of("beruf"))) - .as("nor is its heading a slot claim") - .isNull(); + .as("the heading is the author's own word for the block, so it claims the " + + "slot on the same terms a hand-written section would — and the " + + "role, which says PROJECTS here, is not consulted either way") + .isSameAs(module); } @Test - void aModuleHeadingDoesNotStealATypedSlot() { + void aTypedSectionAheadOfAModuleKeepsTheSlot() { + // Both match the keys, so this pins the order rather than the type: find + // returns the first section in document order, which is the one the author + // put first. The module is not preferred for being a module, nor skipped + // for being one. + CvSection typed = EntriesSection.builder("Projects") + .entry("Ported the renderer", "Acme", "2021", "") + .build(); CvSection module = ModuleSection.builder("Projects", SectionRole.OTHER, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")) .build(); - assertThat(SectionRouter.find(only(module), SectionRole.PROJECTS, List.of("projects"))) - .isNull(); + assertThat(SectionRouter.find(List.of(typed, module), SectionRole.PROJECTS, + List.of("projects"))) + .isSameAs(typed); } @Test