Using Templates

July 6, 2026 · View on GitHub

You have a preset you like. You want to render your content (your name, your experience, your skills, your design tweaks). This doc walks the CvDocument builder, the section types, and the theme variants.

If you haven't read quickstart.md, do that first — it sets up the conceptual model in 5 minutes.

Dependency. These presets ship in the opt-in graph-compose-templates artifact (not bundled in graph-compose); add it, or use graph-compose-bundle. See the README install matrix.


Table of contents

  1. The pieces you assemble
  2. Identity — name, contact, optional links
  3. Section types
  4. Slots — main vs sidebar
  5. Picking a preset
  6. Customising a theme
  7. Rendering — pageSize, margins, output
  8. Common patterns

The Pieces You Assemble

CvDocument  doc      = …;                  // your content
BrandTheme     theme    = BrandTheme.boxedClassic();   // optional override
DocumentTemplate<CvDocument> tpl = BoxedSections.create(theme);

try (DocumentSession s = GraphCompose.document(path).create()) {
    tpl.compose(s, doc);
    s.buildPdf();
}

Three lines of "what":

  • CvDocument — your content. Built via builder.
  • BrandTheme — visual style. Use a shipped factory or build your own.
  • A preset — orchestrates them into a page flow.

Every CV starts with a CvIdentity. Two required pieces, links are optional.

CvIdentity identity = CvIdentity.builder()
    // Required: first + last (middle is optional)
    .name("Jane", "Doe")
    // .name("Jane", "Q.", "Doe")              // ← with middle name

    // Required: phone + email + address (none can be blank)
    .contact("+44 20 7946 0958",
             "jane.doe@example.com",
             "London, UK")

    // Optional: any number of labelled links
    .link("LinkedIn", "https://linkedin.com/in/jane-doe")
    .link("GitHub",   "https://github.com/jane-doe")
    .link("Portfolio","https://jane.dev")
    .build();

No .link(...) calls? Header just renders the phone / email / address. Optional means truly optional.

Label is a free string. "Behance", "Substack", "Etsy", anything. The widget renders the label; the URL is the click target.


Section Types

The CvSection sealed hierarchy has a small set of concrete shapes. Each captures a structurally different content pattern, not a visual flavour.

1. ParagraphSection — one block of prose

For Professional Summary, Profile, Objective, About Me.

new ParagraphSection("Professional Summary",
    "Backend engineer with **5 years** of experience...");

Inline markdown (**bold**, *italic*, _italic_) is honoured.

2. SkillsSection — grouped skills

For Technical Skills and similar capability groups where the content is naturally category -> skills[].

SkillsSection.builder("Technical Skills")
    .group("Languages", "Java 21", "Kotlin", "SQL")
    .group("Tools", "Maven", "Docker", "GitHub Actions")
    .build();

Keeping skills grouped means the same CV data can render as bullets, a grid/table, sidebar chips, or compact inline rows depending on the preset.

3. RowsSection — list of label/body rows with a decoration style

For Languages, Awards, Additional Information, Projects, anything with "label: value" entries that is not a skill taxonomy.

RowsSection.builder("Additional Information", RowStyle.PLAIN)
    .row("Languages", "English (Fluent), German (Intermediate)")
    .row("Work Eligibility", "Eligible to work in the UK and the EU")
    .build();

Three decoration styles — pick by what you want visually:

RowStyle.PLAIN              // <b>Label:</b> body  (no bullet, single line)
RowStyle.BULLETED           // • <b>Label:</b> body
RowStyle.BULLETED_STACKED   // • <b>Label</b>
                            //   body (on second line, indented)

The same RowsSection type covers Additional Information and Projects — pick the style that matches the visual density you want.

4. EntriesSection — timeline entries (title / subtitle / date / body)

For Education, Professional Experience — anything where you have a list of items each with a title, subtitle, date, and description.

EntriesSection.builder("Experience")
    .entry("Senior Engineer",      // title (bold)
           "Acme Payments",          // subtitle (italic, muted)
           "2022-Present",           // date (right-aligned)
           "Owned the settlement service...")    // body (paragraph)
    .entry("Engineer",
           "Bright Bank",
           "2019-2022",
           "Built the fraud-detection rule engine.")
    .build();

Blank fields collapse — a blank date removes the right column, a blank subtitle drops the italic line, a blank body drops the paragraph beneath.


Slots — main vs sidebar

By default, every section is placed in Slot.MAIN — the main column. Multi-column presets read Slot.SIDEBAR separately.

CvDocument doc = CvDocument.builder()
    .identity(identity)
    .section(summary)                              // → MAIN (default)
    .section(Slot.MAIN, skills)                    // → MAIN (explicit)
    .section(Slot.SIDEBAR, languagesSpoken)        // → SIDEBAR
    .sections(Slot.MAIN, experience, education)    // varargs → MAIN
    .build();

Single-column presets (BoxedSections, MinimalUnderlined, ModernProfessional, CenteredHeadline, BlueBanner, EditorialBlue, ClassicSerif) render only Slot.MAIN. NordicClean and CompactMono also read Slot.MAIN, but lay it out as their own two-column rail/body compositions. Sidebar content is silently dropped — switch to a multi-column preset to render it.

If you don't use slots at all, your sections go to MAIN and every preset renders them. The slot model is opt-in.


Picking a preset

Nine shipped today:

