Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ follow semantic versioning; release dates are ISO 8601.
are drawn exactly as before. The committed visual baselines for the layered CV and
cover-letter presets moved by the ligatures alone and were re-recorded.

### Templates

- **Monogram Sidebar draws the employer.** Its experience entries rendered the position,
the date and the description, and never `CvEntry.subtitle()` — so every company name
was missing from the rendered CV while the education block, which does render its
subtitle, looked complete. The employer is now drawn between the position and the date,
in the shared theme entry-subtitle style.

### Documentation

- **The presets that cap content say so.** `MonogramSidebar`, `SidebarPortrait` and
`MintEditorial` compose a fixed amount of content: entries past a per-block cap are not
rendered, do not move to a continuation page, and are reported nowhere. Each preset's
class documentation now names its caps, explains that they are load-bearing — the
columns are one atomic `addRow`, so an uncapped block raises
`AtomicNodeTooLargeException` instead of spilling onto a second page — and points at
`TimelineMinimal`, which splits its own columns. `docs/templates/v2-layered/using-templates.md`
carries the same table under *Picking a preset*. The caps themselves are unchanged.

## v2.2.0 — 2026-08-15

### Public API
Expand Down
Binary file modified assets/readme/examples/cv-monogram-sidebar-v2.pdf
Binary file not shown.
23 changes: 23 additions & 0 deletions docs/templates/v2-layered/using-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,29 @@ BoxedSections.create(BrandTheme.boxedClassic()) // explicit
BoxedSections.create(myCustomTheme) // your own
```

### Presets that cap content

Three presets are compositions for a fixed amount of content rather than
layouts that grow with the document. Items past the cap are **not
rendered** — they do not move to a continuation page, and nothing in the
API or the produced PDF reports that they were dropped.

| Preset | Caps |
|---|---|
| `MonogramSidebar.create()` | 2 experience, 2 education, 7 skills, 3 projects, 3 additional-info rows |
| `SidebarPortrait.create()` | 2 experience, 2 education, 5 skills, 3 languages, 2 projects |
| `MintEditorial.create()` | 6 expertise labels, 6 skill bars (experience spans both pages in full) |

The caps are load-bearing, not a matter of taste: each of these presets
builds its columns as one `addRow`, and a row is atomic — it fits a page
whole or the paginator raises `AtomicNodeTooLargeException`. Lifting a
cap without teaching the preset to pick its own page boundaries turns a
CV that silently lost an entry into one that fails to render.

If the document's length is the author's rather than the template's,
pick a preset that paginates — `TimelineMinimal` splits its own columns
and carries every entry it is given onto as many pages as it needs.

`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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.demcha.compose.document.templates.cv.presets;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.templates.cv.data.CvDocument;
import com.demcha.compose.document.templates.cv.data.CvIdentity;
import com.demcha.compose.document.templates.cv.data.EntriesSection;
import com.demcha.compose.document.templates.cv.data.ParagraphSection;
import com.demcha.compose.document.templates.cv.data.SkillsSection;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/**
* What a CV rendered through Monogram Sidebar keeps, and what it is documented to drop.
*
* <p>The preset composes a fixed page, so the two are different questions. Which entries
* it draws is a design decision, stated in its class documentation and pinned here so
* that changing it is a change to a test rather than a silent change to everyone's CV.
* What it draws <em>of</em> an entry is not a decision at all: an experience entry's
* subtitle is the employer, and a CV that names a position and a date but no employer is
* simply wrong.</p>
*/
class MonogramSidebarContentFidelityTest {

@Test
void anExperienceEntryNamesTheEmployerItWasGiven() throws Exception {
String text = renderText(document());

assertThat(text)
.describedAs("the main column drew the position, the date and the "
+ "description of each job and never its employer, so every "
+ "company name was missing from the rendered CV")
.contains("Acme Rendering");
}

@Test
void theEntriesPastTheDocumentedCapAreTheOnlyOnesMissing() throws Exception {
String text = renderText(document());

// Positions render in caps, employers as written.
assertThat(text)
.describedAs("the two experience entries this preset draws")
.contains("SENIOR ENGINEER")
.contains("Acme Rendering")
.contains("Northwind Data");

assertThat(text)
.describedAs("a third job is dropped rather than paginated — the "
+ "class documentation says so, and a preset that started "
+ "carrying it should say so there first")
.doesNotContain("Nikoplast");
}

private static String renderText(CvDocument doc) throws Exception {
byte[] pdf;
try (DocumentSession session = GraphCompose.document()
.pageSize(DocumentPageSize.A4)
.margin(DocumentInsets.of(MonogramSidebar.RECOMMENDED_MARGIN))
.create()) {
MonogramSidebar.create().compose(session, doc);
pdf = session.toPdfBytes();
}
try (PDDocument document = Loader.loadPDF(pdf)) {
// Collapse the layout's own line breaks: this asks whether a phrase reached
// the page at all, and where the engine chose to wrap it is the visual
// regression tests' business.
return new PDFTextStripper().getText(document).replaceAll("\\s+", " ");
}
}

/** Three employers, so the cap has something to cut and the first two something to name. */
private static CvDocument document() {
return CvDocument.builder()
.identity(CvIdentity.builder()
.name("Jane", "Doe")
.jobTitle("Backend Engineer")
.contact("+44 0", "j@d.com", "London")
.build())
.sections(
new ParagraphSection("Professional Summary",
"Builds document pipelines across the JVM."),
SkillsSection.builder("Technical Skills")
.group("Languages", "Java 21", "Kotlin")
.build(),
EntriesSection.builder("Professional Experience")
.entry("Senior Engineer", "Acme Rendering",
"2021-2024", "Built rendering services.")
.entry("Engineer", "Northwind Data",
"2018-2021", "Owned the ingestion pipeline.")
.entry("Junior Engineer", "Nikoplast",
"2015-2018", "Maintained the order system.")
.build())
.build();
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,21 @@
* delegated to {@link IconTextRow} and {@link SkillBar}; the preset only
* orchestrates page composition, section mapping, and the Awards / References
* grids (which are page-composition concerns local to this layout).</p>
*
* <h2>What this preset does not draw</h2>
*
* <p>Experience spans both pages, so a long career is carried in full. The
* two page-2 sidebar blocks are not: <strong>6</strong> expertise labels and
* <strong>6</strong> skill bars are drawn, and anything past that is
* <em>not rendered</em> — and neither the API nor the produced PDF says a
* skill was dropped. A document listing ten skills shows six of them.</p>
*
* <p>The caps are load-bearing rather than a matter of taste. Each page is one
* atomic {@code addRow} (see above), so a sidebar that outgrows its page does
* not spill onto a third one — it raises {@code AtomicNodeTooLargeException}
* and the CV does not render at all. Lifting a cap means teaching the preset
* to choose its own page boundaries first, the way {@link TimelineMinimal}
* does with {@link ColumnPagination}.</p>
*/
public final class MintEditorial {

Expand Down Expand Up @@ -164,12 +179,12 @@ public final class MintEditorial {
private static final int EXPERIENCE_PAGE_ONE = 2;

/**
* Expertise category labels shown beneath the badge.
* Expertise category labels shown beneath the badge; the rest are dropped.
*/
private static final int EXPERTISE_LIMIT = 6;

/**
* Skill bars rendered in the page-2 sidebar.
* Skill bars rendered in the page-2 sidebar; the rest are dropped.
*/
private static final int SKILL_LIMIT = 6;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@
* any preset-side filler logic. The preset draws its visual ornaments
* (monogram ring, section rules) inline because none of these visuals
* are shared with another v2 preset today.</p>
*
* <h2>What this preset does not draw</h2>
*
* <p>The composition is fixed to one page, and each block is capped at the
* number of items that page was drawn for: <strong>2</strong> experience
* entries, <strong>2</strong> education entries, <strong>7</strong> skills,
* <strong>3</strong> projects and <strong>3</strong> additional-information
* rows. Anything past a cap is <em>not rendered</em> — it does not move to a
* second page, and neither the API nor the produced PDF says it was dropped.
* A CV with four jobs shows the first two.</p>
*
* <p>The caps are load-bearing rather than a matter of taste. The two columns
* are one {@code addRow}, and a row is atomic — it must fit a page whole or
* the paginator raises {@code AtomicNodeTooLargeException} rather than
* breaking inside it. Lifting a cap without teaching the preset to choose its
* own page boundaries turns a CV that silently lost an entry into one that
* fails to render at all.</p>
*
* <p>So this preset is a choice about how much a reader should see, not a
* layout that adapts to the document handed to it. For a CV whose length is
* the author's rather than the template's, use a preset that paginates —
* {@link TimelineMinimal} splits its own columns with {@link ColumnPagination}
* and carries every entry it is given onto as many pages as it needs.</p>
*/
public final class MonogramSidebar {

Expand Down Expand Up @@ -103,10 +126,24 @@ public final class MonogramSidebar {

private static final double MAIN_SECTION_RULE_WIDTH = 355.0;

/*
* How much of each block the one-page composition draws. Entries past a
* cap are dropped, not paginated — see the class documentation.
*/

/** Education entries drawn in the sidebar; the rest are dropped. */
private static final int EDUCATION_LIMIT = 2;

/** Skill tokens drawn in the sidebar; the rest are dropped. */
private static final int SKILL_LIMIT = 7;

/** Experience entries drawn in the main column; the rest are dropped. */
private static final int EXPERIENCE_LIMIT = 2;

/** Projects drawn in the main column; the rest are dropped. */
private static final int PROJECT_LIMIT = 3;

/** Additional-information rows drawn in the main column; the rest are dropped. */
private static final int ADDITIONAL_LIMIT = 3;

private static final String CONTACT_ICON_ROOT =
Expand Down Expand Up @@ -693,6 +730,7 @@ private void addExperienceEntries(SectionBuilder section,
return;
}
DocumentTextStyle positionStyle = mainEntryTitleStyle();
DocumentTextStyle employerStyle = theme.entrySubtitleStyle();
DocumentTextStyle dateStyle = mainEntryDateStyle();
DocumentTextStyle bodyStyle = mainBodyStyle();

Expand All @@ -705,6 +743,17 @@ private void addExperienceEntries(SectionBuilder section,
.lineSpacing(1.15)
.margin(DocumentInsets.top(5))
.rich(rich -> MarkdownInline.appendUpperCased(rich, entry.title(), positionStyle)));
if (!entry.subtitle().isBlank()) {
// The employer. Drawn on its own line between the position and the
// date, as the sidebar's education block draws the institution, so
// the two entry kinds read the same way down the page.
section.addParagraph(paragraph -> paragraph
.textStyle(employerStyle)
.align(TextAlign.LEFT)
.lineSpacing(1.15)
.margin(DocumentInsets.zero())
.rich(rich -> MarkdownInline.append(rich, entry.subtitle(), employerStyle)));
}
if (!entry.date().isBlank()) {
section.addParagraph(paragraph -> paragraph
.text(TextOrnaments.spacedUpper(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,29 @@
* continuation pages of multi-page CVs) without any preset-side
* filler logic. Use {@link Options} to override the sidebar fill,
* main fill or accent colour without forking the theme.</p>
*
* <h2>What this preset does not draw</h2>
*
* <p>Each block is capped at the number of items the composition was drawn
* for: <strong>2</strong> experience entries, <strong>2</strong> education
* entries, <strong>5</strong> skills, <strong>3</strong> languages and
* <strong>2</strong> projects. Anything past a cap is <em>not rendered</em> —
* it does not move to a continuation page, and neither the API nor the
* produced PDF says it was dropped. A CV with four jobs shows the first
* two.</p>
*
* <p>The caps are load-bearing rather than a matter of taste. The two columns
* are one {@code addRow}, and a row is atomic — it must fit a page whole or
* the paginator raises {@code AtomicNodeTooLargeException} rather than
* breaking inside it. Lifting a cap without teaching the preset to choose its
* own page boundaries turns a CV that silently lost an entry into one that
* fails to render at all.</p>
*
* <p>So this preset is a choice about how much a reader should see, not a
* layout that adapts to the document handed to it. For a CV whose length is
* the author's rather than the template's, use a preset that paginates —
* {@link TimelineMinimal} splits its own columns with {@link ColumnPagination}
* and carries every entry it is given onto as many pages as it needs.</p>
*/
public final class SidebarPortrait {

Expand Down Expand Up @@ -135,9 +158,21 @@ public final class SidebarPortrait {
*/
private static final double MAIN_SECTION_RULE_WIDTH = 346.0;

/*
* How much of each block the one-page composition draws. Entries past a
* cap are dropped, not paginated — see the class documentation.
*/

/** Education entries drawn in the sidebar; the rest are dropped. */
private static final int EDUCATION_LIMIT = 2;

/** Skill tokens drawn in the sidebar; the rest are dropped. */
private static final int SKILL_LIMIT = 5;

/** Language rows drawn in the sidebar; the rest are dropped. */
private static final int LANGUAGE_LIMIT = 3;

/** Experience entries drawn in the main column; the rest are dropped. */
private static final int EXPERIENCE_LIMIT = 2;

private static final String TEMPLATE_ASSET_ROOT =
Expand Down
Loading