diff --git a/CHANGELOG.md b/CHANGELOG.md index e44aa7b6..fa01a119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/readme/examples/cv-monogram-sidebar-v2.pdf b/assets/readme/examples/cv-monogram-sidebar-v2.pdf index cf236150..744d93f3 100644 Binary files a/assets/readme/examples/cv-monogram-sidebar-v2.pdf and b/assets/readme/examples/cv-monogram-sidebar-v2.pdf differ diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index bcfaf31f..e260f3a3 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -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 diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebarContentFidelityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebarContentFidelityTest.java new file mode 100644 index 00000000..dedaa342 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebarContentFidelityTest.java @@ -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. + * + *

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 of 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.

+ */ +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(); + } +} diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png index 6cc28e78..9a9bf5a0 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png differ diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java index 261bf077..2105d301 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MintEditorial.java @@ -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).

+ * + *

What this preset does not draw

+ * + *

Experience spans both pages, so a long career is carried in full. The + * two page-2 sidebar blocks are not: 6 expertise labels and + * 6 skill bars are drawn, and anything past that is + * not rendered — and neither the API nor the produced PDF says a + * skill was dropped. A document listing ten skills shows six of them.

+ * + *

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}.

*/ public final class MintEditorial { @@ -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; diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java index 09acff8d..af683895 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java @@ -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.

+ * + *

What this preset does not draw

+ * + *

The composition is fixed to one page, and each block is capped at the + * number of items that page was drawn for: 2 experience + * entries, 2 education entries, 7 skills, + * 3 projects and 3 additional-information + * rows. Anything past a cap is not rendered — 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.

+ * + *

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.

+ * + *

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.

*/ public final class MonogramSidebar { @@ -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 = @@ -693,6 +730,7 @@ private void addExperienceEntries(SectionBuilder section, return; } DocumentTextStyle positionStyle = mainEntryTitleStyle(); + DocumentTextStyle employerStyle = theme.entrySubtitleStyle(); DocumentTextStyle dateStyle = mainEntryDateStyle(); DocumentTextStyle bodyStyle = mainBodyStyle(); @@ -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( diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java index e57acc44..994b612b 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java @@ -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.

+ * + *

What this preset does not draw

+ * + *

Each block is capped at the number of items the composition was drawn + * for: 2 experience entries, 2 education + * entries, 5 skills, 3 languages and + * 2 projects. Anything past a cap is not rendered — + * 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.

+ * + *

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.

+ * + *

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.

*/ public final class SidebarPortrait { @@ -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 =