diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ce9535dd..9f2e6642a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/assets/readme/examples/cv-timeline-minimal-v2.pdf b/assets/readme/examples/cv-timeline-minimal-v2.pdf
index 384077076..08b894964 100644
Binary files a/assets/readme/examples/cv-timeline-minimal-v2.pdf and b/assets/readme/examples/cv-timeline-minimal-v2.pdf differ
diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvTimelineMinimalExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvTimelineMinimalExample.java
index a3e031cc3..7fe9467b8 100644
--- a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvTimelineMinimalExample.java
+++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvTimelineMinimalExample.java
@@ -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.
+ *
+ *
Module headings come from the sample's own section titles, so the
+ * sidebar reads Education & 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.
*
* Output:
* {@code examples/target/generated-pdfs/templates/cv/cv-timeline-minimal-v2.pdf}.
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java
new file mode 100644
index 000000000..fb0a999d8
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java
@@ -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.
+ *
+ * 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.
+ */
+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");
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/ColumnPaginationTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/ColumnPaginationTest.java
new file mode 100644
index 000000000..ba6d71243
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/ColumnPaginationTest.java
@@ -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.
+ *
+ * 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.
+ */
+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 allLines(List> pages) {
+ List flat = new ArrayList<>();
+ for (List page : pages) {
+ for (ColumnPagination.Block block : page) {
+ flat.addAll(block.lines());
+ }
+ }
+ return flat;
+ }
+
+ @Test
+ void aColumnThatFitsStaysOnOnePage() {
+ List> 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 lines = IntStream.range(0, 60)
+ .mapToObj(i -> "entry " + i).toList();
+
+ List> 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 lines = IntStream.range(0, 40)
+ .mapToObj(i -> "entry " + i).toList();
+
+ List> 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> 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 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 lines = List.of("a", "b", "c");
+
+ List> 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");
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimalContentFidelityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimalContentFidelityTest.java
new file mode 100644
index 000000000..a0bfc2f77
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimalContentFidelityTest.java
@@ -0,0 +1,226 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+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.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 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;
+
+/**
+ * A CV rendered through Timeline Minimal keeps every entry it was given.
+ *
+ * A preset that drops content is worse than one that fails: the PDF looks
+ * finished, the page is not obviously full, and the reader has no way to know
+ * a job, a degree, or a whole section was never drawn. The three ways this
+ * preset used to lose data are each pinned here, because each is silent.
+ *
+ * The fixture is deliberately denser than a one-page CV. Content that does
+ * not fit belongs on page two — the engine paginates — not in the bin.
+ */
+class TimelineMinimalContentFidelityTest {
+
+ @Test
+ void everyEntryOfADenseCvSurvivesIntoThePdf() throws Exception {
+ String text = renderText(TimelineMinimal.create(), denseDocument());
+
+ // Tail entries of each module: the ones a per-module cap dropped first.
+ assertThat(text)
+ .describedAs("every education entry must reach the page; the 4th "
+ + "was the first casualty of the 5-line sidebar cap")
+ .contains("MSc Computer Science")
+ .contains("University of Leeds")
+ .contains("Cloud Architect")
+ .contains("BSc Hydraulic Engineering");
+
+ assertThat(text)
+ .describedAs("every employer must reach the page")
+ .contains("Acme Rendering")
+ .contains("Northwind Data")
+ .contains("Nikoplast");
+
+ assertThat(text)
+ .describedAs("every project must reach the page")
+ .contains("GraphCompose")
+ .contains("Ledger Sync")
+ .contains("Spring Cart API");
+
+ assertThat(text)
+ .describedAs("every skill group must reach the page")
+ .contains("Languages")
+ .contains("Frameworks")
+ .contains("AssertJ")
+ .contains("Build")
+ .contains("Datastores")
+ .contains("Messaging")
+ .contains("Observability")
+ .contains("Kubernetes");
+ }
+
+ @Test
+ void aSectionMatchingNoKnownCategoryIsStillRenderedUnderItsOwnTitle() throws Exception {
+ String text = renderText(TimelineMinimal.create(), denseDocument());
+
+ // Headings render in caps, so compare without case.
+ assertThat(text)
+ .describedAs("an arbitrary section title matched none of the preset's "
+ + "keyword lists and was discarded without a trace")
+ .containsIgnoringCase("Awards")
+ .contains("Rising Star");
+ }
+
+ @Test
+ void aSecondSectionOfAKnownCategoryIsNotSwallowedByTheFirst() throws Exception {
+ String text = renderText(TimelineMinimal.create(), denseDocument());
+
+ assertThat(text)
+ .describedAs("firstMatching consumed only the first title matching "
+ + "the summary keys, so the second prose section vanished")
+ .containsIgnoringCase("Open Source")
+ .contains("Maintains a document engine");
+ }
+
+ @Test
+ void proseIsNotCutWithAnEllipsisWhileThePageStillHasRoom() throws Exception {
+ String text = renderText(TimelineMinimal.create(), denseDocument());
+
+ assertThat(text)
+ .describedAs("the summary was clipped mid-sentence by a character "
+ + "budget rather than by what actually fits")
+ .contains("closing sentence of the profile");
+ }
+
+ @Test
+ void aCvWithATallContactStackStillRendersEveryEntry() throws Exception {
+ // The masthead grows a line per contact and per link, and the body
+ // budget is what is left of the page after it. Thirteen contact rows
+ // is far past anything the other fixtures exercise; the pagination has
+ // to absorb that without dropping content at either end.
+ CvIdentity.Builder identity = CvIdentity.builder()
+ .name("Jane", "Doe")
+ .jobTitle("Backend Engineer")
+ .contact("+44 0", "j@d.com", "London");
+ for (int i = 1; i <= 10; i++) {
+ identity.link("Profile " + i, "https://example.com/" + i);
+ }
+
+ CvDocument doc = CvDocument.builder()
+ .identity(identity.build())
+ .sections(denseDocument().sections().toArray(new CvSection[0]))
+ .build();
+
+ String text = renderText(TimelineMinimal.create(), doc);
+
+ assertThat(text)
+ .describedAs("the body has to give way to the taller masthead "
+ + "without losing what it was holding")
+ .contains("BSc Hydraulic Engineering")
+ .contains("Nikoplast")
+ .containsIgnoringCase("Awards");
+ }
+
+ private static String renderText(DocumentTemplate template,
+ CvDocument doc) throws Exception {
+ byte[] pdf;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(DocumentInsets.of(TimelineMinimal.RECOMMENDED_MARGIN))
+ .create()) {
+ template.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, not this
+ // test's. Ligatures are a separate matter — PDFBox drops the
+ // glyph for "ft"/"ti"/"fk", so "Software" extracts as "So ware";
+ // the assertions below stay clear of those letter pairs.
+ String text = new PDFTextStripper().getText(document);
+ return text.replaceAll("\\s+", " ");
+ }
+ }
+
+ /**
+ * Four education entries, three employers, three projects, eight skill
+ * groups, a second prose section, and a section whose title matches none
+ * of the preset's keyword lists.
+ */
+ private static CvDocument denseDocument() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Jane", "Doe")
+ .jobTitle("Backend Engineer")
+ .contact("+44 0", "j@d.com", "London")
+ .link("GitHub", "https://github.com/jane")
+ .build())
+ .sections(
+ // Comfortably past the 245-character budget the preset
+ // used to clip prose at, so the closing words are only
+ // present if nothing clipped them.
+ new ParagraphSection("Professional Summary",
+ "Builds reliable document pipelines across the JVM, "
+ + "with a decade spent on rendering, layout and the "
+ + "unglamorous parts of typesetting that decide "
+ + "whether a page is readable rather than merely "
+ + "correct, from hyphenation and widow control to "
+ + "the way a table behaves when it meets the foot "
+ + "of a page. This is the "
+ + "closing sentence of the profile."),
+ new ParagraphSection("Open Source",
+ "Maintains a document engine used in production."),
+ SkillsSection.builder("Technical Skills")
+ .group("Languages", "Java 21", "Kotlin")
+ .group("Frameworks", "Spring Boot", "Quarkus")
+ .group("Testing", "JUnit 5", "AssertJ")
+ .group("Build", "Maven", "Gradle")
+ .group("Datastores", "PostgreSQL", "Redis")
+ .group("Messaging", "Kafka", "RabbitMQ")
+ .group("Observability", "Micrometer", "Grafana")
+ .group("Cloud", "AWS", "Kubernetes")
+ .build(),
+ EntriesSection.builder("Education & Certifications")
+ .entry("MSc Computer Science",
+ "University of Manchester", "2019-2021", "")
+ .entry("BEng Software Engineering",
+ "University of Leeds", "2015-2019", "")
+ .entry("Cloud Architect Certification",
+ "AWS", "2022", "")
+ .entry("BSc Hydraulic Engineering",
+ "Kyiv Polytechnic", "2011-2015", "")
+ .build(),
+ RowsSection.builder("Projects", RowStyle.BULLETED_STACKED)
+ .row("GraphCompose", "Declarative PDF layout engine.")
+ .row("Ledger Sync", "Double-entry reconciliation service.")
+ .row("Spring Cart API", "Storefront checkout backend.")
+ .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(),
+ RowsSection.builder("Awards", RowStyle.PLAIN)
+ .row("Rising Star", "Engineering award, 2019")
+ .build(),
+ RowsSection.builder("Additional Information", RowStyle.PLAIN)
+ .row("Languages", "English, German")
+ .build())
+ .build();
+ }
+}
diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png
index d075ab258..d010bdaaf 100644
Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png differ
diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-1.png
new file mode 100644
index 000000000..17e0515f8
Binary files /dev/null and b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-1.png differ
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java
new file mode 100644
index 000000000..560c5b94d
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java
@@ -0,0 +1,131 @@
+package com.demcha.compose.document.templates.cv.components;
+
+import com.demcha.compose.document.templates.cv.data.CvSection;
+
+import java.util.ArrayList;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Assigns a CV's sections to a preset's fixed modules and keeps whatever is
+ * left over.
+ *
+ * {@link SectionLookup#firstMatching} answers "which section is the
+ * Education one?" and nothing else. A preset built only from those calls
+ * renders exactly the categories it thought to ask about: a second section
+ * matching the same keywords is shadowed by the first, and a section whose
+ * title matches no list at all — "Awards", "Publications", anything a user
+ * invented — is never looked at. Neither loss is visible in the output. The
+ * page renders, it looks finished, and the reader has no way to tell that a
+ * section was dropped.
+ *
+ * This narrows that to an allocation: {@link #claim(List)} hands out each
+ * section once, and {@link #remaining()} returns everything no module
+ * asked for, in document order, so the preset can render it rather than
+ * discard it.
+ *
+ * Not thread-safe, and not meant to be: a preset builds one of these inside
+ * a single {@code compose} call and drops it.
+ *
+ * @since 2.1.2
+ */
+public final class SectionAllocation {
+
+ private final List sections;
+ private final Map claimed = new IdentityHashMap<>();
+
+ private SectionAllocation(List sections) {
+ this.sections = sections;
+ }
+
+ /**
+ * Starts an allocation over a document's sections.
+ *
+ * @param sections the sections to allocate; {@code null} is treated as empty
+ * @return a fresh allocation with nothing claimed yet
+ */
+ public static SectionAllocation of(List sections) {
+ List copy = new ArrayList<>();
+ if (sections != null) {
+ for (CvSection section : sections) {
+ if (section != null) {
+ copy.add(section);
+ }
+ }
+ }
+ return new SectionAllocation(List.copyOf(copy));
+ }
+
+ /**
+ * Claims the first not-yet-claimed section whose normalised title contains
+ * any of the keys.
+ *
+ * Claiming is what makes a second call with overlapping keys return a
+ * different section instead of the same one twice, and what moves
+ * the section out of {@link #remaining()}.
+ *
+ * @param keys candidate title fragments; {@code null} claims nothing
+ * @return the claimed section, or {@code null} when nothing matches
+ */
+ public CvSection claim(List keys) {
+ if (keys == null) {
+ return null;
+ }
+ for (CvSection section : sections) {
+ if (claimed.containsKey(section)) {
+ continue;
+ }
+ String title = SectionLookup.normalize(section.title());
+ for (String key : keys) {
+ if (title.contains(SectionLookup.normalize(key))) {
+ claimed.put(section, Boolean.TRUE);
+ return section;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The sections no module claimed, in document order.
+ *
+ * These are the ones a keyword-only preset loses silently. Render them
+ * under {@link CvSection#title()} — the user wrote that title, and it is
+ * the only label that can be correct for a category the preset does not
+ * know about.
+ *
+ * @return unclaimed sections carrying content, in document order
+ */
+ public List remaining() {
+ List rest = new ArrayList<>();
+ for (CvSection section : sections) {
+ if (!claimed.containsKey(section) && SectionLookup.hasContent(section)) {
+ rest.add(section);
+ }
+ }
+ return List.copyOf(rest);
+ }
+
+ /**
+ * The section's own title, or the preset's label when there is no section.
+ *
+ * A preset that hardcodes its module labels renames the user's content:
+ * a section the author called "Projects" prints as "EXPERTISE", and
+ * "Additional Information" prints as "LANGUAGES". The author's title wins.
+ * There is no blank-title case to weigh against it — every
+ * {@link CvSection} implementation rejects a blank title at construction —
+ * so the label is reached only when the module found no section at all,
+ * which is also when it has nothing to render.
+ *
+ * @param section the claimed section, or {@code null} when none matched
+ * @param fallback the preset's own label for the module
+ * @return the title to print
+ * @throws NullPointerException if {@code fallback} is {@code null}
+ */
+ public static String titleOr(CvSection section, String fallback) {
+ Objects.requireNonNull(fallback, "fallback");
+ return section == null ? fallback : section.title();
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ColumnPagination.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ColumnPagination.java
new file mode 100644
index 000000000..faa8ed05f
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ColumnPagination.java
@@ -0,0 +1,211 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Splits a column of titled text blocks across pages.
+ *
+ * A preset whose body is a multi-column {@code addRow} cannot lean on the
+ * paginator: a row is atomic, so a row taller than the page raises
+ * {@code AtomicNodeTooLargeException} rather than flowing. The preset has to
+ * pick the page boundary itself, and to pick it, it has to guess how tall its
+ * own content will be before the engine measures anything.
+ *
+ * The guess models one thing — line wrapping. A block's line is a logical
+ * line; in a 148pt sidebar at 7.5pt it renders as three. Counting logical
+ * lines and calling that a page budget is what overflows: the same count of
+ * lines is twice the height in the narrow column. So the estimate turns a
+ * character count into wrapped lines using the column's width and the font's
+ * mean advance, and packs blocks until the page height is spent.
+ *
+ * It is an estimate, and deliberately a conservative one: a page that
+ * breaks a block early costs some whitespace, while a page that breaks late
+ * costs an exception. A font far from the assumed mean advance, or a single
+ * unbreakable token wider than the column, can still push a page over.
+ *
+ * What it never does is lose a line. Everything handed to
+ * {@link #paginate} comes back out on some page.
+ */
+final class ColumnPagination {
+
+ /**
+ * Mean glyph advance as a fraction of font size, for the humanist sans
+ * faces these presets use.
+ *
+ * Taken across the body text of the repository's CV fixtures in Lato
+ * at 7.5–7.9pt. Being a mean it is wrong for any individual line; over a
+ * block of prose it converges, which is the scale it is used at.
+ */
+ private static final double MEAN_GLYPH_ADVANCE_RATIO = 0.48;
+
+ /**
+ * Rendered line height as a ratio of font size, before extra spacing.
+ *
+ * Package-visible so a preset sizing its own header uses the same
+ * ratio this uses to size its columns — two copies of the number would
+ * drift and only show up as a page that overflows.
+ */
+ static final double FONT_LEADING_RATIO = 1.2;
+
+ private final double charsPerLine;
+ private final double lineHeight;
+ private final double headingHeight;
+ private final double blockGap;
+
+ private ColumnPagination(double charsPerLine, double lineHeight,
+ double headingHeight, double blockGap) {
+ this.charsPerLine = charsPerLine;
+ this.lineHeight = lineHeight;
+ this.headingHeight = headingHeight;
+ this.blockGap = blockGap;
+ }
+
+ /**
+ * One titled run of already-flattened content.
+ *
+ * @param title the heading to print
+ * @param lines the block's logical lines, complete and unclipped
+ * @param prose {@code true} to render as running text, {@code false} as bullets
+ */
+ record Block(String title, List lines, boolean prose) {
+ }
+
+ /**
+ * Builds an estimator for one column.
+ *
+ * @param columnWidth the column's inner width in points
+ * @param bodyFontSize font size of the column's body text
+ * @param extraLineSpacing additive gap between lines, in points
+ * @param headingFontSize font size of a block's heading
+ * @param blockGap space a block adds beyond its lines — heading
+ * gap, trailing rule, spacing to the next block
+ * @return an estimator for that column
+ * @throws IllegalArgumentException if a font size or the width is not positive
+ */
+ static ColumnPagination forColumn(double columnWidth, double bodyFontSize,
+ double extraLineSpacing,
+ double headingFontSize, double blockGap) {
+ if (columnWidth <= 0 || bodyFontSize <= 0 || headingFontSize <= 0) {
+ throw new IllegalArgumentException(
+ "columnWidth, bodyFontSize and headingFontSize must be positive, got "
+ + columnWidth + ", " + bodyFontSize + ", " + headingFontSize);
+ }
+ return new ColumnPagination(
+ Math.max(1.0, columnWidth / (bodyFontSize * MEAN_GLYPH_ADVANCE_RATIO)),
+ bodyFontSize * FONT_LEADING_RATIO + Math.max(0.0, extraLineSpacing),
+ headingFontSize * FONT_LEADING_RATIO,
+ Math.max(0.0, blockGap));
+ }
+
+ /**
+ * Splits blocks into per-page groups that fit {@code budget} points.
+ *
+ * A block that does not fit the remainder of a page starts the
+ * next one intact. A block too tall for a whole page is carried across as
+ * many pages as it needs, repeating its heading — dropping the heading
+ * would leave the continuation reading as part of what came before it.
+ *
+ * @param blocks the column's blocks in render order
+ * @param budget height available on one page, in points; must be positive
+ * @return one list per page; at least one, possibly empty
+ */
+ List> paginate(List blocks, double budget) {
+ List> pages = new ArrayList<>();
+ List current = new ArrayList<>();
+ double used = 0;
+ for (Block block : blocks) {
+ List pending = block.lines();
+ while (!pending.isEmpty()) {
+ int fits = linesThatFit(pending, budget - used);
+ if (fits == pending.size()) {
+ current.add(new Block(block.title(), pending, block.prose()));
+ used += blockHeight(pending);
+ pending = List.of();
+ } else if (fits > 0 && current.isEmpty()) {
+ // First block on the page and it still overflows: take what
+ // fits so the remainder makes progress on the next page.
+ current.add(new Block(block.title(),
+ List.copyOf(pending.subList(0, fits)), block.prose()));
+ pending = List.copyOf(pending.subList(fits, pending.size()));
+ used = budget;
+ } else if (current.isEmpty()) {
+ // Not one line fits an empty page — the budget is smaller
+ // than a heading. Emit a line anyway: looping here would
+ // never terminate, and dropping it is the bug being fixed.
+ current.add(new Block(block.title(),
+ List.of(pending.get(0)), block.prose()));
+ pending = List.copyOf(pending.subList(1, pending.size()));
+ used = budget;
+ } else {
+ // Push the whole block to the next page rather than
+ // orphaning its opening lines under a heading at the foot.
+ used = budget;
+ }
+ if (used >= budget && !pending.isEmpty()) {
+ pages.add(List.copyOf(current));
+ current = new ArrayList<>();
+ used = 0;
+ }
+ }
+ }
+ pages.add(List.copyOf(current));
+ return List.copyOf(pages);
+ }
+
+ /**
+ * Estimated height of a whole page's worth of blocks.
+ *
+ * @param page the blocks placed on one page
+ * @return height in points
+ */
+ double pageHeight(List page) {
+ double height = 0;
+ for (Block block : page) {
+ height += blockHeight(block.lines());
+ }
+ return height;
+ }
+
+ /**
+ * How many rendered lines a logical line takes in this column.
+ *
+ * @param line the logical line; {@code null} or empty counts as one
+ * @return at least one
+ */
+ int wrappedLines(String line) {
+ if (line == null || line.isEmpty()) {
+ return 1;
+ }
+ return Math.max(1, (int) Math.ceil(line.length() / charsPerLine));
+ }
+
+ /** Height a run of lines occupies under its own heading. */
+ double blockHeight(List lines) {
+ double height = headingHeight + blockGap;
+ for (String line : lines) {
+ height += wrappedLines(line) * lineHeight;
+ }
+ return height;
+ }
+
+ /**
+ * The largest prefix of {@code lines} that fits {@code available} points
+ * once the heading is paid for.
+ *
+ * @return how many lines fit; {@code 0} when not even one does
+ */
+ int linesThatFit(List lines, double available) {
+ double used = headingHeight + blockGap;
+ int fitted = 0;
+ for (String line : lines) {
+ double next = used + wrappedLines(line) * lineHeight;
+ if (next > available) {
+ break;
+ }
+ used = next;
+ fitted++;
+ }
+ return fitted;
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java
index d78ebe386..6bf2ededb 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java
@@ -3,6 +3,7 @@
import com.demcha.compose.document.templates.core.identity.Link;
import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
import com.demcha.compose.document.dsl.RowBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.node.DocumentLinkOptions;
@@ -16,6 +17,7 @@
import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.core.text.TextStyles;
import com.demcha.compose.document.templates.core.text.MarkdownInline;
+import com.demcha.compose.document.templates.cv.components.SectionAllocation;
import com.demcha.compose.document.templates.cv.components.SectionLookup;
import com.demcha.compose.document.templates.cv.data.*;
import com.demcha.compose.document.templates.core.theme.BrandTheme;
@@ -38,9 +40,27 @@
* The preset stays a thin orchestrator. The 3-column body layout
* (sidebar / axis / main) and the contact icon row are preset-local
* because no other v2 preset uses this visual today. Section bodies
- * are flattened to a list of lines via a preset-local helper so the
- * sidebar can apply per-module truncation limits — the canonical
- * shared dispatchers do not enforce that shape.
+ * are flattened to a list of lines via a preset-local helper, which is
+ * the shape the two narrow columns and the fixed-height axis need — the
+ * canonical shared dispatchers produce richly-styled multi-paragraph
+ * output instead.
+ *
+ * Nothing the CV carries is dropped
+ *
+ * Sections are matched to modules by title keyword, and a keyword match
+ * is a guess: a CV may hold a category this preset never heard of, or two
+ * that answer to the same keywords. Both are routed rather than discarded —
+ * {@link SectionAllocation} hands each section out once and returns the
+ * rest, which land in the main column under their own headings. A module's
+ * heading is the section's own title where it has one; the labels here are
+ * only a fallback.
+ *
+ * Content longer than a page continues on the next one. The body is a
+ * row, and a row is atomic — the paginator cannot break inside one — so the
+ * preset estimates its own columns' heights through {@link ColumnPagination}
+ * and emits one row per page, letting each finished row overflow naturally.
+ * What it holds back for the masthead is measured from the identity, because
+ * the contact stack grows a row per link.
*/
public final class TimelineMinimal {
@@ -101,6 +121,45 @@ public final class TimelineMinimal {
*/
private static final DocumentColor ICON_COLOR = DocumentColor.rgb(58, 58, 58);
+ /**
+ * Shortest axis a continuation page may draw. Below this the four
+ * segments and three markers stop reading as a timeline.
+ */
+ private static final double MIN_CONTINUATION_AXIS_HEIGHT = 120.0;
+
+ /** Gap between the three body columns; also the row's own spacing. */
+ private static final double BODY_COLUMN_GAP = 16.0;
+
+ private static final double SIDEBAR_WEIGHT = 0.74;
+ private static final double AXIS_WEIGHT = 0.12;
+ private static final double MAIN_WEIGHT = 1.74;
+
+ /** Gap between the stacked contact rows. */
+ private static final double CONTACT_ROW_GAP = 3.0;
+
+ /** Gap between the name and the job title beneath it. */
+ private static final double NAME_BLOCK_GAP = 4.0;
+
+ /** Job-title size; read both by the style and by the masthead measurement. */
+ private static final double JOB_TITLE_SIZE = 9.5;
+
+ /**
+ * Slack added to the measured masthead height.
+ *
+ * Covers what the arithmetic below does not model — a contact line long
+ * enough to wrap, the rule's own thickness. An over-estimate costs
+ * whitespace at the foot of a page; an under-estimate costs
+ * {@code AtomicNodeTooLargeException}, because a row cannot be broken by
+ * the paginator, so the error is deliberately taken on the safe side.
+ */
+ private static final double MASTHEAD_SLACK = 18.0;
+
+ /** Heading gap plus the trailing rule each sidebar block carries. */
+ private static final double SIDEBAR_BLOCK_GAP = 22.0;
+
+ /** Heading gap plus the trailing rule each main-column block carries. */
+ private static final double MAIN_BLOCK_GAP = 24.0;
+
private static final List SUMMARY_KEYS =
List.of("summary", "professional summary", "profile");
private static final List SKILL_KEYS =
@@ -155,9 +214,64 @@ public void compose(DocumentSession document, CvDocument doc) {
Objects.requireNonNull(doc, "doc");
double width = document.canvas().innerWidth();
- List sections = doc.sectionsIn(Slot.MAIN);
+ SectionAllocation allocation =
+ SectionAllocation.of(doc.sectionsIn(Slot.MAIN));
+
+ // Claim order decides which module wins when two lists could
+ // match the same title, and every claim removes the section
+ // from what falls through to the main column below.
+ CvSection education = allocation.claim(EDUCATION_KEYS);
+ CvSection skills = allocation.claim(SKILL_KEYS);
+ CvSection projects = allocation.claim(PROJECT_KEYS);
+ CvSection additional = allocation.claim(ADDITIONAL_KEYS);
+ CvSection summary = allocation.claim(SUMMARY_KEYS);
+ CvSection experience = allocation.claim(EXPERIENCE_KEYS);
+
+ List sidebar = modules(
+ module(education, "Education"),
+ module(skills, "Skills"),
+ module(projects, "Expertise"),
+ module(additional, "Languages"));
+
+ List main = new ArrayList<>();
+ addModule(main, prose(summary, "Professional Profile"));
+ addModule(main, module(experience, "Work Experience"));
+ // Whatever no module claimed — a user's own "Awards", a second
+ // prose section — goes into the main column under its own
+ // title rather than off the page.
+ for (CvSection leftover : allocation.remaining()) {
+ addModule(main, module(leftover, leftover.title()));
+ }
+
+ // Column inner widths: the row subtracts its gaps before the
+ // weighted split, so derive them the same way or the wrap
+ // estimate below is measuring a column that does not exist.
+ double usable = Math.max(1.0, width - 2 * BODY_COLUMN_GAP);
+ double weightSum = SIDEBAR_WEIGHT + AXIS_WEIGHT + MAIN_WEIGHT;
+ double bodyBudget = Math.max(1.0,
+ document.availableHeight()
+ - mastheadHeight(doc.identity())
+ - theme.spacing().pageFlowSpacing() * 2);
+
+ ColumnPagination sidebarMetrics = ColumnPagination.forColumn(
+ usable * SIDEBAR_WEIGHT / weightSum,
+ theme.typography().sizeEntrySubtitle(), 1.0,
+ theme.typography().sizeEntryTitle(),
+ SIDEBAR_BLOCK_GAP);
+ ColumnPagination mainMetrics = ColumnPagination.forColumn(
+ usable * MAIN_WEIGHT / weightSum,
+ theme.typography().sizeBody(), 1.2,
+ theme.typography().sizeBanner(),
+ MAIN_BLOCK_GAP);
- document.dsl()
+ List> sidebarPages =
+ sidebarMetrics.paginate(sidebar, bodyBudget);
+ List> mainPages =
+ mainMetrics.paginate(main, bodyBudget);
+ int pages = Math.max(1,
+ Math.max(sidebarPages.size(), mainPages.size()));
+
+ PageFlowBuilder flow = document.dsl()
.pageFlow()
.name("CvV2TimelineMinimalRoot")
.spacing(theme.spacing().pageFlowSpacing())
@@ -173,40 +287,33 @@ public void compose(DocumentSession document, CvDocument doc) {
.horizontal(width)
.color(theme.palette().rule())
.thickness(theme.spacing().accentRuleWidth())
- .margin(DocumentInsets.zero()))
- .addRow("CvV2TimelineMinimalBody", row -> addBodyRow(row,
- List.of(
- new ModulePlacement("Education",
- SectionLookup.firstMatching(sections,
- EDUCATION_KEYS),
- 5),
- new ModulePlacement("Skills",
- SectionLookup.firstMatching(sections,
- SKILL_KEYS),
- 6),
- new ModulePlacement("Expertise",
- SectionLookup.firstMatching(sections,
- PROJECT_KEYS),
- 3),
- new ModulePlacement("Languages",
- SectionLookup.firstMatching(sections,
- ADDITIONAL_KEYS),
- 3)),
- List.of(
- new ModulePlacement("Professional Profile",
- SectionLookup.firstMatching(sections,
- SUMMARY_KEYS),
- 1),
- new ModulePlacement("Work Experience",
- SectionLookup.firstMatching(sections,
- EXPERIENCE_KEYS),
- 4)),
- TIMELINE_AXIS_HEIGHT))
- .build();
+ .margin(DocumentInsets.zero()));
+
+ // One row per page. A row is atomic — the paginator cannot
+ // break inside it — so the preset decides the page boundary
+ // itself and lets each finished row overflow onto the next
+ // page of its own accord. An explicit page break would emit a
+ // blank page whenever page one happens to be exactly full.
+ for (int page = 0; page < pages; page++) {
+ List sidebarPage = pageAt(sidebarPages, page);
+ List mainPage = pageAt(mainPages, page);
+ // The full-height axis is the preset's signature and stays
+ // on the opening page. A continuation page carrying three
+ // lines does not want 620pt of rule beside them, so there
+ // the axis follows the content.
+ double axisHeight = page == 0
+ ? TIMELINE_AXIS_HEIGHT
+ : continuationAxisHeight(sidebarPage, sidebarMetrics,
+ mainPage, mainMetrics);
+ flow.addRow("CvV2TimelineMinimalBody" + page,
+ row -> addBodyRow(row, sidebarPage, mainPage,
+ axisHeight));
+ }
+ flow.build();
}
private void addNameBlock(SectionBuilder section, CvIdentity identity) {
- section.spacing(4)
+ section.spacing(NAME_BLOCK_GAP)
.addParagraph(paragraph -> paragraph
.text(spacedUpper(identity.name().full()))
.textStyle(nameStyle())
@@ -221,16 +328,15 @@ private void addNameBlock(SectionBuilder section, CvIdentity identity) {
}
private void addBodyRow(RowBuilder row,
- List sidebarModules,
- List mainModules,
+ List sidebarModules,
+ List mainModules,
double axisHeight) {
- row.spacing(16)
- .weights(0.74, 0.12, 1.74)
+ row.spacing(BODY_COLUMN_GAP)
+ .weights(SIDEBAR_WEIGHT, AXIS_WEIGHT, MAIN_WEIGHT)
.addSection("CvV2TimelineMinimalSidebar", sidebar -> {
sidebar.spacing(10);
- for (ModulePlacement placement : sidebarModules) {
- addSidebarModule(sidebar, placement.title(),
- placement.section(), placement.limit());
+ for (ColumnPagination.Block module : sidebarModules) {
+ addSidebarModule(sidebar, module);
}
})
.addSection("CvV2TimelineMinimalAxis", axis ->
@@ -238,16 +344,14 @@ private void addBodyRow(RowBuilder row,
timelineAxisStyle(), axisHeight))
.addSection("CvV2TimelineMinimalMain", main -> {
main.spacing(11);
- for (ModulePlacement placement : mainModules) {
- boolean bullets = placement.limit() > 1;
- addMainModule(main, placement.title(),
- placement.section(), bullets, placement.limit());
+ for (ColumnPagination.Block module : mainModules) {
+ addMainModule(main, module);
}
});
}
private void addContact(SectionBuilder section, CvIdentity identity) {
- section.spacing(3);
+ section.spacing(CONTACT_ROW_GAP);
DocumentTextStyle textStyle = contactTextStyle();
DocumentTextStyle fallbackIconStyle = fallbackIconStyle();
for (ContactItem item : contactItems(identity)) {
@@ -274,6 +378,38 @@ private void addContact(SectionBuilder section, CvIdentity identity) {
}
}
+ /**
+ * Height the masthead takes off the page before the body row.
+ *
+ * The header is a row, so its height is the taller of its two
+ * columns: the name block on the left, the contact stack on the
+ * right. The stack grows a row per link, which is why this is
+ * measured rather than assumed — a fixed reserve is wrong in both
+ * directions. Too small, and a CV with enough contact links pushes
+ * the body row past the page into
+ * {@code AtomicNodeTooLargeException}, since a row cannot be
+ * broken. Too large, and every ordinary CV loses page space it
+ * could have filled.
+ *
+ * @param identity the header's data; {@code null} yields the rule alone
+ * @return height in points, including the rule and its slack
+ */
+ private double mastheadHeight(CvIdentity identity) {
+ double nameBlock = theme.typography().sizeHeadline()
+ * ColumnPagination.FONT_LEADING_RATIO;
+ if (identity != null && !identity.jobTitle().isBlank()) {
+ nameBlock += NAME_BLOCK_GAP
+ + JOB_TITLE_SIZE * ColumnPagination.FONT_LEADING_RATIO;
+ }
+ int rows = contactItems(identity).size();
+ double contactStack = rows == 0 ? 0.0
+ : rows * theme.typography().sizeContact()
+ * ColumnPagination.FONT_LEADING_RATIO
+ + (rows - 1) * CONTACT_ROW_GAP;
+ return Math.max(nameBlock, contactStack)
+ + theme.spacing().accentRuleWidth() + MASTHEAD_SLACK;
+ }
+
private List contactItems(CvIdentity identity) {
if (identity == null) {
return List.of();
@@ -318,12 +454,12 @@ private SvgGlyph glyph(String iconFile) {
return SvgGlyph.fromResource(CONTACT_ICON_ROOT + iconFile);
}
- private void addSidebarModule(SectionBuilder sidebar, String title,
- CvSection section, int limit) {
- List lines = sectionLines(section);
+ private void addSidebarModule(SectionBuilder sidebar, ColumnPagination.Block module) {
+ List lines = module.lines();
if (lines.isEmpty()) {
return;
}
+ String title = module.title();
sidebar.addSection("CvV2TimelineMinimalSidebar"
+ SectionLookup.normalize(title), block -> {
block.spacing(6)
@@ -331,9 +467,9 @@ private void addSidebarModule(SectionBuilder sidebar, String title,
.text(title.toUpperCase(Locale.ROOT))
.textStyle(sidebarTitleStyle())
.margin(DocumentInsets.zero()));
- for (String line : lines.stream().limit(limit).toList()) {
+ for (String line : lines) {
block.addParagraph(paragraph -> paragraph
- .text(excerpt(line, 76))
+ .text(line)
.textStyle(sidebarBodyStyle())
.lineSpacing(1)
.margin(DocumentInsets.zero()));
@@ -365,13 +501,12 @@ private TimelineAxisWidget.Style timelineAxisStyle() {
.build();
}
- private void addMainModule(SectionBuilder main, String title,
- CvSection section, boolean bullets,
- int limit) {
- List lines = sectionLines(section);
+ private void addMainModule(SectionBuilder main, ColumnPagination.Block module) {
+ List lines = module.lines();
if (lines.isEmpty()) {
return;
}
+ String title = module.title();
main.addSection("CvV2TimelineMinimalMain"
+ SectionLookup.normalize(title), block -> {
block.spacing(5)
@@ -379,21 +514,23 @@ private void addMainModule(SectionBuilder main, String title,
.text(title.toUpperCase(Locale.ROOT))
.textStyle(mainTitleStyle())
.margin(DocumentInsets.zero()));
- if (bullets) {
- for (String line : lines.stream().limit(limit).toList()) {
+ if (module.prose()) {
+ for (String line : lines) {
+ block.addParagraph(paragraph -> paragraph
+ .text(line)
+ .textStyle(mainBodyStyle())
+ .lineSpacing(1.4)
+ .margin(DocumentInsets.zero()));
+ }
+ } else {
+ for (String line : lines) {
block.addParagraph(paragraph -> paragraph
- .text(excerpt(line, 136))
+ .text(line)
.textStyle(mainBulletStyle())
.lineSpacing(1.2)
.bulletOffset("-")
.margin(DocumentInsets.zero()));
}
- } else {
- block.addParagraph(paragraph -> paragraph
- .text(excerpt(lines.get(0), 245))
- .textStyle(mainBodyStyle())
- .lineSpacing(1.4)
- .margin(DocumentInsets.zero()));
}
block.addLine(line -> line
.horizontal(300)
@@ -414,7 +551,7 @@ private DocumentTextStyle nameStyle() {
private DocumentTextStyle jobTitleStyle() {
return TextStyles.of(theme.typography().headlineFont(),
- 9.5,
+ JOB_TITLE_SIZE,
DocumentTextDecoration.BOLD,
theme.palette().ink());
}
@@ -472,13 +609,14 @@ private DocumentTextStyle mainBodyStyle() {
// -- helpers -----------------------------------------------------------
/**
- * Flattens a {@link CvSection} into a list of single-line strings
- * suitable for the truncation-driven sidebar / main rendering. v2
- * {@code SectionDispatcher} would produce richly-styled multi-paragraph
- * output, which is not what Timeline Minimal needs — its layout
- * relies on knowing the exact line count so per-module
- * {@code limit} can drop overflow without breaking the visual flow
- * around the fixed-height timeline axis.
+ * Flattens a {@link CvSection} into a list of single-line strings.
+ *
+ * The shared {@code SectionDispatcher} would produce richly-styled
+ * multi-paragraph output, which is not the shape this layout can work
+ * with: knowing the line count is what lets the preset choose its own
+ * page boundaries around the fixed-height axis. Counting lines is all it
+ * is for — every line produced here is rendered, on this page or the
+ * next.
*/
private static List sectionLines(CvSection section) {
if (!SectionLookup.hasContent(section)) {
@@ -607,24 +745,58 @@ private static String spacedUpper(String value) {
return builder.toString();
}
- private static String excerpt(String value, int maxChars) {
- String clean = MarkdownInline.plainText(value)
- .replaceAll("\\s+", " ").trim();
- if (clean.length() <= maxChars) {
- return clean;
+ private static String safe(String value) {
+ return value == null ? "" : value;
+ }
+
+ /** A bulleted module, titled from the section or the preset's label. */
+ private static ColumnPagination.Block module(CvSection section, String fallbackTitle) {
+ return new ColumnPagination.Block(SectionAllocation.titleOr(section, fallbackTitle),
+ sectionLines(section), false);
+ }
+
+ /** A running-text module — a profile or any other prose section. */
+ private static ColumnPagination.Block prose(CvSection section, String fallbackTitle) {
+ return new ColumnPagination.Block(SectionAllocation.titleOr(section, fallbackTitle),
+ sectionLines(section), true);
+ }
+
+ private static List modules(ColumnPagination.Block... candidates) {
+ List kept = new ArrayList<>();
+ for (ColumnPagination.Block candidate : candidates) {
+ addModule(kept, candidate);
}
- int boundary = clean.lastIndexOf(' ', maxChars - 1);
- int end = boundary > maxChars / 2 ? boundary : maxChars - 1;
- return clean.substring(0, end).trim() + "...";
+ return kept;
}
- private static String safe(String value) {
- return value == null ? "" : value;
+ private static void addModule(List target, ColumnPagination.Block module) {
+ if (!module.lines().isEmpty()) {
+ target.add(module);
+ }
}
- private record ModulePlacement(String title, CvSection section, int limit) {
+ private static List pageAt(List> pages, int index) {
+ return index < pages.size() ? pages.get(index) : List.of();
}
+ /**
+ * Axis height for a continuation page: as tall as the taller of the two
+ * columns is estimated to be, so the rule ends where the content does.
+ *
+ * @return a height in points, never above {@link #TIMELINE_AXIS_HEIGHT}
+ * and never so short the markers collide
+ */
+ private static double continuationAxisHeight(List sidebarPage,
+ ColumnPagination sidebarMetrics,
+ List mainPage,
+ ColumnPagination mainMetrics) {
+ double tallest = Math.max(sidebarMetrics.pageHeight(sidebarPage),
+ mainMetrics.pageHeight(mainPage));
+ return Math.max(MIN_CONTINUATION_AXIS_HEIGHT,
+ Math.min(TIMELINE_AXIS_HEIGHT, tallest));
+ }
+
+
private record ContactItem(String fallbackIcon, String iconFile,
String text, DocumentLinkOptions linkOptions) {
}