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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,35 @@
All notable changes to GraphCompose are documented here. Versions
follow semantic versioning; release dates are ISO 8601.

## v2.1.2 — Planned

### Templates

- **Timeline Minimal renders the whole CV.** The preset used to drop content three
ways, none of them visible in the output. Per-module caps kept the first few lines
of each block and discarded the rest, so a fourth degree or a third employer simply
was not drawn — on a page that the fixed-height axis left looking only four-fifths
used. Prose was cut at a character count, ending a summary mid-sentence with an
ellipsis while the column still had room. And sections were matched to modules by
title keyword, taking the first hit for each category: a second prose section was
shadowed by the first, a section whose title matched nothing — a user's own
"Awards" or "Publications" — was never looked at, and the ones that did match were
relabelled, so "Projects" printed as EXPERTISE and "Additional Information"
printed as LANGUAGES.

Everything the document carries is now rendered. Headings come from the section's
own title, with the preset's label left only for a module that matched nothing.
Content past one page continues on the next: the body is a row, and a row is
atomic — the paginator cannot break inside one — so the preset estimates its
columns' heights from the column width and font metrics, emits one row per page,
and lets each finished row overflow naturally. The axis keeps its full height on
the opening page and follows the content on a continuation page.

- **New `SectionAllocation` for CV presets.** Hands each section out once and
returns what no module claimed, which is what `SectionLookup.firstMatching` alone
cannot express. The remaining CV presets still slot by keyword and still discard
what does not match; they are unchanged here.

## v2.1.1 — 2026-08-05

### Build
Expand Down
Binary file modified assets/readme/examples/cv-timeline-minimal-v2.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@
* grouped skills sample data — spaced uppercase Barlow Condensed
* name, right-aligned contact stack with PNG icons, and the central
* vertical timeline axis (4 segments / 3 circles) separating the
* sidebar (Education / Skills / Expertise / Languages) from the main
* column (Professional Profile / Work Experience).
* sidebar from the main column.
*
* <p>Module headings come from the sample's own section titles, so the
* sidebar reads Education &amp; Certifications / Technical Skills /
* Projects and the main column Professional Summary / Professional
* Experience. The sample carries more than one page of content: the
* remainder continues on page two, where the axis shortens to match.</p>
*
* <p>Output:
* {@code examples/target/generated-pdfs/templates/cv/cv-timeline-minimal-v2.pdf}.</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.demcha.compose.document.templates.cv.components;

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.ParagraphSection;
import com.demcha.compose.document.templates.cv.data.RowStyle;
import com.demcha.compose.document.templates.cv.data.RowsSection;
import org.junit.jupiter.api.Test;

import java.util.List;

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

