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) { }