You like the layered architecture, but the shipped presets
(BoxedSections, MinimalUnderlined, ModernProfessional,
CenteredHeadline, BlueBanner, EditorialBlue, ClassicSerif,
NordicClean, CompactMono) don't match the design you want. This
doc walks you through writing a new preset from scratch — without
subclassing, without duplicating rendering code.
If you haven't read quickstart.md and using-templates.md, do those first.
- The core idea — compose, don't subclass
- The widget catalog
- Anatomy of a preset
- Full worked example —
CardStylepreset - When the widget doesn't fit — go inline
- Three layers of widget customisation
- Adding a new widget — the test of when
- Tests + render parity
A preset is one public final class (no inheritance) with a
create() factory that returns a DocumentTemplate<CvDocument>.
Inside, compose() is the orchestration method: it sequences
widgets in a page flow.
@Override
public void compose(DocumentSession document, CvDocument doc) {
document.dsl().pageFlow()
.name("MyPresetRoot")
.spacing(theme.spacing().pageFlowSpacing())
.addSection("Headline", s -> Headline.spacedCentered(s, doc.identity().name(), theme))
.addSection("Contact", s -> ContactLine.centered(s, doc.identity(), theme));
for (CvSection sec : doc.sectionsIn(Slot.MAIN)) {
pageFlow
.addSection("Title", s -> SectionHeader.banner(s, sec.title(), theme))
.addSection("Body", s -> SectionDispatcher.renderBody(s, sec, theme));
}
pageFlow.build();
}That's the entire rendering decision tree. ~12 lines. No DSL
plumbing. No private renderXxx methods. Each line is a single
visual decision you can read like a recipe.
The CV widget classes live in
com.demcha.compose.document.templates.cv.v2.widgets. Each has a
small set of named variants. Generic widgets that can be reused by
CVs, proposals, invoices, and cover letters live one package higher
in com.demcha.compose.document.templates.widgets.
| Variant | Visual |
|---|---|
Headline.spacedCentered(host, name, theme) |
Centred letter-spaced uppercase (J A N E D O E) |
Headline.uppercaseCentered(host, name, theme) |
Centred uppercase without extra spacing (JANE DOE) |
Headline.uppercaseLeftAligned(host, name, theme) |
Left-aligned uppercase without extra spacing (JANE DOE) |
Headline.rightAligned(host, name, theme) |
Right-aligned plain bold (Jane Doe) |
Headline.render(host, name, theme, align, spacedCaps) |
Low-level — any (alignment, transform) combo |
| Variant | Visual |
|---|---|
Subheadline.centeredSpacedCaps(host, text, style) |
Centred letter-spaced uppercase tagline (P R O F E S S I O N A L T I T L E) |
| Variant | Visual |
|---|---|
ContactLine.centered(host, identity, theme) |
Centred, phone → email → address → links |
ContactLine.centered(host, identity, theme, bodyStyle, linkStyle, separatorStyle) |
Centred contact row with explicit style overrides |
ContactLine.rightAligned(host, identity, theme) |
Right-aligned, address → phone → email → links |
ContactLine.leftAligned(host, identity, theme[, bodyStyle, linkStyle, separatorStyle]) |
Left-aligned command-bar row with explicit style overrides when needed |
ContactLine.rightAlignedStacked(host, identity, theme, bodyStyle, linkStyle) |
Right-aligned vertical stack, one contact item per line |
ContactLine.twoRowRightAligned(host, identity, theme, bodyStyle, linkStyle, separatorStyle) |
Right-aligned address/phone row plus email/link row |
ContactLine.render(host, identity, theme, align, order) |
Low-level — any alignment + field-order combo |
| Variant | Visual |
|---|---|
SectionHeader.banner(host, title, theme) |
Pale-grey panel + centred spaced-caps inside |
SectionHeader.fullWidthBanner(host, title, theme[, style]) |
Full-width fill banner + centred spaced-caps inside; surrounding rules stay in preset page flow |
SectionHeader.underlined(host, title, theme) |
Small left spaced-caps + thin rule below |
SectionHeader.flat(host, title, color, theme) |
Large bold title in a given colour, no panel |
SectionHeader.flatSpacedCaps(host, title, color, theme, titleStyle) |
Small left spaced-caps title in a soft colour, no panel |
SectionHeader.tickLabel(host, title, theme, color, tickWidth[, titleStyle]) |
Short accent tick above compact uppercase label |
SectionHeader.upperRule(host, title, theme, titleStyle, ruleColor, ruleWidth) |
Uppercase label with short rule below |
SectionHeader.spacedCapsRule(host, title, theme, titleStyle, ruleColor, ruleWidth, ruleThickness, ruleMargin) |
Spaced-caps label with short rule below |
| Variant | Visual |
|---|---|
SkillBar.render(host, skill, trackWidth, theme) |
Spaced-caps skill label above a thin track with a level-positioned marker; renders the label with no bar when skill.level() is absent |
Reads the level from CvSkill.level() ([0, 1]); used by the Mint
Editorial skills sidebar.
| Variant | Visual |
|---|---|
IconTextRow.render(host, icon, iconSize, text, style, link, margin) |
A glyph image followed by a label on one baseline; the whole row is a single click target when a link is supplied |
Used for the icon-led contact and social rows in sidebar CV layouts (Mint Editorial).
| Widget | Visual |
|---|---|
Masthead.centered(host, identity, theme, style) |
Centred editorial identity block: name, optional title, metadata, link row |
FlowSectionHeader.banner(...) / FlowSectionHeader.label(...) |
Page-flow-level headers where the surrounding rules are outside the body section |
ProfileBand.render(...) |
Tinted/ruled summary block with markdown-aware body text |
SectionModule.tick(...) / SectionModule.upperRule(...) |
Named rail/card module that combines a section-header variant with caller-supplied body content |
| Widget | Visual |
|---|---|
TableWidget.fixed(...) / TableWidget.grid(...) |
Configurable tables/grids with borders, fills, zebra rows, padding, typography, and column count |
CardWidget.render(...) |
Reusable card/container shell with spacing, padding, fill, stroke, and corner radius |
The separator glyph used by ContactLine, the bullet glyph used by
RowRenderer, and other character-level choices come from
theme.decoration() — swap a CvDecoration to change them
globally.
Some presets also expose narrow preset-specific options when the
visual decision is structural rather than a reusable widget. Example:
NordicClean.Options lets authors move the skills rail to the right
and override the accent colour, rail fill, or profile-band fill
without mutating shared CvTheme defaults or changing other presets.
Every preset is the same skeleton:
public final class MyPreset {
public static final String ID = "my-preset";
public static final String DISPLAY_NAME = "My Preset";
public static final double RECOMMENDED_MARGIN = 28.0;
private MyPreset() { }
public static DocumentTemplate<CvDocument> create() {
return create(CvTheme.boxedClassic());
}
public static DocumentTemplate<CvDocument> create(CvTheme theme) {
Objects.requireNonNull(theme, "theme");
return new Template(theme);
}
private static final class Template implements DocumentTemplate<CvDocument> {
private final CvTheme theme;
Template(CvTheme theme) { this.theme = theme; }
@Override public String id() { return ID; }
@Override public String displayName() { return DISPLAY_NAME; }
@Override
public void compose(DocumentSession document, CvDocument doc) {
// ← the only place that varies between presets
}
}
}Two factories (create() and create(CvTheme)), three constants
(ID, DISPLAY_NAME, RECOMMENDED_MARGIN), one inner Template
class implementing DocumentTemplate<CvDocument>. Stable.
Suppose you want a preset where each section is wrapped in a soft card with a coloured left accent stripe. Here's the full preset.
public final class CardStyle {
public static final String ID = "card-style";
public static final String DISPLAY_NAME = "Card Style";
public static final double RECOMMENDED_MARGIN = 24.0;
private static final DocumentColor ACCENT = DocumentColor.rgb(33, 150, 243);
private CardStyle() { }
public static DocumentTemplate<CvDocument> create() {
return create(CvTheme.boxedClassic());
}
public static DocumentTemplate<CvDocument> create(CvTheme theme) {
Objects.requireNonNull(theme, "theme");
return new Template(theme);
}
private static final class Template implements DocumentTemplate<CvDocument> {
private final CvTheme theme;
Template(CvTheme theme) { this.theme = theme; }
@Override public String id() { return ID; }
@Override public String displayName() { return DISPLAY_NAME; }
@Override
public void compose(DocumentSession document, CvDocument doc) {
PageFlowBuilder pageFlow = document.dsl().pageFlow()
.name("CardStyleRoot")
.spacing(8)
.addSection("Headline", s ->
Headline.rightAligned(s, doc.identity().name(), theme))
.addSection("Contact", s ->
ContactLine.rightAligned(s, doc.identity(), theme));
for (CvSection sec : doc.sectionsIn(Slot.MAIN)) {
pageFlow.addSection("Card", host -> {
host.accentLeft(ACCENT, 3.0) // ← the "card" stripe
.padding(new DocumentInsets(8, 12, 8, 12));
SectionHeader.flat(host, sec.title(), ACCENT, theme);
SectionDispatcher.renderBody(host, sec, theme);
});
}
pageFlow.build();
}
}
}Forty-five lines including the boilerplate. Everything that
makes it visually distinct is in compose():
- right-aligned headline (existing widget)
- right-aligned contact (existing widget)
- a custom "card" wrapper around each section (inline — uses DSL
accentLeft+paddingdirectly) - flat coloured section title (existing widget, given
ACCENT) - body rendered via the dispatcher (no custom rendering)
You used three widgets (Headline, ContactLine,
SectionHeader) plus two inline DSL calls (accentLeft and
padding) to build the card shape. No private renderXxx. No
duplicated rendering.
Widgets are optional helpers, not required wrappers. If your preset needs something the catalog doesn't cover, inline it.
Example: ModernProfessional uses preset-specific colours
(slate-blue name, royal-blue link underlines) that no widget
default knows about. Its renderHeader and renderContact stay
inline — only renderSectionTitle uses a widget
(SectionHeader.flat(..., SECTION_TITLE_COLOR, theme) because that
widget takes a colour parameter).
private void renderHeader(SectionBuilder section, CvIdentity identity) {
DocumentTextStyle nameStyle = DocumentTextStyle.builder()
.fontName(FontName.HELVETICA_BOLD)
.size(theme.typography().sizeHeadline())
.color(NAME_COLOR)
.build();
section.addParagraph(p -> p
.text(identity.name().full())
.textStyle(nameStyle)
.align(TextAlign.RIGHT)
.margin(DocumentInsets.zero()));
}This is fine. Widgets coexist with inline DSL in the same
compose(). If you see the same inline rendering repeating across
2+ presets, then extract a widget — not before.
Every widget exposes three layers, escalating from convenience to control:
Headline.spacedCentered(host, name, theme);One line. No params beyond (host, content, theme).
Headline.render(host, name, theme, TextAlign.LEFT, /* spacedCaps */ false);Same widget, fully parameterised. Use when the convenience method doesn't match your need but the widget shape is right.
section.addParagraph(p -> p
.text(name.full())
.textStyle(myCustomStyle)
.align(TextAlign.RIGHT));Bypass the widget entirely. Use when no widget shape fits.
Don't fight the widget API. If Layer 1 fits, use Layer 1. If not, try Layer 2. If still not, go inline. That's the design.
| Pattern repetition across presets | Action |
|---|---|
| 1 preset only | Inline. Don't extract. |
| 2 presets | Add a new factory method to an existing widget, OR add a parameter to .render(...). |
| 3+ presets | It's its own widget — new class in cv/v2/widgets/. |
Don't predict — extract. Premature widgets are noise; they add API surface that nobody calls.
When you do add a new widget:
- One file per widget in
cv/v2/widgets/. public final classwith a private constructor.- 1-3 named factories + a lower-level
.render(...)when useful. - First parameter is always
SectionBuilder host. - Pass
CvTheme themewhen the widget reads shared tokens; pass an explicit style only when the preset owns that unique style. - No instance state — all static, all stateless.
- JavaDoc the visual — what does this look like? Who uses it?
- Add to
WidgetSmokeTestwith a basic "renders without throwing" check.
A new preset needs at least:
-
Smoke test in
src/test/.../cv/v2/presets/MyPresetSmokeTest.java:exposes_stable_identity— checksid()anddisplayName()default_factory_renders— callscreate().compose(...)with a full sample document, assertssession.roots()is non-emptycustom_theme_renders— same but withcreate(theme)renders_with_classic_theme_too— proves the preset doesn't depend on theme-specific tokens
-
Example runner in
examples/src/main/java/com/demcha/examples/templates/cv/v2/CvMyPresetExample.java:- Renders to
examples/target/generated-pdfs/templates/cv/cv-my-preset.pdf - Uses
ExampleDataFactory.sampleCvDocumentV2()for content
- Renders to
-
Eyeball the rendered PDF — does it match your design intent? Are sections in the right slots? Is page break sensible?
A future Phase will add PDF/PNG snapshot diffing so visual regressions break the build. Until then, render parity is by-hand.
→ Want to add a brand-new template family (invoice-v2, cover-letter-v2) following the same layered shape? contributor-guide.md
→ The full recipe cookbook (with code for every customisation
combo):
cv/v2/AUTHORS.md