/**
* {@link SectionAllocation} hands each section out once and keeps the rest.
*
* <p>The behaviour worth pinning is what a preset built on
* {@link SectionLookup#firstMatching} could not do: notice that a section was
* never asked for, and tell two sections apart when both answer to the same
* keywords.</p>
*/
class SectionAllocationTest {

private static final ParagraphSection SUMMARY =
new ParagraphSection("Professional Summary", "Builds pipelines.");
private static final ParagraphSection PROFILE =
new ParagraphSection("Profile", "A second prose block.");
private static final RowsSection AWARDS =
RowsSection.builder("Awards", RowStyle.PLAIN)
.row("Rising Star", "2019")
.build();

@Test
void claimReturnsTheFirstMatchInDocumentOrder() {
SectionAllocation allocation = SectionAllocation.of(
List.of(SUMMARY, PROFILE, AWARDS));

assertThat(allocation.claim(List.of("summary", "profile")))
.isSameAs(SUMMARY);
}

@Test
void aClaimedSectionIsNotHandedOutTwice() {
SectionAllocation allocation = SectionAllocation.of(
List.of(SUMMARY, PROFILE));

CvSection first = allocation.claim(List.of("summary", "profile"));
CvSection second = allocation.claim(List.of("summary", "profile"));

assertThat(first).isSameAs(SUMMARY);
assertThat(second)
.describedAs("the second module must see the second section, "
+ "not the one already spoken for")
.isSameAs(PROFILE);
}

@Test
void remainingKeepsWhatNoModuleAskedForInDocumentOrder() {
SectionAllocation allocation = SectionAllocation.of(
List.of(SUMMARY, AWARDS, PROFILE));
allocation.claim(List.of("summary"));

assertThat(allocation.remaining())
.describedAs("an unclaimed section is the one a keyword-only "
+ "preset loses without trace")
.containsExactly(AWARDS, PROFILE);
}

@Test
void remainingIsEmptyOnceEverySectionIsSpokenFor() {
SectionAllocation allocation = SectionAllocation.of(List.of(SUMMARY));
allocation.claim(List.of("summary"));

assertThat(allocation.remaining()).isEmpty();
}

@Test
void anEmptySectionIsNotOfferedAsLeftoverWork() {
CvSection empty = EntriesSection.builder("Publications").build();
SectionAllocation allocation = SectionAllocation.of(List.of(SUMMARY, empty));

assertThat(allocation.remaining())
.describedAs("rendering a heading with nothing under it is worse "
+ "than skipping the section")
.doesNotContain(empty);
}

@Test
void claimReturnsNullWhenNothingMatches() {
SectionAllocation allocation = SectionAllocation.of(List.of(SUMMARY));

assertThat(allocation.claim(List.of("references"))).isNull();
assertThat(allocation.remaining()).containsExactly(SUMMARY);
}

@Test
void nullInputsAreToleratedTheWayPresetCallSitesExpect() {
SectionAllocation allocation = SectionAllocation.of(null);

assertThat(allocation.remaining()).isEmpty();
assertThat(allocation.claim(List.of("summary"))).isNull();
assertThat(SectionAllocation.of(List.of(SUMMARY)).claim(null)).isNull();
}

@Test
void theSectionsOwnTitleWinsOverThePresetsLabel() {
assertThat(SectionAllocation.titleOr(SUMMARY, "Profile"))
.isEqualTo("Professional Summary");
}

@Test
void thePresetsLabelIsUsedOnlyWhenNoSectionMatched() {
assertThat(SectionAllocation.titleOr(null, "Languages"))
.isEqualTo("Languages");
}

@Test
void aBlankTitleCannotReachTheLabelBecauseTheDataRejectsItFirst() {
assertThatThrownBy(() -> new ParagraphSection(" ", "body"))
.describedAs("titleOr has no blank branch precisely because "
+ "this constructor makes one unreachable")
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("blank");
}

@Test
void aMissingFallbackLabelIsARejectedArgument() {
assertThatThrownBy(() -> SectionAllocation.titleOr(SUMMARY, null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("fallback");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package com.demcha.compose.document.templates.cv.presets;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;

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

/**
* {@link ColumnPagination} decides page boundaries without losing lines.
*
* <p>The property that matters is conservation: a preset reached for this
* because dropping the overflow is the bug it replaced, so every case below
* checks that what went in comes back out, whatever the budget.</p>
*/
class ColumnPaginationTest {

/** A sidebar-shaped column: 148pt wide, 7.5pt body, 12.5pt heading. */
private static ColumnPagination sidebar() {
return ColumnPagination.forColumn(148.0, 7.5, 1.0, 12.5, 22.0);
}

private static ColumnPagination.Block block(String title, String... lines) {
return new ColumnPagination.Block(title, List.of(lines), false);
}

private static List<String> allLines(List<List<ColumnPagination.Block>> pages) {
List<String> flat = new ArrayList<>();
for (List<ColumnPagination.Block> page : pages) {
for (ColumnPagination.Block block : page) {
flat.addAll(block.lines());
}
}
return flat;
}

@Test
void aColumnThatFitsStaysOnOnePage() {
List<List<ColumnPagination.Block>> pages = sidebar().paginate(
List.of(block("Education", "MSc", "BEng")), 600.0);

assertThat(pages).hasSize(1);
assertThat(pages.get(0)).hasSize(1);
assertThat(pages.get(0).get(0).lines()).containsExactly("MSc", "BEng");
}

@Test
void everyLineSurvivesHoweverManyPagesItTakes() {
List<String> lines = IntStream.range(0, 60)
.mapToObj(i -> "entry " + i).toList();

List<List<ColumnPagination.Block>> pages = sidebar().paginate(
List.of(new ColumnPagination.Block("Skills", lines, false)), 200.0);

assertThat(pages.size())
.describedAs("60 lines cannot fit a 200pt page")
.isGreaterThan(1);
assertThat(allLines(pages))
.describedAs("conservation is the whole point — the previous "
+ "behaviour kept a prefix and dropped the rest")
.containsExactlyElementsOf(lines);
}

@Test
void aBlockCarriedAcrossPagesRepeatsItsHeading() {
List<String> lines = IntStream.range(0, 40)
.mapToObj(i -> "entry " + i).toList();

List<List<ColumnPagination.Block>> pages = sidebar().paginate(
List.of(new ColumnPagination.Block("Skills", lines, false)), 200.0);

assertThat(pages).allSatisfy(page ->
assertThat(page).allSatisfy(block ->
assertThat(block.title())
.describedAs("a continuation without its heading "
+ "reads as part of the block above it")
.isEqualTo("Skills")));
}

@Test
void aBlockThatDoesNotFitTheRemainderStartsTheNextPageWhole() {
ColumnPagination column = sidebar();
// Sized so the first block fills most of the page and the second
// cannot start on what is left.
List<List<ColumnPagination.Block>> pages = column.paginate(
List.of(block("First", "a", "b", "c", "d", "e", "f"),
block("Second", "x", "y", "z")),
140.0);

assertThat(pages.size()).isGreaterThan(1);
List<ColumnPagination.Block> secondBlockPieces = pages.stream()
.flatMap(List::stream)
.filter(b -> b.title().equals("Second"))
.toList();
assertThat(secondBlockPieces)
.describedAs("the second block should move whole rather than "
+ "leave one orphaned line under a heading at the foot")
.hasSize(1);
assertThat(secondBlockPieces.get(0).lines()).containsExactly("x", "y", "z");
}

@Test
@Timeout(5)
void aBudgetTooSmallForEvenAHeadingStillTerminatesAndKeepsEveryLine() {
List<String> lines = List.of("a", "b", "c");

List<List<ColumnPagination.Block>> pages = sidebar().paginate(
List.of(new ColumnPagination.Block("Skills", lines, false)), 1.0);

assertThat(allLines(pages))
.describedAs("a degenerate budget must not spin forever, and "
+ "must not resolve the deadlock by discarding lines")
.containsExactlyElementsOf(lines);
}

@Test
void anEmptyColumnStillYieldsOnePage() {
assertThat(sidebar().paginate(List.of(), 600.0)).hasSize(1);
assertThat(sidebar().paginate(List.of(), 600.0).get(0)).isEmpty();
}

@Test
void aLongLineCountsAsTheSeveralItWrapsTo() {
ColumnPagination column = sidebar();
// 148pt at 7.5pt ≈ 41 characters per rendered line.
assertThat(column.wrappedLines("short")).isEqualTo(1);
assertThat(column.wrappedLines("x".repeat(41))).isEqualTo(1);
assertThat(column.wrappedLines("x".repeat(42))).isEqualTo(2);
assertThat(column.wrappedLines("x".repeat(130))).isEqualTo(4);
}

@Test
void anEmptyLineStillOccupiesOne() {
assertThat(sidebar().wrappedLines("")).isEqualTo(1);
assertThat(sidebar().wrappedLines(null)).isEqualTo(1);
}

@Test
void aWiderColumnWrapsLater() {
ColumnPagination main = ColumnPagination.forColumn(348.0, 7.8, 1.2, 13.5, 24.0);

assertThat(main.wrappedLines("x".repeat(90)))
.describedAs("the main column is more than twice the sidebar's "
+ "width, so the same text wraps to fewer lines")
.isLessThan(sidebar().wrappedLines("x".repeat(90)));
}

@Test
void aColumnWithNoWidthOrNoFontIsRejected() {
assertThatThrownBy(() -> ColumnPagination.forColumn(0, 7.5, 1.0, 12.5, 22.0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must be positive");
assertThatThrownBy(() -> ColumnPagination.forColumn(148.0, 0, 1.0, 12.5, 22.0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must be positive");
assertThatThrownBy(() -> ColumnPagination.forColumn(148.0, 7.5, 1.0, -1, 22.0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must be positive");
}
}
Loading
Loading