PresetVisual signature
BoxedSections.create()Centred letter-spaced name, pale-grey panel section banners, two-page friendly
MinimalUnderlined.create()Centred name with thin rule, small spaced-caps section titles with accent rule, single page
ModernProfessional.create()Right-aligned big slate-blue name, flat bright-blue bold section titles, dense single page
CenteredHeadline.create()Centred spaced-caps name, small subheadline, full-width rules around contact and modules
BlueBanner.create()Centred PT-Serif name, compact Lato body, blue full-width section banners between thin rules
EditorialBlue.create()Centred uppercase masthead, optional job-title subtitle, blue editorial rules, compact skills table
ClassicSerif.create()PT-Serif cover/detail layout, cream profile band, tan rules
NordicClean.create()Barlow uppercase identity, teal profile band, tinted sidebar rail, compact main column
CompactMono.create()Dark command-bar header, pale left rail, same-width right-column cards

Each factory has a no-arg form (uses a sensible default theme) and a create(BrandTheme) form (custom theme).

BoxedSections.create()                          // default theme
BoxedSections.create(BrandTheme.boxedClassic())    // explicit
BoxedSections.create(myCustomTheme)             // your own

NordicClean also exposes preset-specific options because its signature has a structural rail and three editable colour surfaces: the accent rules/links/name underline, the rail fill, and the profile band fill.

NordicClean.create(
    BrandTheme.nordicClean(),
    NordicClean.Options.builder()
        .railSide(NordicClean.RailSide.RIGHT)   // skills rail on the right
        .accentColor(DocumentColor.rgb(40, 110, 120))
        .railFillColor(DocumentColor.rgb(244, 249, 249))
        .profileFillColor(DocumentColor.rgb(226, 244, 245))
        .build());

Customising a theme

Themes are records made of four sub-records:

Sub-recordWhat it controls
PaletteColours (ink, muted, rule, banner)
TypographyFonts + size scale (8 sizes + line spacing)
SpacingMargins, padding, weights, gaps
DecorationBullet glyph, stacked indent, contact separator

Swap one piece, keep the rest:

// Navy palette, classic everything else
Palette navy = new Palette(
    DocumentColor.rgb(15, 34, 80),     // ink — primary text
    DocumentColor.rgb(90, 110, 150),   // muted — italic subtitles
    DocumentColor.rgb(120, 140, 180),  // rule — separator lines
    DocumentColor.rgb(220, 230, 240)); // banner — pale fill

BrandTheme navyTheme = new BrandTheme(
    navy,
    Typography.classic(),
    Spacing.classic(),
    Decoration.classic());

BoxedSections.create(navyTheme);

Change a glyph (bullet, separator):

Decoration arrowDecoration = new Decoration(
    "▶ ",       // bullet glyph
    "  ",       // stacked-row second-line indent
    "  ·  ");   // contact-line separator

BrandTheme theme = new BrandTheme(
    Palette.classic(),
    Typography.classic(),
    Spacing.classic(),
    arrowDecoration);

Change a font (Helvetica instead of PT Serif):

Typography sans = new Typography(
    FontName.HELVETICA_BOLD, FontName.HELVETICA,
    21.5, 8.5, 9.6, 9.2, 8.8, 8.4, 8.6, 1.4);  // sizes per role

BrandTheme theme = new BrandTheme(
    Palette.classic(), sans, Spacing.classic(), Decoration.classic());

For more recipes (compact spacing, alternative typography scales, etc.) see cv/AUTHORS.md.


Rendering — pageSize, margins, output

Standard session-first API. The preset has a RECOMMENDED_MARGIN constant that pairs visually with its design.

float m = (float) BoxedSections.RECOMMENDED_MARGIN;  // 28pt for Boxed

try (DocumentSession session = GraphCompose.document(Path.of("cv.pdf"))
        .pageSize(DocumentPageSize.A4)
        .margin(m, m, m, m)
        .create()) {

    template.compose(session, doc);
    session.buildPdf();          // writes the file
}

Other output forms:

session.toPdfBytes();        // byte[]
session.buildPdf(output);    // OutputStream

Common patterns

Conditional section (omit if data is empty)

CvDocument.Builder b = CvDocument.builder().identity(identity);
b.section(summary);
if (!certificates.isEmpty()) {
    b.section(buildCertificationsSection(certificates));
}
CvDocument doc = b.build();
CvDocument.builder()
    .identity(identity)
    .section(summary)                              // main
    .sections(Slot.SIDEBAR, skills, languages)     // sidebar
    .build();

Then render with a multi-column preset (when one ships) — sidebar content is dropped by single-column presets today.

Skip section title

The section's title is rendered by the preset. To suppress a section title visually you'd need a preset that doesn't render it (or write your own — see authoring-presets.md).

Persona-neutral content

Nothing in the API assumes a developer audience. A teacher's CV looks the same — different strings, same builders:

CvDocument.builder()
    .identity(CvIdentity.builder()
        .name("Maria", "Lopez")
        .contact("+34 600 000 000", "maria@example.com", "Madrid, Spain")
        // no .link() — Maria has no public profiles
        .build())
    .section(new ParagraphSection("About Me",
        "Primary school teacher with 12 years' experience."))
    .section(EntriesSection.builder("Teaching Experience")
        .entry("Lead Teacher Y3", "Colegio Santa Ana", "2018-Present",
               "Year-3 lead, mentored two NQTs.")
        .build())
    .section(RowsSection.builder("Languages", RowStyle.PLAIN)
        .row("Spanish",   "Native")
        .row("English",   "Fluent (CEFR C1)")
        .build())
    .build();

No GitHub, no Projects, no Tech Skills — and the API doesn't notice.


Next step

→ Want a custom visual style on top of the v2 building blocks? authoring-presets.md

→ Reference for every recipe (change bullet, swap colours, …) cv/AUTHORS.md