diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e37d32..a69fb198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -289,6 +289,18 @@ for this cycle. page-spanning body. Inert when nothing follows (a trailing heading is never moved) and best-effort when the heading plus one line cannot share a page. Default off, so layouts that do not opt in are unchanged. +- `LineBuilder.keepWithNext()` — the line counterpart of + `SectionBuilder.keepWithNext()`, so a full-width header rule joins its banner's + keep-with-next run and the whole title block (rule + banner + rule) relocates + together instead of the banner stranding apart from its rules or its body. +- **Single-column CV presets no longer orphan a section title.** Every preset whose + sections flow down the page — BoxedSections, MinimalUnderlined, ModernProfessional, + Executive, CenteredHeadline, BlueBanner, EditorialBlue, and ClassicSerif — keeps a + section heading with the first line of its body across a page break: a standalone + header section binds to the following body section (a multi-node rule + banner + rule + group binds as one run), and a combined header+body module keeps the heading in a + nested keep-with-next group. Multi-column presets place their sections in fixed + columns that do not paginate, so no heading can strand there. - **Reproducible PDF output** (`@Beta`). `PdfFixedLayoutBackend.builder().deterministic(true)` (or `.deterministic(Instant)` for an explicit timestamp) pins the document CreationDate / ModDate and derives the PDF `/ID` from the document metadata instead diff --git a/core/src/main/java/com/demcha/compose/document/dsl/LineBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/LineBuilder.java index f01eb7f8..6b5fe1d4 100644 --- a/core/src/main/java/com/demcha/compose/document/dsl/LineBuilder.java +++ b/core/src/main/java/com/demcha/compose/document/dsl/LineBuilder.java @@ -32,6 +32,7 @@ public final class LineBuilder implements Transformable { private DocumentDashPattern dashPattern = DocumentDashPattern.NONE; private DocumentLineCap lineCap = DocumentLineCap.BUTT; private boolean fillWidth = false; + private boolean keepWithNext = false; /** * Creates a line builder. @@ -280,6 +281,33 @@ public LineBuilder fill() { return this; } + /** + * Keeps this line with the block that follows it: the line relocates to the + * next page rather than stranding at a page bottom apart from the content it + * leads into. Used so a header rule joins its banner's keep-with-next run and + * the whole title block (rule + banner + rule) moves together — see + * {@link SectionBuilder#keepWithNext()}. + * + * @return this builder + * @since 2.0.0 + */ + public LineBuilder keepWithNext() { + this.keepWithNext = true; + return this; + } + + /** + * Sets whether the line stays with the block that follows it. + * + * @param value true to keep the line with the next block + * @return this builder + * @since 2.0.0 + */ + public LineBuilder keepWithNext(boolean value) { + this.keepWithNext = value; + return this; + } + /** * Attaches line-level external link metadata. * @@ -404,7 +432,8 @@ public LineNode build() { dashPattern, anchor, lineCap, - fillWidth); + fillWidth, + keepWithNext); } private boolean isHorizontalLine() { diff --git a/core/src/main/java/com/demcha/compose/document/node/LineNode.java b/core/src/main/java/com/demcha/compose/document/node/LineNode.java index 8c2fdfdd..716ac667 100644 --- a/core/src/main/java/com/demcha/compose/document/node/LineNode.java +++ b/core/src/main/java/com/demcha/compose/document/node/LineNode.java @@ -34,6 +34,11 @@ * available where it is placed (its row slot, or the * content width) instead of {@code width}; the flex line * behind a dot leader. Defaults to {@code false}. + * @param keepWithNext when {@code true}, the line stays with the block that + * follows it rather than stranding at a page bottom apart + * from it (see {@link DocumentNode#keepWithNext()}); lets a + * header rule join its banner's keep-with-next run. + * Defaults to {@code false}. * @author Artem Demchyshyn */ public record LineNode( @@ -53,7 +58,8 @@ public record LineNode( DocumentDashPattern dashPattern, String anchor, DocumentLineCap lineCap, - boolean fillWidth + boolean fillWidth, + boolean keepWithNext ) implements DocumentNode { /** * Normalizes spacing defaults and validates explicit line geometry. @@ -74,6 +80,49 @@ public record LineNode( requireFinite(endY, "endY"); } + /** + * Backward-compatible canonical constructor without the keep-with-next flag — + * defaults to {@code false} (normal flow, byte-identical placement). + * + * @param name node name used in snapshots and layout graph paths + * @param width resolved line box width + * @param height resolved line box height + * @param startX line start x offset inside the box + * @param startY line start y offset inside the box + * @param endX line end x offset inside the box + * @param endY line end y offset inside the box + * @param stroke line stroke descriptor + * @param linkTarget optional node-level link target + * @param bookmarkOptions optional node-level bookmark metadata + * @param padding inner padding + * @param margin outer margin + * @param transform render-time affine transform + * @param dashPattern dash pattern for the stroke + * @param anchor optional navigation anchor name + * @param lineCap end-cap style for the stroke + * @param fillWidth whether the line stretches to the available width + */ + public LineNode(String name, + double width, + double height, + double startX, + double startY, + double endX, + double endY, + DocumentStroke stroke, + DocumentLinkTarget linkTarget, + DocumentBookmarkOptions bookmarkOptions, + DocumentInsets padding, + DocumentInsets margin, + DocumentTransform transform, + DocumentDashPattern dashPattern, + String anchor, + DocumentLineCap lineCap, + boolean fillWidth) { + this(name, width, height, startX, startY, endX, endY, stroke, linkTarget, bookmarkOptions, + padding, margin, transform, dashPattern, anchor, lineCap, fillWidth, false); + } + /** * Backward-compatible canonical constructor without the fill flag — defaults * to {@code false} (a fixed-width, byte-identical line). diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/PresetHeaderKeepWithNextTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/PresetHeaderKeepWithNextTest.java new file mode 100644 index 00000000..84f77e67 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/PresetHeaderKeepWithNextTest.java @@ -0,0 +1,152 @@ +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.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.style.DocumentColor; +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.CvSkill; +import com.demcha.compose.document.templates.cv.data.EntriesSection; +import com.demcha.compose.document.templates.cv.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.data.SkillsSection; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies the preset-level keep-with-next wiring: single-column CV presets keep a + * section heading with the first line of its body, marking keep-with-next at the + * level that binds the heading to the body — the header SECTION for standalone-header + * presets, the nested header SUBSECTION for combined header+body presets — and + * crucially NOT the combined body/module section itself, which would wrongly bind it + * to the next module. The pagination mechanism is covered by {@code + * SectionKeepWithNextTest}; the last test here exercises the nested (combined-preset) + * shape end-to-end across a page break. + */ +class PresetHeaderKeepWithNextTest { + + private static final DocumentColor GREY = DocumentColor.rgb(220, 220, 220); + private static final DocumentColor INK = DocumentColor.rgb(20, 80, 95); + + private static CvDocument sampleDoc() { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Test", "User").jobTitle("Engineer") + .contact("+1 555 0100", "user@example.com", "City").build()) + .section(new ParagraphSection("Professional Summary", "Summary text goes here.")) + .section(SkillsSection.builder("Technical Skills") + .leveledGroup("Languages", List.of(CvSkill.of("Java", 0.9), CvSkill.of("Kotlin", 0.8))) + .build()) + .section(EntriesSection.builder("Professional Experience") + .entry("Senior Engineer", "ACME", "2020-2024", "Built things.") + .entry("Engineer", "Globex", "2018-2020", "Built more things.") + .build()) + .section(EntriesSection.builder("Education") + .entry("BSc Computer Science", "University", "2014-2018", "Studied.") + .build()) + .build(); + } + + private static List nodesOf(DocumentTemplate template) { + try (DocumentSession document = GraphCompose.document() + .pageSize(DocumentPageSize.A4).margin(28, 28, 28, 28).create()) { + template.compose(document, sampleDoc()); + List out = new ArrayList<>(); + collect(document.roots(), out); + return out; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static void collect(List nodes, List out) { + for (DocumentNode node : nodes) { + out.add(node); + collect(node.children(), out); + } + } + + /** Standalone-header preset: every banner section is kept with next; no body section is. */ + @Test + void boxedSectionsKeepsBannerNotBody() { + List nodes = nodesOf(BoxedSections.create()); + assertThat(nodes.stream().filter(n -> n.name().startsWith("CvV2Banner")).toList()) + .isNotEmpty().allMatch(DocumentNode::keepWithNext); + assertThat(nodes.stream().filter(n -> n.name().startsWith("CvV2Body")).toList()) + .isNotEmpty().noneMatch(DocumentNode::keepWithNext); + } + + /** + * Combined header+body preset: the nested header subsection is kept with next, but + * the module section that wraps header and body is NOT — marking it would bind the + * whole module to the next module instead of keeping the title with its own body. + */ + @Test + void classicSerifKeepsHeaderSubsectionNotModule() { + List nodes = nodesOf(ClassicSerif.create()); + assertThat(nodes.stream().filter(n -> n.name().startsWith("CvV2ClassicSerif")).toList()) + .isNotEmpty().noneMatch(DocumentNode::keepWithNext); + assertThat(nodes.stream().filter(n -> "Header".equals(n.name())).toList()) + .isNotEmpty().allMatch(DocumentNode::keepWithNext); + } + + /** Multi-node banner header: the whole rule + banner + rule title run is kept together. */ + @Test + void blueBannerKeepsWholeTitleRun() { + List nodes = nodesOf(BlueBanner.create()); + List firstRun = nodes.stream() + .filter(n -> n.name().startsWith("BlueBannerTitle_0")).toList(); + assertThat(firstRun).hasSizeGreaterThanOrEqualTo(3).allMatch(DocumentNode::keepWithNext); + } + + /** + * End-to-end for the combined-preset shape: a module section whose header is a + * nested keep-with-next subsection relocates the header with the first line of the + * body across a page break, rather than stranding it at the page bottom. + */ + @Test + void nestedHeaderSubsectionStaysWithBodyAcrossBoundary() { + int headerPage = combinedModulePages(true, "HeaderMark"); + int bodyPage = combinedModulePages(true, "BodyMark"); + assertThat(headerPage).isEqualTo(bodyPage); + + // Control: without the opt-in, the header strands on the earlier page. + assertThat(combinedModulePages(false, "HeaderMark")) + .isLessThan(combinedModulePages(false, "BodyMark")); + } + + private static int combinedModulePages(boolean keepWithNext, String mark) { + try (DocumentSession document = GraphCompose.document() + .pageSize(300, 400).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) { + document.pageFlow().name("Flow").spacing(12) + .addSection("Filler", s -> s.addShape(260, 250, GREY)) + .addSection("Module", host -> { + host.spacing(12); + // Header nested in its own subsection (the ClassicSerif shape). + host.addSection("Header", header -> { + if (keepWithNext) { + header.keepWithNext(); + } + header.addShape(shape -> shape.name("HeaderMark").size(260, 40).fillColor(INK)); + }); + host.addShape(shape -> shape.name("BodyMark").size(260, 80).fillColor(INK)); + }) + .build(); + LayoutGraph graph = document.layoutGraph(); + PlacedNode node = graph.nodes().stream() + .filter(n -> mark.equals(n.semanticName())) + .findFirst().orElseThrow(); + return node.startPage(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java index 412ee053..9cbdd108 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java @@ -122,8 +122,11 @@ public void compose(DocumentSession document, CvDocument doc) { for (int i = 0; i < sections.size(); i++) { final CvSection sec = sections.get(i); final int idx = i; - pageFlow.addSection("CvV2Banner_" + idx, - host -> SectionHeader.banner(host, sec.title(), theme)); + pageFlow.addSection("CvV2Banner_" + idx, host -> { + // Keep the banner with the first line of its body across a page break. + host.keepWithNext(); + SectionHeader.banner(host, sec.title(), theme); + }); pageFlow.addSection("CvV2Body_" + idx, host -> SectionDispatcher.renderBody(host, sec, theme)); } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java index 64c1afd5..0cf25e9b 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/CenteredHeadline.java @@ -149,11 +149,17 @@ public void compose(DocumentSession document, CvDocument doc) { for (int i = 0; i < sections.size(); i++) { final CvSection sec = sections.get(i); final int idx = i; - pageFlow.addSection("Title_" + idx, host -> - SectionHeader.flatSpacedCaps(host, sec.title(), - theme.palette().muted(), theme, null)); - pageFlow.addLine(line -> - rule(line, "TitleBottomRule_" + idx, ruleWidth, 8, 8)); + // Title + its trailing rule form one keep-with-next run so the group + // stays with the first line of the body across a page break. + pageFlow.addSection("Title_" + idx, host -> { + host.keepWithNext(); + SectionHeader.flatSpacedCaps(host, sec.title(), + theme.palette().muted(), theme, null); + }); + pageFlow.addLine(line -> { + rule(line, "TitleBottomRule_" + idx, ruleWidth, 8, 8); + line.keepWithNext(); + }); pageFlow.addSection("Body_" + idx, host -> renderBody(host, sec)); if (idx < sections.size() - 1) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java index dd78d531..e4dbab8d 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ClassicSerif.java @@ -169,6 +169,24 @@ private void addSummary(PageFlowBuilder flow, CvSection section) { }); } + /** + * Renders the spaced-caps rule header inside a keep-with-next subsection so + * the title + rule stay with the first line of the module body across a page + * break instead of stranding at a page bottom. The subsection's spacing + * matches the module spacing and it adds no padding or margin, so header and + * body placement is unchanged from rendering the header directly into the + * module host. + */ + private void keptHeader(SectionBuilder host, String title) { + host.addSection("Header", header -> { + header.keepWithNext() + .spacing(theme.spacing().sectionBodySpacing()); + SectionHeader.spacedCapsRule(header, title, theme, + titleStyle(), ACCENT, 72, 1.0, + new DocumentInsets(0, 0, 2, 0)); + }); + } + private void addCoverSkillsModule(PageFlowBuilder flow, CvSection section) { if (!SectionLookup.hasContent(section)) { return; @@ -177,9 +195,7 @@ private void addCoverSkillsModule(PageFlowBuilder flow, CvSection section) { flow.addSection("CvV2ClassicSerifCoreSkills", host -> { host.spacing(theme.spacing().sectionBodySpacing()) .padding(new DocumentInsets(0, 0, 2, 0)); - SectionHeader.spacedCapsRule(host, "Core Skills", theme, - titleStyle(), ACCENT, 72, 1.0, - new DocumentInsets(0, 0, 2, 0)); + keptHeader(host, "Core Skills"); renderCoverSkillsBody(host, section); }); } @@ -205,9 +221,7 @@ private void addLinearModule(PageFlowBuilder flow, String title, flow.addSection("CvV2ClassicSerif" + SectionLookup.normalize(title), host -> { host.spacing(theme.spacing().sectionBodySpacing()) .padding(new DocumentInsets(0, 0, 2, 0)); - SectionHeader.spacedCapsRule(host, title, theme, - titleStyle(), ACCENT, 72, 1.0, - new DocumentInsets(0, 0, 2, 0)); + keptHeader(host, title); renderDetailBody(host, section); }); } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java index 71ceca99..95ae92d3 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/Executive.java @@ -125,10 +125,12 @@ public void compose(DocumentSession document, CvDocument doc) { for (int i = 0; i < sections.size(); i++) { CvSection sec = sections.get(i); int idx = i; - flow.addSection("CvV2ExecutiveTitle_" + idx, host -> - SectionHeader.flat(host, - sec.title().toUpperCase(Locale.ROOT), - ACCENT, theme)); + flow.addSection("CvV2ExecutiveTitle_" + idx, host -> { + host.keepWithNext(); + SectionHeader.flat(host, + sec.title().toUpperCase(Locale.ROOT), + ACCENT, theme); + }); flow.addSection("CvV2ExecutiveBody_" + idx, host -> SectionDispatcher.renderBody(host, sec, theme)); } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java index 5ff991bb..a9320676 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/MinimalUnderlined.java @@ -118,8 +118,10 @@ public void compose(DocumentSession document, CvDocument doc) { for (int i = 0; i < sections.size(); i++) { final CvSection sec = sections.get(i); final int idx = i; - pageFlow.addSection("Title_" + idx, host -> - SectionHeader.underlined(host, sec.title(), theme)); + pageFlow.addSection("Title_" + idx, host -> { + host.keepWithNext(); + SectionHeader.underlined(host, sec.title(), theme); + }); pageFlow.addSection("Body_" + idx, host -> SectionDispatcher.renderBody(host, sec, theme)); } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java index 4df65608..67a59336 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ModernProfessional.java @@ -173,8 +173,10 @@ public void compose(DocumentSession document, CvDocument doc) { for (int i = 0; i < sections.size(); i++) { final CvSection sec = sections.get(i); final int idx = i; - pageFlow.addSection("Title_" + idx, host -> - SectionHeader.flat(host, sec.title(), SECTION_TITLE_COLOR, theme)); + pageFlow.addSection("Title_" + idx, host -> { + host.keepWithNext(); + SectionHeader.flat(host, sec.title(), SECTION_TITLE_COLOR, theme); + }); pageFlow.addSection("Body_" + idx, host -> SectionDispatcher.renderBody(host, sec, theme)); } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/FlowSectionHeader.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/FlowSectionHeader.java index 0e6fd0c6..e84e157f 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/FlowSectionHeader.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/FlowSectionHeader.java @@ -71,8 +71,12 @@ public static void banner(PageFlowBuilder flow, DocumentInsets bottomRuleMargin) { addRule(flow, name + "RuleTop", ruleWidth, ruleColor, theme, topRuleMargin); - flow.addSection(name, host -> SectionHeader.fullWidthBanner(host, - title, theme, titleStyle)); + flow.addSection(name, host -> { + // Whole title group (rule + banner + rule) is one keep-with-next run so it + // relocates together and stays with the first line of the body below. + host.keepWithNext(); + SectionHeader.fullWidthBanner(host, title, theme, titleStyle); + }); addRule(flow, name + "RuleBottom", ruleWidth, ruleColor, theme, bottomRuleMargin); } @@ -139,6 +143,7 @@ public static void label(PageFlowBuilder flow, topRuleMargin); } flow.addSection(name, section -> section + .keepWithNext() .spacing(0) .padding(titlePadding) .addParagraph(paragraph -> paragraph @@ -156,8 +161,12 @@ private static void addRule(PageFlowBuilder flow, DocumentColor color, BrandTheme theme, DocumentInsets margin) { + // Rules are marked keep-with-next so the whole title run (top rule + banner + // + bottom rule) relocates together and the banner never strands apart from + // its rules or its body across a page break. flow.addLine(line -> line .name(name) + .keepWithNext() .horizontal(width) .color(color) .thickness(theme.spacing().accentRuleWidth())