diff --git a/CHANGELOG.md b/CHANGELOG.md index eaeda76dd..68c03e828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,110 @@ All notable changes to GraphCompose are documented here. Versions follow semantic versioning; release dates are ISO 8601. -## v2.2.1 — Planned +## v2.3.0 — Planned + +### Public API + +- **A CV section whose shape is a value, for CVs assembled at runtime.** The four + section records each fix one shape at compile time, which is right when a CV is + written in Java — you pick the record, the compiler checks it. It is the wrong model + when the CV arrives as data: a user who has just chosen "Volunteering, shaped like + Education, with dates" cannot instantiate a different record per choice, so every + shape somebody thought of would have to become a type. + + `ModuleSection` carries the choice instead. One `CvItem` record holds every optional + field — title, link, subtitle, period, location, description lines — and a `CvKind` + (`PARAGRAPH`, `BULLETS`, `BULLETS_STACKED`, `INLINE_LIST`, `ENTRIES`, + `ENTRIES_DATED`) decides which of them are read: the same item renders with or without its dates depending on the kind + alone. `BodyStyle` decides whether a description reads as prose or as bullets, and + `SectionRole` states what a section *means* — the decision multi-column presets make + by matching headings against English keywords, which a CV headed `Ausbildung` or + `Навыки` never matches. The presets do not read the role yet; it travels with the + section now so a document built today needs no rewrite when the routing work lands. + + The existing four records are untouched and mix with modules in the same document. + A module renders through the existing components rather than beside them, so one + drawn as `ENTRIES_DATED` lays out exactly like the `EntriesSection` carrying the same + content — held node-for-node by a parity suite, for every kind, alongside the + extracted text so structure and content are both pinned. The addition is binary- + compatible (the japicmp gate covers this module); it is a fifth permit on a sealed + interface, so a downstream `switch` over `CvSection` that was exhaustive without a + `default` needs one. + +- **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. + +- **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. + +- **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.** + `BlueBanner` and `ClassicSerif` each kept a private copy of the section dispatcher + whose final `else` threw `IllegalStateException`; `EditorialBlue`'s had no `else` at + all; and `SectionLookup.hasContent` — which presets consult *before* routing, and + which `SectionAllocation.remaining()` uses to decide what still needs a home — + answered `false` for any subtype it had not been taught, dropping the heading along + with the body. So a section type added to the model would have crashed two presets + and vanished from several more, including through the very fallback that exists to + catch unplaced sections. All three dispatchers now delegate unfamiliar shapes to the + canonical one, and `hasContent` answers for every permit. + +- **An entry with no date no longer reserves a column for it.** `EntryRenderer` always + emitted the two-column title/date header, so an undated entry — a certification, a + project — had its title wrapped early to leave room for nothing. Its Javadoc had + described the collapsing behaviour since the entry renderer was written. No shipped + fixture has a blank date, so no existing render moves. ### Build diff --git a/README.md b/README.md index fb97aa3c0..e63ee3bc8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ > **Release status** — > 🟢 **Latest stable**: [v2.2.0](https://github.com/DemchaAV/GraphCompose/releases/tag/v2.2.0) — the **right-to-left** release: Hebrew and Arabic lay out, shape, join and mirror through PDF, PowerPoint and Word — in paragraphs and in table cells — with the fonts to render them. See [CHANGELOG.md](./CHANGELOG.md). -> · 🟡 **In development**: v2.2.1 on `develop` — see [CHANGELOG.md](./CHANGELOG.md). +> · 🟡 **In development**: v2.3.0 on `develop` — see [CHANGELOG.md](./CHANGELOG.md).
Live Showcase
diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml
index 1ad6ce226..fb743b64f 100644
--- a/benchmarks/pom.xml
+++ b/benchmarks/pom.xml
@@ -7,7 +7,7 @@
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. A runtime module is only as good as the weakest kind: an author who picks
+ * one the renderers never learned to lower gets a section that silently draws
+ * nothing, and the CV looks finished. Enumerating the enum rather than listing
+ * cases means a kind added later fails here until it is wired, which is the
+ * point — a new constant cannot ship half-rendered. The 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. That equivalence is the whole basis of the runtime module: it renders
+ * through the existing components rather than beside them, so the two
+ * authoring routes are two spellings of one document. Left unchecked it is a
+ * claim in a Javadoc, and the failure it hides is silent — a module that
+ * merely looks close, on a preset nobody re-renders, in a CV nobody compares
+ * side by side. Each case pins both halves of "the same": the layout snapshot, which
+ * carries node structure and bounds but not text, and the extracted PDF text,
+ * which carries the words but not their positions. Either alone passes
+ * documents the other would catch. 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. 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. 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. Nothing here draws. Each {@code CvKind} is a rule for turning
+ * {@link CvItem}s into the inputs {@link ParagraphRenderer},
+ * {@link RowRenderer} and {@link EntryRenderer} already take, which is
+ * what makes a runtime-assembled module and a hand-written
+ * {@code EntriesSection} carrying the same content lay out the same
+ * way — a property the parity suite checks node for node rather than
+ * by eye. The lowering is also where a kind's documented indifference
+ * happens: {@code ENTRIES} builds its {@link CvEntry} with a blank
+ * date, so an item's {@code period} reaches no renderer at all. Every
+ * field a kind ignores is dropped here, in one place, rather than by
+ * each renderer deciding what to skip. 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. The title goes in unlinked. This row bolds its label by wrapping
+ * it in markdown markers, which would nest around link markup and
+ * reach the page as literal asterisks; a module whose titles are
+ * links wants {@link CvKind#BULLETS_STACKED}, which bolds through the
+ * text style and leaves the link intact. A title containing a bracket is left alone. The markdown link
+ * pattern's label admits no brackets, so wrapping
+ * {@code "Ledger [v2]"} would match nothing and print the whole
+ * construction — URL included — as visible text. Either the title
+ * already carries its own {@code [text](url)}, which renders as the
+ * link it is, or it is prose with a bracket in it and reaches the
+ * page as written. 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.
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.
+ * * @param keys candidate title fragments; {@code null} claims nothing * @return the claimed section, or {@code null} when nothing matches */ diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java index c2280c71a..b450b8e67 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,8 +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, kit); } else if (section instanceof EntriesSection e) { // Timeline entries (Education, Experience) get a spacer // between items — each entry is a multi-line block @@ -63,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/components/SectionLookup.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java index c116eb34f..e5f3a96ce 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java @@ -50,9 +50,18 @@ public static CvSection firstMatching(ListThe default is {@code false}, which makes an unlisted subtype + * invisible rather than merely unstyled: presets filter on this + * before they route or render, so a section this method does not + * recognise never reaches a dispatcher at all. Every {@code CvSection} + * permit therefore needs a case here — the branch below for + * {@code ModuleSection} exists because the fallback dropped the section + * heading and body together, on presets that had a perfectly good + * rendering path for it.
+ * * @param section the section to inspect; may be {@code null} * @return {@code true} if the section has non-empty body, entries, - * rows, or skill groups + * rows, skill groups, or module items */ public static boolean hasContent(CvSection section) { if (section instanceof ParagraphSection paragraph) { @@ -67,6 +76,9 @@ public static boolean hasContent(CvSection section) { if (section instanceof SkillsSection skills) { return skills.groups() != null && !skills.groups().isEmpty(); } + if (section instanceof ModuleSection module) { + return !module.items().isEmpty(); + } return false; } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java new file mode 100644 index 000000000..b87f3ad16 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java @@ -0,0 +1,258 @@ +package com.demcha.compose.document.templates.cv.components; + +import com.demcha.compose.document.templates.core.identity.Link; +import com.demcha.compose.document.templates.cv.data.BodyStyle; +import com.demcha.compose.document.templates.cv.data.CvEntry; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.CvRow; +import com.demcha.compose.document.templates.cv.data.CvSection; +import com.demcha.compose.document.templates.cv.data.EntriesSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.data.RowStyle; +import com.demcha.compose.document.templates.cv.data.RowsSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.data.SkillGroup; +import com.demcha.compose.document.templates.cv.data.SkillsSection; + +import java.util.ArrayList; +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. + * + *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.
+ * + *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.
+ * + *These presets still drop a section that matches no slot at all — their + * whole body is one atomic row that cannot paginate, so there is nowhere to + * put it. Routing by role fixes the sections that were lost while a slot + * for them sat empty; the rest waits on pagination.
+ * + * @since 2.3.0 + */ +public final class SectionRouter { + + private SectionRouter() { + } + + /** + * The section for a timeline slot — education, experience, anything the + * preset draws as dated entries. + * + *A matched module becomes an {@link EntriesSection}: each item's + * title, its subtitle and location joined, its period (blank when the + * module's kind does not read one), and its description lines joined + * into the single body string a {@link CvEntry} holds. A description the + * author asked to bullet reads as prose here — the slot draws one + * paragraph.
+ * + * @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 + * @return an {@code EntriesSection}, or {@code null} when nothing matches + */ + public static CvSection entries(ListA matched module becomes a {@link RowsSection} in the caller's + * {@link RowStyle}: one row per item, its title the label and its + * description lines joined into the body.
+ * + * @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 style the decoration this slot draws rows with + * @return a {@code RowsSection}, or {@code null} when nothing matches + */ + public static CvSection rows(ListA matched module becomes a {@link ParagraphSection} whose body is + * every item's description, joined. The slot holds one block of prose, + * so a module with several items reads as one.
+ * + * @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 + * @return a {@code ParagraphSection}, or {@code null} when nothing matches + */ + public static CvSection paragraph(ListA matched module becomes a {@link SkillsSection}: an item with a + * description is a category whose skills are its lines, and the items + * with none are collected into one group under the module's own heading — + * a plain list of skills is a list of skills, not a set of categories + * each holding itself.
+ * + * @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 + * @return a {@code SkillsSection}, or {@code null} when nothing matches + */ + public static CvSection skills(ListRole 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.
+ * + * @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 + * @return the section, or {@code null} when nothing matches + */ + public static CvSection find(ListThe kind decides the item's shape (a bullet, a dated entry, a + * line in a list); this decides what happens to + * {@link CvItem#body()} inside it. The same experience entry can list + * its achievements as bullets or read as a paragraph without changing + * the module's kind, which is the distinction authors actually make + * when they say "this section is bulleted".
+ * + * @since 2.3.0 + */ +public enum BodyStyle { + + /** + * Each body line is a paragraph of prose. The default: an item + * built without a stated style reads as text. + */ + PARAGRAPH, + + /** + * Each body line carries a bullet glyph and a hanging indent. + */ + BULLETS +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java new file mode 100644 index 000000000..db0f1716a --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java @@ -0,0 +1,189 @@ +package com.demcha.compose.document.templates.cv.data; + +import com.demcha.compose.document.templates.core.identity.Link; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * One entry inside a {@link ModuleSection} — the universal record every + * runtime-assembled module is built from. + * + *A job, a degree, a project, a skill category, a paragraph of a + * summary: all of them are a title plus some optional context plus a + * description. Rather than a record per shape, this carries every + * optional field and lets the section's {@link CvKind} decide which + * ones it reads — a {@code period} is drawn by + * {@link CvKind#ENTRIES_DATED} and ignored by {@link CvKind#ENTRIES}, + * with the same item on both sides. Each kind documents exactly what + * it reads.
+ * + *Only {@code title} is required, and only because a module entry + * with nothing to name it has nothing to render. Everything else is + * blank, {@code null}, or empty when the author has nothing to say — + * no placeholder text, no {@code "—"} stand-ins.
+ * + *Build one through {@link #of(String)} and the {@code with}-style + * methods, which read in the order the fields render:
+ * + *{@code
+ * CvItem.of("Senior Backend Engineer")
+ * .at("Acme GmbH")
+ * .in("Berlin, DE")
+ * .period("2021 - Present")
+ * .bullets("Cut p99 latency 40%", "Led the payments migration");
+ * }
+ *
+ * @param title what the entry is called; required, non-blank. May
+ * carry inline markdown, including {@code [text](url)}
+ * @param link optional click target for the title; {@code null}
+ * when the title is not a link. A {@code link} and a
+ * markdown link inside {@code title} do the same job —
+ * prefer this one, which needs no escaping
+ * @param subtitle employer, institution, client; blank when absent
+ * @param period date or range as the author wants it written
+ * ({@code "2021 - Present"}, {@code "2019"}); blank
+ * when absent, and read only by dated kinds
+ * @param location city, country, or "Remote"; blank when absent
+ * @param body description lines; empty when the entry is a
+ * heading only. One line renders as one paragraph or
+ * one bullet, per {@code bodyStyle}
+ * @param bodyStyle whether {@code body} reads as prose or as bullets
+ * @since 2.3.0
+ */
+public record CvItem(String title, Link link, String subtitle, String period,
+ String location, ListThis is the axis that lets one {@link CvItem} record serve every + * module: the kind decides which of the item's optional fields are + * read and which are ignored. An item carrying a + * {@code period} rendered under {@link #ENTRIES} simply does not show + * a date column — the same data under {@link #ENTRIES_DATED} does. + * Each constant below names exactly what it reads, so "ignored" is a + * documented contract rather than a surprise.
+ * + *Every kind lowers onto the renderers this package already ships + * (see {@code components.ModuleRenderer}); none of them draws + * anything a hand-built {@link RowsSection}, {@link EntriesSection} or + * {@link ParagraphSection} could not.
+ * + *The orthogonal axes are {@link SectionRole} — what the section + * means, which is what a multi-column preset places on — and + * {@link BodyStyle}, which decides how one item's description lines + * render. Keeping them apart is what lets a "Volunteering" module be + * shaped exactly like Education without a new type.
+ * + * @since 2.3.0 + */ +public enum CvKind { + + /** + * Prose — a summary, an objective, a statement. Each item renders + * as its description, one paragraph per body line. + * + *Reads {@code body} only. The {@code title} is ignored here on + * purpose: the section already carries a heading, and a prose block + * that repeated it would print the same words twice. For a labelled + * one-liner ({@code Languages: English, German}) reach for + * {@link #INLINE_LIST}, which is what that shape is.
+ */ + PARAGRAPH, + + /** + * A bullet per item, description on the same line — + * {@code • Throughput: doubled it}. The shape of a short list where + * each entry is a label and a value ({@link RowStyle#BULLETED}). + * + *Reads {@code title}, {@code link}, {@code body}. Ignores + * {@code subtitle}, {@code period}, {@code location}. A body of + * several lines is joined with spaces; if the lines are meant to + * stand apart, the module wants {@link #BULLETS_STACKED}.
+ */ + BULLETS, + + /** + * A bullet per item, description stacked underneath and indented to + * the title — the shape a Projects section takes when the + * description is a sentence rather than a value + * ({@link RowStyle#BULLETED_STACKED}). + * + *Reads {@code title}, {@code link}, {@code body}. Ignores + * {@code subtitle}, {@code period}, {@code location}.
+ * + *Inline or stacked is the module's choice, not something + * inferred from how long a description happens to be: the same + * section reads one way throughout, and an author who picked + * "bulleted list with descriptions underneath" gets it whether the + * first entry is one line or five.
+ */ + BULLETS_STACKED, + + /** + * One line per item, the description collapsed into a + * comma-separated run after a bold label — + * {@code Languages: Java 21, Kotlin, SQL}. The shape skills and + * languages take in a narrow column. + * + *Reads {@code title} and {@code body}. Ignores {@code link}, + * {@code subtitle}, {@code period}, {@code location}.
+ */ + INLINE_LIST, + + /** + * Timeline entries without the date column: bold title, italic + * subtitle line, description beneath. + * + *Reads {@code title}, {@code link}, {@code subtitle}, + * {@code location}, {@code body}. Ignores {@code period} — this + * is the kind to pick when the dates exist in the data but should + * not show.
+ */ + ENTRIES, + + /** + * Timeline entries with the date column right-aligned against the + * title — Education, Experience, and anything shaped like them. + * + *Reads every field: {@code title}, {@code link}, + * {@code subtitle}, {@code period}, {@code location}, + * {@code body}.
+ */ + ENTRIES_DATED +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java index e3da6bbe7..c5e2d2660 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java @@ -17,13 +17,21 @@ * items with four fields (title, subtitle, date, body). *Every implementation carries a {@code title} — the banner text * the renderer wraps in a styled panel above the section body.
*/ public sealed interface CvSection - permits ParagraphSection, RowsSection, EntriesSection, SkillsSection { + permits ParagraphSection, RowsSection, EntriesSection, SkillsSection, ModuleSection { /** * Banner heading shown above this section's body. diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java new file mode 100644 index 000000000..331078a78 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java @@ -0,0 +1,178 @@ +package com.demcha.compose.document.templates.cv.data; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * A section assembled at runtime: a heading, what it means + * ({@link SectionRole}), how it draws ({@link CvKind}), and the + * {@link CvItem}s it holds. + * + *The other {@link CvSection} implementations each fix one shape at + * compile time — {@link ParagraphSection} is prose, {@link RowsSection} + * is rows, {@link EntriesSection} is a timeline. That is the right + * model for a CV written in Java, where the author picks the record and + * the compiler checks it. It is the wrong one for a CV assembled from + * data at runtime: a user who has just chosen "Volunteering, shaped + * like Education, with dates" cannot instantiate a different record per + * choice, and every new shape would mean a new type.
+ * + *So this record moves the choice into a value. One item type carries + * every optional field; the kind decides which are read and which are + * ignored; the role says where the section belongs without a preset + * having to recognise its heading. The result is that a module nobody + * anticipated needs no new code — only a different + * {@code (role, kind)} pair.
+ * + *It renders through the same components as everything else. Every + * kind lowers onto {@link ParagraphSection}-, {@link RowsSection}- or + * {@link EntriesSection}-shaped output, so a module drawn as + * {@link CvKind#ENTRIES_DATED} is laid out exactly like the + * {@code EntriesSection} carrying the same content — which the parity + * suite holds to, layout node for layout node.
+ * + *{@code
+ * ModuleSection.builder("Volunteering", SectionRole.OTHER, CvKind.ENTRIES_DATED)
+ * .item(CvItem.of("Mentor, Rails Girls")
+ * .at("Rails Girls Berlin")
+ * .period("2019 - 2021")
+ * .bullets("Ran three weekend workshops"))
+ * .build();
+ * }
+ *
+ * @param title non-blank banner heading, in the author's own words
+ * @param role what the section means; {@link SectionRole#OTHER} when
+ * the catalogue has no name for it
+ * @param kind how the items draw
+ * @param items ordered items; null entries are dropped
+ * @since 2.3.0
+ */
+public record ModuleSection(String title, SectionRole role, CvKind kind, ListMulti-column presets have to decide what belongs in a sidebar, + * and until now they decided it by matching the section's title + * against a list of English keywords each preset kept privately. A CV + * whose headings read {@code "Ausbildung"} or {@code "Навыки"} matched + * nothing, and a heading nobody anticipated was placed by whatever the + * preset does with leftovers. The role carries that decision in the + * data, where the author already knows the answer.
+ * + *It is deliberately separate from {@link CvKind}: the role says + * what a section is, the kind says how it draws. A "Volunteering" + * module shaped exactly like Education is + * {@code role = OTHER, kind = ENTRIES_DATED} — a combination no single + * enum could express without one constant per pairing.
+ * + *{@link #OTHER} is the honest default and is never a second-class + * citizen: a preset that cannot place it by role falls back to the + * heading the author wrote, in document order.
+ * + * @since 2.3.0 + */ +public enum SectionRole { + + /** Profile, objective, professional summary — the opening prose. */ + SUMMARY, + + /** Employment history. */ + EXPERIENCE, + + /** Degrees, certifications, courses. */ + EDUCATION, + + /** Technical or professional skills, however they are grouped. */ + SKILLS, + + /** Personal or professional projects. */ + PROJECTS, + + /** Spoken languages and proficiency. */ + LANGUAGES, + + /** + * Anything else — awards, volunteering, publications, interests, + * references, a section this catalogue has no name for. Carries no + * placement hint, so presets fall back to the author's own + * heading. + */ + OTHER +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java index f5ff77798..54adaf5e0 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java @@ -40,8 +40,33 @@ * — grouped skills: category plus ordered skill labels. This * keeps skills semantic so presets can render them as tables, * sidebar chips, or inline rows without reparsing text. + *Writing a CV in Java: the four fixed shapes. The compiler checks + * the record you picked, and a project is visibly a + * {@code RowsSection} rather than a section that happens to hold + * rows.
+ * + *Assembling one from data — a form, a JSON payload, an LLM: the + * module. The section's shape and meaning arrive as values + * ({@code CvKind}, {@code SectionRole}), so a heading nobody + * anticipated — "Volunteering", shaped like Education — needs no new + * type and no new branch. Both routes render through the same + * components, and the parity suite holds them to laying out the same + * content identically, so the choice is about how the CV is authored, + * not about what it can look like.
+ * *Sections live inside a {@link com.demcha.compose.document.templates.cv.data.CvDocument}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java
index 6f70fd3ab..69e7e90c5 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 Stateless: every method takes its theme, so one instance serves
+ * every {@code create(theme)}. 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. 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. {@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. 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. The routing half is checked end to end by {@code RoleRoutingTest}, which
+ * renders a foreign-language CV through every preset. What that cannot see is
+ * the text itself: a preset draws whatever it is handed, so a module lowered
+ * with the wrong separator or a doubled label renders perfectly and reads
+ * wrong. These cases pin the strings. 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.{@code
+ * CvTemplates.byId("modern-professional")
+ * .orElseThrow()
+ * .compose(session, doc);
+ * }
+ *
+ *