diff --git a/CHANGELOG.md b/CHANGELOG.md index 89abc270..2689ccb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -292,6 +292,24 @@ follow semantic versioning; release dates are ISO 8601. Nine presets and every slot they compose changed; a CV written in Russian and German now renders on all sixteen, which `RoleRoutingTest` holds by rendering one. +- **Timeline Minimal can draw its projects in the wide column.** The preset puts them in + the narrow sidebar beside education and skills, which suits a list of short labels and + not a CV whose projects carry a paragraph each: twelve such projects take the sidebar + three slices where the main column takes one, and every technical line wraps its way + there while the wide column ends half empty. + + `TimelineMinimal.Options.builder().projectsColumn(TimelineMinimal.Column.MAIN)` moves + them under the work history, where a reader meets the career first and the projects that + came out of it second. `create(Options)` and `create(BrandTheme, Options)` join the + existing factories and reject a null `Options`, as every other preset carrying one does. + The moved block is measured against the column it lands in, so the wrapping estimate + follows it. + + The sidebar arrangement stays the default and is held to the pixel by its own baseline; + the variant has a baseline of its own. Which column projects belong in is a property of + the CV rather than of the design, which is why it is a caller's choice rather than a + second preset. + - **The three column-flow presets keep the modular promise too.** `SidebarPortrait`, `MonogramSidebar`, and `MintEditorial` declare `ModularCvTemplate`, so `CvTemplates.modular()` now offers a two-column design to a CV assembled at runtime diff --git a/docs/templates/v2-layered/authoring-presets.md b/docs/templates/v2-layered/authoring-presets.md index a54739ac..432695d9 100644 --- a/docs/templates/v2-layered/authoring-presets.md +++ b/docs/templates/v2-layered/authoring-presets.md @@ -154,7 +154,10 @@ Some presets also expose narrow preset-specific options when the visual decision is structural rather than a reusable widget. Example: `NordicClean.Options` lets authors move the skills rail to the right and override the accent colour, rail fill, or profile-band fill -without mutating shared `BrandTheme` defaults or changing other presets. +without mutating shared `BrandTheme` defaults or changing other presets; +`TimelineMinimal.Options` moves its projects out of the narrow sidebar +for a project-heavy CV. Both keep the preset's own arrangement as the +default, so a caller who asks for nothing gets what shipped. --- diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index 638fecd6..acde6ef1 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -366,6 +366,18 @@ NordicClean.create( .build()); ``` +`TimelineMinimal` exposes one for the same reason: which column its projects +belong in is a property of the CV, not of the design. The narrow sidebar suits +a list of short labels, and a CV whose projects carry a paragraph each fills it +long before the wide column fills. + +```java +TimelineMinimal.create( + TimelineMinimal.Options.builder() + .projectsColumn(TimelineMinimal.Column.MAIN) // under the work history + .build()); +``` + --- diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvV2VisualParityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvV2VisualParityTest.java index fe7b0ddc..ece2cba9 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvV2VisualParityTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvV2VisualParityTest.java @@ -123,6 +123,15 @@ private static Stream presets() { Arguments.of("timeline_minimal", TimelineMinimal.RECOMMENDED_MARGIN, (Supplier>) TimelineMinimal::create), + // The same preset with its projects moved to the wide column — + // a caller's option, so it needs a baseline of its own rather + // than being read off the default one. + Arguments.of("timeline_minimal_projects_main", + TimelineMinimal.RECOMMENDED_MARGIN, + (Supplier>) () -> TimelineMinimal.create( + TimelineMinimal.Options.builder() + .projectsColumn(TimelineMinimal.Column.MAIN) + .build())), Arguments.of("engineering_resume", EngineeringResume.RECOMMENDED_MARGIN, (Supplier>) EngineeringResume::create), diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimalProjectsColumnTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimalProjectsColumnTest.java new file mode 100644 index 00000000..cb2a6888 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimalProjectsColumnTest.java @@ -0,0 +1,216 @@ +package com.demcha.compose.document.templates.cv.presets; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.snapshot.LayoutSnapshot; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.core.theme.BrandTheme; +import com.demcha.compose.document.templates.cv.CvComposedText; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.EntriesSection; +import com.demcha.compose.document.templates.cv.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.data.RowStyle; +import com.demcha.compose.document.templates.cv.data.RowsSection; +import com.demcha.compose.document.templates.cv.data.SkillsSection; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.demcha.compose.document.templates.cv.CvComposedText.squash; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Timeline Minimal draws its projects in the column the caller asked for. + * + *

The narrow sidebar suits a list of short labels. A CV whose projects + * carry a paragraph each fills it long before the wide column fills, so the + * projects run onto a page of their own while the main column ends half + * empty. Which column they belong in is a property of the CV rather than of + * the design, so it is the caller's to choose — and the default stays what it + * has always been.

+ * + *

Placement is read off the node names the preset gives its + * blocks, not off reading order. Order would only be a proxy: the preset emits + * one body row per page, so once the document paginates a later page's sidebar + * follows an earlier page's main column and the proxy inverts. The names say + * which column drew the block whatever the pagination does.

+ */ +class TimelineMinimalProjectsColumnTest { + + /** How the preset names a block: the column, then its normalised title. */ + private static final String SIDEBAR_PROJECTS = "CvV2TimelineMinimalSidebarprojects"; + private static final String MAIN_PROJECTS = "CvV2TimelineMinimalMainprojects"; + + @Test + void byDefaultTheProjectsAreDrawnInTheSidebar() { + List names = blockNames(TimelineMinimal.create()); + + assertThat(names) + .as("the sidebar draws them, and only the sidebar") + .contains(SIDEBAR_PROJECTS) + .doesNotContain(MAIN_PROJECTS); + } + + @Test + void theProjectsColumnOptionMovesThemToTheMainColumn() { + List names = blockNames(TimelineMinimal.create(movedToMain())); + + assertThat(names) + .as("moved, not copied — the sidebar no longer draws them") + .contains(MAIN_PROJECTS) + .doesNotContain(SIDEBAR_PROJECTS); + } + + @Test + void theMovedProjectsAreDrawnAfterTheWorkHistory() { + List names = blockNames(TimelineMinimal.create(movedToMain())); + + assertThat(names) + .as("a reader of the wide column meets the career first and the " + + "projects that came out of it second") + .containsSubsequence("CvV2TimelineMinimalMainprofessionalsummary", + "CvV2TimelineMinimalMainprofessionalexperience", + MAIN_PROJECTS); + } + + @Test + void theOptionMovesEveryProjectExactlyOnceAndLosesNoOtherSection() { + String moved = compose(TimelineMinimal.create(movedToMain())); + + // Counted, not sampled: a block drawn in both columns and a block + // dropped from the middle of the list are the two failures column + // pagination exists to prevent, and presence alone sees neither. + assertThat(CvComposedText.occurrences(moved, squash(PROJECT_DESCRIPTION))) + .as("every project drawn, and each of them once") + .isEqualTo(PROJECT_NAMES.length); + for (String name : PROJECT_NAMES) { + assertThat(moved).as("%s reaches the page", name).contains(squash(name)); + } + assertThat(moved) + .as("and the work history, the sidebar blocks and a section no " + + "slot claimed are all still there") + .contains(squash("Principal Engineer"), squash("MSc Computer Science"), + squash("Java 21"), squash("Publications"), + squash("Deterministic layout")); + } + + @Test + void nullOptionsAreRejectedRatherThanTakenAsTheDefault() { + // Every preset in this package carrying an Options record rejects a null + // one. A null arriving from a caller's configuration is a dropped value, + // and drawing the stock layout for it hides that until somebody asks why + // the projects never moved. + assertThatThrownBy(() -> TimelineMinimal.create(BrandTheme.timelineMinimal(), null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("options"); + assertThatThrownBy(() -> TimelineMinimal.create((TimelineMinimal.Options) null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("options"); + } + + @Test + void aProjectHeavyCvNeedsFewerPagesWithTheProjectsInTheWideColumn() { + // The reported symptom, measured. Twelve projects is the size used + // because it is where the sidebar genuinely runs out of column: it takes + // three slices where the wide column takes one. At nine the default also + // reports two pages, but for a different reason — its single slice no + // longer fits under the masthead and is displaced whole, leaving the + // first page nearly blank. Asserting on that number would be measuring a + // different mechanism than the one this option addresses. + int sidebarPages = pages(TimelineMinimal.create()); + int mainPages = pages(TimelineMinimal.create(movedToMain())); + + assertThat(mainPages) + .as("the wide column holds what the narrow one spills " + + "(sidebar %d pages, main %d)", sidebarPages, mainPages) + .isLessThan(sidebarPages); + } + + // -- fixtures -------------------------------------------------------- + + private static final String[] PROJECT_NAMES = { + "GraphCompose", "Ledger Sync", "Field Atlas", "Template Studio", + "LayoutLint", "ChromeForge", "Signal Desk", "Paper Trail", + "Quiet Hours", "Night Mail", "Slow Radio", "Wide Angle"}; + + private static final String PROJECT_DESCRIPTION = + "Declarative Java PDF layout engine with a semantic authoring DSL, " + + "slot-based templates and snapshot testing, powering production CV, " + + "invoice and proposal pipelines for hiring tools and billing systems."; + + private static TimelineMinimal.Options movedToMain() { + return TimelineMinimal.Options.builder() + .projectsColumn(TimelineMinimal.Column.MAIN) + .build(); + } + + private static String compose(DocumentTemplate preset) { + return CvComposedText.squashedNodes(preset, projectHeavyCv()); + } + + /** Every composed block's name, in layout order. */ + private static List blockNames(DocumentTemplate preset) { + try (DocumentSession session = newSession()) { + preset.compose(session, projectHeavyCv()); + LayoutSnapshot snapshot = session.layoutSnapshot(); + return snapshot.nodes().stream() + .map(node -> node.entityName()) + .filter(name -> name != null && name.startsWith("CvV2TimelineMinimal")) + .toList(); + } + } + + private static int pages(DocumentTemplate preset) { + try (DocumentSession session = newSession()) { + preset.compose(session, projectHeavyCv()); + return session.layoutGraph().totalPages(); + } + } + + private static DocumentSession newSession() { + float margin = (float) TimelineMinimal.RECOMMENDED_MARGIN; + return GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(margin, margin, margin, margin) + .create(); + } + + /** A CV whose projects carry a paragraph each — the shape that spills. */ + private static CvDocument projectHeavyCv() { + RowsSection.Builder projects = + RowsSection.builder("Projects", RowStyle.BULLETED_STACKED); + for (String name : PROJECT_NAMES) { + projects.row(name, PROJECT_DESCRIPTION); + } + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jordan", "Rivera") + .jobTitle("Platform Engineer") + .contact("+44 20 5555 1000", "jordan@example.com", "London, UK") + .build()) + .sections(List.of( + new ParagraphSection("Professional Summary", + "Platform engineer with ten years on document pipelines, " + + "layout engines and the template systems other teams " + + "build on."), + EntriesSection.builder("Professional Experience") + .entry("Principal Engineer", "Acme Rendering", "2022 - 2025", + "Owns the rendering pipeline and its release train.") + .build(), + EntriesSection.builder("Education") + .entry("MSc Computer Science", "University of Manchester", + "2018 - 2020", "Distinction.") + .build(), + SkillsSection.builder("Technical Skills") + .group("Platform", "Java 21", "Kotlin") + .build(), + projects.build(), + RowsSection.builder("Publications", RowStyle.PLAIN) + .row("Deterministic layout", "JVM Summit 2024") + .build())) + .build(); + } +} diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal_projects_main-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal_projects_main-page-0.png new file mode 100644 index 00000000..68e4ab76 Binary files /dev/null and b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal_projects_main-page-0.png differ diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java index a6d6417f..c26c337b 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CvTemplates.java @@ -32,8 +32,10 @@ * templates, they are templates with a fixed idea of what a CV contains.

* *

Every lookup builds a fresh template with the preset's own default - * theme; a caller wanting a variant calls that preset's - * {@code create(BrandTheme)} directly. {@code CvTemplatesCoverageTest} holds + * theme and default layout; a caller wanting a variant calls that preset's + * own factory directly — {@code create(BrandTheme)}, or {@code create(Options)} + * where the preset offers one, as {@code NordicClean} and + * {@code TimelineMinimal} do. {@code CvTemplatesCoverageTest} holds * this catalogue to the presets package, so a preset added and not * registered fails the build rather than staying invisible to every runtime * caller.

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 4a9da2a0..b4e00866 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 @@ -34,7 +34,8 @@ *

Minimal two-column CV with a vertical timeline axis between the * sidebar (Education / Skills / Expertise / Languages / Interests / * References) and the main column (Professional Profile / Work - * Experience). Visual signature ported from the v1 + * Experience) — the default arrangement, which {@link Options} lets a + * caller change for the projects. Visual signature ported from the v1 * {@code TimelineMinimalCvTemplateComposer}: spaced caps name in * Barlow Condensed, contact stack with PNG icons, all-grey palette, * three timeline dots between four axis segments.

@@ -63,6 +64,21 @@ * 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.

+ * + *

Choosing the projects column

+ * + *

The sidebar suits a list of short project labels. A CV whose projects + * carry a paragraph each fills it long before the wide column fills, so the + * default draws them onto a second page while the main column ends half + * empty. Which column they belong in is a property of the CV rather than of + * the design:

+ * + *
{@code
+ * TimelineMinimal.create(
+ *         TimelineMinimal.Options.builder()
+ *                 .projectsColumn(TimelineMinimal.Column.MAIN)
+ *                 .build());
+ * }
*/ public final class TimelineMinimal { @@ -184,7 +200,7 @@ private TimelineMinimal() { * @return ready-to-use template */ public static DocumentTemplate create() { - return create(BrandTheme.timelineMinimal()); + return create(BrandTheme.timelineMinimal(), Options.defaults()); } /** @@ -194,11 +210,134 @@ public static DocumentTemplate create() { * @return ready-to-use template */ public static DocumentTemplate create(BrandTheme theme) { + return create(theme, Options.defaults()); + } + + /** + * Builds the preset with its own theme and caller-supplied options. + * + *

Ambiguous against {@link #create(BrandTheme)} for a bare + * {@code null} literal; pass {@link Options#defaults()} rather than + * {@code null} to ask for the stock layout.

+ * + * @param options placement options + * @return ready-to-use template + * @throws NullPointerException if {@code options} is null + * @since 2.3.0 + */ + public static DocumentTemplate create(Options options) { + return create(BrandTheme.timelineMinimal(), options); + } + + /** + * Builds the preset with a caller-supplied theme and options. + * + * @param theme active theme + * @param options placement options + * @return ready-to-use template + * @throws NullPointerException if either argument is null + * @since 2.3.0 + */ + public static DocumentTemplate create(BrandTheme theme, Options options) { Objects.requireNonNull(theme, "theme"); - return new Template(theme); + // Rejected rather than defaulted, which is what every other preset + // carrying an Options record does: a null arriving from a caller's + // configuration is a dropped value, and rendering the stock layout for + // it hides that until somebody asks why the projects never moved. + Objects.requireNonNull(options, "options"); + return new Template(theme, options); + } + + /** + * Which of this preset's two columns a section is drawn in. + * + *

Not to be confused with + * {@link com.demcha.compose.document.templates.cv.data.Slot}, which shares + * two of these names: a slot is where the author of the CV put a + * section, and every preset here reads {@code Slot.MAIN}. This is where + * this preset then draws one of them.

+ * + * @since 2.3.0 + */ + public enum Column { + /** The narrow left column, beside Education and Skills. */ + SIDEBAR, + /** The wide right column, under the profile and the work history. */ + MAIN } - private record Template(BrandTheme theme) implements DocumentTemplate { + /** + * Placement this preset accepts from the caller. + * + *

The narrow column suits a list of short labels. A CV whose projects + * carry a paragraph each fills it faster than the wide column fills, so + * the projects run onto a page of their own while the main column ends + * half empty and every technical line wraps three times. Moving them is a + * property of the CV, not of the design, which is why it is a caller's + * choice rather than a second preset.

+ * + * @param projectsColumn where the projects section is drawn; + * {@code null} means {@link Column#SIDEBAR} + * @since 2.3.0 + */ + public record Options(Column projectsColumn) { + + /** Normalises a null column to the default. */ + public Options { + projectsColumn = projectsColumn == null ? Column.SIDEBAR : projectsColumn; + } + + /** + * The layout this preset has always drawn: projects in the sidebar. + * + * @return the default options + */ + public static Options defaults() { + return new Options(Column.SIDEBAR); + } + + /** + * Starts a mutable builder. + * + * @return new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for {@link Options}. + */ + public static final class Builder { + private Column projectsColumn = Column.SIDEBAR; + + private Builder() { + } + + /** + * Sets the column the projects section is drawn in. + * + * @param value target column + * @return this builder + */ + public Builder projectsColumn(Column value) { + this.projectsColumn = value; + return this; + } + + /** + * Builds the configured options. + * + * @return a new {@link Options} + */ + public Options build() { + return new Options(projectsColumn); + } + } + } + + private record Template(BrandTheme theme, Options options) + implements DocumentTemplate { @Override public String id() { @@ -229,15 +368,29 @@ public void compose(DocumentSession document, CvDocument doc) { CvSection summary = allocation.claim(SectionRole.SUMMARY, SUMMARY_KEYS); CvSection experience = allocation.claim(SectionRole.EXPERIENCE, EXPERIENCE_KEYS); - List sidebar = modules( - module(education, "Education"), - module(skills, "Skills"), - module(projects, "Expertise"), - module(additional, "Languages")); + // Built once and placed in one column or the other, so the + // default is the same block in the same list it always was. + ColumnPagination.Block projectsBlock = module(projects, "Expertise"); + boolean projectsInMain = options.projectsColumn() == Column.MAIN; + + List sidebar = projectsInMain + ? modules(module(education, "Education"), + module(skills, "Skills"), + module(additional, "Languages")) + : modules(module(education, "Education"), + module(skills, "Skills"), + projectsBlock, + module(additional, "Languages")); List main = new ArrayList<>(); addModule(main, prose(summary, "Professional Profile")); addModule(main, module(experience, "Work Experience")); + // After the work history and before the leftover tail: a reader + // scanning the wide column meets the career first and the + // projects that came out of it second. + if (projectsInMain) { + addModule(main, projectsBlock); + } // 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. A paragraph keeps reading as