diff --git a/CHANGELOG.md b/CHANGELOG.md index e26d298e..b13eed6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,40 @@ follow semantic versioning; release dates are ISO 8601. interface, so a downstream `switch` over `CvSection` that was exhaustive without a `default` needs one. +- **Which presets can be handed a runtime module, declared rather than assumed.** A module + is only useful if the template renders it, and not every preset can promise that: + several compose a fixed set of modules and find each by matching headings, so a + section they do not recognise never reaches a renderer. The CV still comes out — + minus a section, looking finished — which is the kind of failure nobody reports. + + `ModularCvTemplate` is the promise, and `CvTemplates.modular()` is the list a CV + builder should offer; `CvTemplates` also answers `byId`, `all`, `ids`, and + `recommendedMargin`, so picking a preset at runtime stops being a hand-kept map in + every consumer. Declaring the interface is not free: `ModularCvTemplateFidelityTest` + renders a document carrying every kind, an invented heading, a heading in a + script no keyword list contains, and a heading that *does* match one, through each + template that declares it, and asserts every item reached the page under the words + the author wrote — the last case because `EditorialBlue` renamed any heading + matching "certification" to EDUCATION, so "Certifications & Awards" arrived as a + word nobody had written. The promise covers `Slot.MAIN`, and says so: every shipped + preset composes a single main column, so a sidebar section is dropped by these + templates as by every other. Seven presets qualify today. `ClassicSerif` does not, + and finding that out is what the gate is for — it draws any shape it is given, but + only gives itself the sections it recognises. + + `CvTemplatesCoverageTest` derives the catalogue from the presets package rather than + trusting it, so a preset that ships without being registered fails the build instead + of being invisible to every caller that looks a template up by id. + +- **A preset can draw runtime modules in its own style.** `CvRenderKit` is the three + shapes a section body reduces to — a paragraph, a label/value row, a timeline entry — + and a template hands back the kit it draws them with. The lowering from `CvItem` + stays shared, because deciding what a linked title looks like or which fields a kind + reads belongs to the model and must not be re-decided per preset; only the drawing is + the preset's. `BlueBanner`, `ClassicSerif`, and `EditorialBlue` now render modules + with their own entry and project shapes rather than the canonical ones — the + limitation the entry above left open. + ### Fixed - **A section shape a preset did not recognise was lost three different ways.** diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index 461bb11c..c997f6c3 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -212,6 +212,41 @@ Modules and the four fixed types mix freely in one document, and both render through the same components — a module drawn as `ENTRIES_DATED` lays out exactly like the `EntriesSection` carrying the same content. +### Which preset can you hand a runtime module to? + +Not every preset. Several compose a fixed set of modules and find each by +matching headings, so a section they do not recognise never reaches a +renderer; the CV still comes out, minus a section, looking finished. The +ones that render whatever they are handed say so in the type system: + +```java +List safe = CvTemplates.modular(); // offer these + +CvTemplates.byId("modern-professional") // or look one up + .orElseThrow() + .compose(session, doc); +``` + +`CvTemplates` also answers `all()`, `ids()`, and `recommendedMargin(id)` — +the margin a preset was designed at, which you need while building the +session, before you have a template. + +Declaring `ModularCvTemplate` is not free: a fidelity suite renders a +document carrying every kind, an invented heading, a heading in a script no +keyword list contains, and a heading that *does* match one, through each +template that declares it, and asserts every item reached the page under the +author's own words. + +The promise covers `Slot.MAIN`, which is where sections go unless you say +otherwise. Every shipped preset composes a single main column, so a section +placed in `Slot.SIDEBAR` is dropped — by these templates as by every other. + +A template also says *how* it draws through `CvRenderKit`. The shared +lowering turns a module into paragraphs, rows, and entries; the kit draws +them, so a preset with its own entry style renders your runtime module in +that style rather than the canonical one. Presets whose bodies already use +the shared components return `CvRenderKit.defaults()`. + --- diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java new file mode 100644 index 00000000..291e4036 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java @@ -0,0 +1,265 @@ +package com.demcha.compose.document.templates.cv.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.data.Slot; +import com.demcha.compose.document.templates.cv.presets.CvTemplates; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Every template that declares {@link ModularCvTemplate} renders every kind + * of module, under whatever heading the author wrote. + * + *

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

+ * + *

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

+ * + *

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

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

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

* - *

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

+ *

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

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

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

+ * + *

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

+ * + *

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

+ * + *

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

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

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

+ * + *

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

+ * + *

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

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

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

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

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

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

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

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

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

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

+ * + *

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

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

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

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

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

+ * + *

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

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

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

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

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

+ * + *

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

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