From afa50bf489ee17366046121dfb3a50d8744d83ca Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 22 Jul 2026 01:49:27 +0100 Subject: [PATCH 1/2] feat(engine): keep a heading with the first line of its following block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A section header emitted as its own block strands at a page bottom when the first line of the block below it does not fit in the remaining space: the header flows on page N while its body starts on page N+1 — a boxed title torn from its background. keepTogether() cannot fix this; it relocates the whole block, so a page-spanning body makes it inert and the orphan returns. Add an opt-in keepWithNext() (CSS break-after:avoid): a section may not be the last placed block on its page when a sibling with content follows. When the section plus the first line of that block overflow the remaining space but fit on a fresh page, the compiler relocates the section so the heading stays with its body. - LayoutCompiler runs a run-aware lookahead in the vertical child loop: at the start of a run of consecutive keep-with-next siblings it sums the run plus the next block's leading line and hoists the break before the run's first member, so a multi-part heading (rule + banner + rule) moves as a unit. - leadingUnitHeight descends the following block's first-child chain to its first paragraph line; an indivisible or non-paragraph-splittable first unit is kept whole. Best-effort: a heading plus one line that cannot share a page flows in place; inert when nothing follows. - keepWithNext() on DocumentNode (default false), SectionNode (new component plus back-compat constructor), and SectionBuilder. Default off, so layouts that do not opt in are byte-identical. --- CHANGELOG.md | 10 + .../compose/document/dsl/SectionBuilder.java | 34 ++- .../document/layout/LayoutCompiler.java | 92 +++++++- .../compose/document/node/DocumentNode.java | 33 +++ .../compose/document/node/SectionNode.java | 42 +++- docs/recipes/keep-together.md | 45 ++++ .../document/dsl/SectionKeepWithNextTest.java | 219 ++++++++++++++++++ 7 files changed, 472 insertions(+), 3 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/dsl/SectionKeepWithNextTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 1256eefd5..13daa51c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,16 @@ for this cycle. ### Public API +- **Keep a heading with its content** — `SectionBuilder.keepWithNext()`. A section + marked keep-with-next is never left stranded as the last block on a page apart from + the content it introduces: when the section plus the first line of the following + block would overflow the remaining page space (but fit on a fresh page), the section + relocates to the next page so the heading stays glued to its body. Distinct from + `keepTogether()`, which relocates a *whole* block — keep-with-next binds only the + first following line, the right tool for a boxed section title above a long, + 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. - **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/SectionBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java index deaa99046..78972e64b 100644 --- a/core/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java +++ b/core/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java @@ -10,6 +10,7 @@ */ public final class SectionBuilder extends AbstractFlowBuilder { private boolean keepTogether = false; + private boolean keepWithNext = false; /** * Creates a section builder. @@ -48,10 +49,41 @@ public SectionBuilder keepTogether(boolean value) { return this; } + /** + * Keeps this section with the block that follows it: when the section plus the + * first line of the next block would not fit in the remaining page space (but + * fit on a fresh page), the section relocates to the next page rather than + * stranding at a page bottom apart from the content it introduces. This is the + * orphaned-heading fix for a boxed section title — it keeps the title with only + * the first line of a long, page-spanning body, unlike {@link #keepTogether()} + * which would try to keep the whole body together. The rule is inert when + * nothing follows the section on the page. + * + * @return this builder + * @since 2.0.0 + */ + public SectionBuilder keepWithNext() { + this.keepWithNext = true; + return this; + } + + /** + * Sets whether the section stays with the block that follows it. + * + * @param value true to keep the section with the first line of the next block + * @return this builder + * @since 2.0.0 + */ + public SectionBuilder keepWithNext(boolean value) { + this.keepWithNext = value; + return this; + } + @Override protected SectionNode buildNode() { return new SectionNode(name(), children(), spacing(), padding(), margin(), fillColor(), - stroke(), cornerRadius(), borders(), keepTogether, anchor(), bleed(), bookmarkOptions()); + stroke(), cornerRadius(), borders(), keepTogether, anchor(), bleed(), bookmarkOptions(), + keepWithNext); } /** diff --git a/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index 9edcca232..2b7a1abf8 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -1,6 +1,7 @@ package com.demcha.compose.document.layout; import com.demcha.compose.document.exceptions.AtomicNodeTooLargeException; +import com.demcha.compose.document.layout.payloads.PreparedParagraphLayout; import com.demcha.compose.document.layout.payloads.PreparedStackLayout; import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.LayerStackNode; @@ -298,8 +299,49 @@ private void compileComposite(PreparedNode prepared, thisChildRegionWidth = Math.max(0.0, (pageRegionWidth - margin.horizontal()) - padding.horizontal()); thisChildRegionX = state.marginLeftForPage(childStartPage) + margin.left() + padding.left(); } + PreparedNode childPrepared = + prepareForRegionWidth(prepareContext, child, thisChildRegionWidth); + + // Opt-in keep-with-next: at the start of a run of consecutive + // keep-with-next siblings, ensure the whole run plus the first line of the + // block it introduces shares one page. Hoisting the break to before the + // run's first member lets a multi-part heading (rule + banner + rule) + // relocate as a unit, so no part is stranded above its body. Inert when + // the run ends the flow (nothing to introduce) and best-effort when the + // run plus one line cannot fit even on a fresh page — matching + // keepTogether's fallback. Gated on keepWithNext(), so layouts that do not + // opt in are byte-identical. + if (child.keepWithNext() + && (index == 0 || !children.get(index - 1).keepWithNext()) + && state.usedHeight > EPS) { + int runEnd = index; + while (runEnd < children.size() && children.get(runEnd).keepWithNext()) { + runEnd++; + } + if (runEnd < children.size()) { + double needed = 0.0; + for (int k = index; k < runEnd; k++) { + PreparedNode memberPrepared = k == index + ? childPrepared + : prepareForRegionWidth(prepareContext, children.get(k), childRegionWidth); + needed += memberPrepared.measureResult().height() + + toMargin(children.get(k).margin()).vertical() + + layoutSpec.spacing(); + } + needed += leadingUnitHeight(children.get(runEnd), childRegionWidth, prepareContext); + // Relocate only when the run + first line genuinely fits on a fresh + // page (EPS, not CAPACITY_TOLERANCE): a run that would still overflow + // the next page by a hair should stay put rather than strand there and + // waste this page too. + if (needed > state.remainingHeight() + EPS + && needed <= state.activeInnerHeight() + EPS) { + state.newPage(); + } + } + } + compileNode( - prepareForRegionWidth(prepareContext, child, thisChildRegionWidth), + childPrepared, path, index, depth + 1, @@ -988,6 +1030,54 @@ private double childAvailableWidth(double regionWidth, DocumentNode node) { return Math.max(0.0, regionWidth - margin.horizontal()); } + /** + * Best-effort height a node consumes to place its first flow line: the top + * reservation (margin-top + padding-top) plus the leading unit of the content + * below it — the first child's leading unit for a vertical composite, the + * first visual line for a paragraph, or the whole outer height for any other + * leaf or an atomic (row / stack) composite that cannot split. + * + *

Used only by the opt-in {@link DocumentNode#keepWithNext()} lookahead to + * decide whether a heading would strand apart from the block it introduces. A + * small over- or under-estimate only shifts a cosmetic page break by one line; + * it can never affect placement correctness, since the value gates a + * {@code newPage()} decision, not any geometry.

+ * + * @param node the following sibling whose first line anchors the heading + * @param regionWidth the content-region width the sibling is measured at + * @param prepareContext measurement context + * @return the height consumed down to the bottom of the node's first flow line + */ + private double leadingUnitHeight(DocumentNode node, double regionWidth, PrepareContext prepareContext) { + Margin margin = toMargin(node.margin()); + Padding padding = toPadding(node.padding()); + PreparedNode prepared = prepareForRegionWidth(prepareContext, node, regionWidth); + double topReservation = margin.top() + padding.top(); + + if (prepared.isComposite()) { + CompositeLayoutSpec layoutSpec = prepared.requireCompositeLayout(); + @SuppressWarnings("unchecked") + NodeDefinition definition = (NodeDefinition) registry.definitionFor(node); + List children = definition.children(node); + // A horizontal row or layer stack is atomic (never splits) and an empty + // vertical box has no first line — the whole box is the leading unit. + if (layoutSpec.axis() != CompositeLayoutSpec.Axis.VERTICAL || children.isEmpty()) { + return prepared.measureResult().height() + margin.vertical(); + } + double availableWidth = childAvailableWidth(regionWidth, node); + double innerRegionWidth = Math.max(0.0, availableWidth - padding.horizontal()); + return topReservation + leadingUnitHeight(children.get(0), innerRegionWidth, prepareContext); + } + + // A paragraph is anchored by its first visual line; every other leaf (image, + // shape, chart, non-paragraph splittable) moves as a whole indivisible unit. + if (prepared.preparedLayout() instanceof PreparedParagraphLayout paragraph + && !paragraph.visualLines().isEmpty()) { + return topReservation + paragraph.visualLines().get(0).lineHeight(); + } + return prepared.measureResult().height() + margin.vertical(); + } + private void addPlacedFragments(List emitted, FragmentPlacement placement, List fragments) { diff --git a/core/src/main/java/com/demcha/compose/document/node/DocumentNode.java b/core/src/main/java/com/demcha/compose/document/node/DocumentNode.java index 98ea62cfb..8c8943d42 100644 --- a/core/src/main/java/com/demcha/compose/document/node/DocumentNode.java +++ b/core/src/main/java/com/demcha/compose/document/node/DocumentNode.java @@ -72,6 +72,39 @@ default boolean keepTogether() { return false; } + /** + * Whether this node must stay with the block that follows it — it may not be + * left as the last placed block on its page when a subsequent sibling with + * content exists. When the node plus the first line of the following content + * would not fit in the remaining page space (but do fit on a fresh page), the + * compiler relocates the node to the next page so it stays glued to what it + * introduces (CSS {@code break-after: avoid} semantics). This is the + * orphaned-heading fix: a boxed section title never strands at a page bottom + * apart from its body. + * + *

Default {@code false} (normal flow). The rule is inert when nothing + * follows the node on the page (a trailing heading is not relocated), and + * best-effort: if the node plus one following line cannot fit even on a fresh + * page, the node flows in place. Unlike {@link #keepTogether()}, which keeps + * the whole block together, this keeps the node with only the first + * line of the next block — the right tool for a heading above a long, + * page-spanning body.

+ * + *

"First line" applies to a following block whose first flow unit is a line + * of text (the common case — a heading above prose or list entries). When the + * following block's first unit is indivisible (an image, a shape, a row) or a + * non-text splittable (a table or list), the node is instead kept with that + * whole first block, so a heading above a body taller than a page that starts + * with such a unit is left in place. Runs of consecutive keep-with-next + * siblings relocate together, with the break hoisted before the run.

+ * + * @return true to keep this node with the first line of the following block + * @since 2.0.0 + */ + default boolean keepWithNext() { + return false; + } + /** * Edges on which this node bleeds past the page content margin to the * trimmed physical page edge. Default {@link DocumentBleed#none()} (normal diff --git a/core/src/main/java/com/demcha/compose/document/node/SectionNode.java b/core/src/main/java/com/demcha/compose/document/node/SectionNode.java index 5f2751c39..cd879a104 100644 --- a/core/src/main/java/com/demcha/compose/document/node/SectionNode.java +++ b/core/src/main/java/com/demcha/compose/document/node/SectionNode.java @@ -26,6 +26,10 @@ * or {@link DocumentBleed#none()} for normal in-margin placement * @param bookmarkOptions optional PDF outline entry placed at the section's top on * its start page, or {@code null} for none + * @param keepWithNext when {@code true}, the section stays with the block that + * follows it — it is relocated to the next page rather than + * stranded at a page bottom apart from the first line of the + * following content (see {@link DocumentNode#keepWithNext()}) * @author Artem Demchyshyn */ public record SectionNode( @@ -41,7 +45,8 @@ public record SectionNode( boolean keepTogether, String anchor, DocumentBleed bleed, - DocumentBookmarkOptions bookmarkOptions + DocumentBookmarkOptions bookmarkOptions, + boolean keepWithNext ) implements DocumentNode { /** * Normalizes optional section fields and validates child spacing. @@ -61,6 +66,41 @@ public record SectionNode( } } + /** + * Backward-compatible constructor without the keep-with-next flag (defaults to + * normal flow). + * + * @param name node name + * @param children child nodes + * @param spacing vertical spacing + * @param padding inner padding + * @param margin outer margin + * @param fillColor optional background fill + * @param stroke optional uniform border stroke + * @param cornerRadius optional render-only corner radius + * @param borders optional per-side borders + * @param keepTogether keep-together relocation flag + * @param anchor optional navigation anchor name + * @param bleed optional bleed declaration + * @param bookmarkOptions optional PDF outline entry, or {@code null} for none + */ + public SectionNode(String name, + List children, + double spacing, + DocumentInsets padding, + DocumentInsets margin, + DocumentColor fillColor, + DocumentStroke stroke, + DocumentCornerRadius cornerRadius, + DocumentBorders borders, + boolean keepTogether, + String anchor, + DocumentBleed bleed, + DocumentBookmarkOptions bookmarkOptions) { + this(name, children, spacing, padding, margin, fillColor, stroke, cornerRadius, borders, keepTogether, + anchor, bleed, bookmarkOptions, false); + } + /** * Backward-compatible constructor without a bookmark (defaults to none). * diff --git a/docs/recipes/keep-together.md b/docs/recipes/keep-together.md index 3eea1db5c..61ff5a68f 100644 --- a/docs/recipes/keep-together.md +++ b/docs/recipes/keep-together.md @@ -41,3 +41,48 @@ Two boundaries to know: `Row`, `LayerStackNode`, `ShapeContainerNode`, and `CanvasLayerNode` are already atomic by design and never split — `keepTogether()` exists for the *composites* (sections, modules, timelines) that flow by default. + +## Keep a heading with its content: `keepWithNext()` + +`keepTogether()` keeps a *whole* block on one page — wrong for a section +heading above a long, page-spanning body: the body never fits, so the request +is ignored and the heading can still strand at a page bottom, apart from what +it introduces (a boxed title torn from its background is the visible symptom). + +`keepWithNext()` is the tool for that case. A section marked keep-with-next is +never left as the last block on a page when a sibling follows it: if the +section **plus the first line** of the following block would overflow the +remaining space (but fit on a fresh page), the section relocates to the next +page so the heading stays glued to its body. + +```java +document.pageFlow() + .addSection("ExperienceTitle", s -> s + .keepWithNext() // title follows its body down + .softPanel(DocumentColor.rgb(238, 240, 242), 4, 10) + .addParagraph(p -> p.text("PROFESSIONAL EXPERIENCE"))) + .addSection("ExperienceBody", s -> s // a long, page-spanning list + .addParagraph(/* … many entries … */)) + .build(); +``` + +The difference from `keepTogether()`: keep-with-next binds the heading to only +the **first following line**, not the whole body — so it works even when the +body spans several pages. + +"First line" means the first line of text when the following block starts with +prose or list entries (the usual heading-over-body case). When it instead starts +with an indivisible unit (an image, a row) or a table/list, the heading is kept +with that whole first block. Consecutive keep-with-next sections relocate as one +run, so a multi-part heading (rule + banner + rule) moves together. + +Two boundaries, mirroring `keepTogether()`: + +- **Inert when nothing follows.** A trailing heading (no following sibling, or + nothing that places a line on the page) is never relocated — there is no + orphan to avoid. +- **Best-effort.** If the heading plus one following line cannot share a page + at all, the heading flows in place rather than jumping to a page it still + cannot share with its body. + +Default off — sections without the opt-in flow exactly as before. diff --git a/qa/src/test/java/com/demcha/compose/document/dsl/SectionKeepWithNextTest.java b/qa/src/test/java/com/demcha/compose/document/dsl/SectionKeepWithNextTest.java new file mode 100644 index 000000000..ea8c07d5d --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/dsl/SectionKeepWithNextTest.java @@ -0,0 +1,219 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.GraphCompose; +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.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies the opt-in {@code keepWithNext()} pagination behavior: a section that + * would otherwise strand as the last block on its page — apart from the + * first line of the block it introduces — relocates to the next page so the + * heading stays glued to its content. The rule is inert when nothing follows and + * best-effort when the heading plus one following line cannot share a page. This + * is the fix for a boxed section title torn from its body across a page break. + * + *

Assertions read the page of the inner content leaf (the named shape + * or paragraph), not the section wrapper: a zero-reservation section whose content + * spills to the next page still records its box start on the prior page, so the + * wrapper's start page would not reflect where the content actually lands.

+ */ +class SectionKeepWithNextTest { + + private static final DocumentColor GREY = DocumentColor.rgb(220, 220, 220); + private static final DocumentColor INK = DocumentColor.rgb(20, 80, 95); + + private static int page(LayoutGraph graph, String name) { + PlacedNode node = graph.nodes().stream() + .filter(n -> name.equals(n.semanticName())) + .findFirst().orElseThrow(); + return node.startPage(); + } + + /** + * Header near the page bottom, followed by an atomic body block that does not + * fit below it: {@code keepWithNext()} pulls the header down to the next page + * so header and body land on the same page. + */ + @Test + void relocatesHeaderToStayWithAtomicBody() { + LayoutGraph on = atomicBody(true); + assertThat(page(on, "HeaderMark")).isEqualTo(page(on, "BodyMark")); + } + + /** + * Default flow (no opt-in): the header sits at the page bottom while the body + * that will not fit below it flows to the next page — the orphan this + * feature removes. Guards that the behavior is off unless requested. + */ + @Test + void withoutKeepWithNextHeaderStrandsFromBody() { + LayoutGraph off = atomicBody(false); + assertThat(page(off, "HeaderMark")).isLessThan(page(off, "BodyMark")); + } + + /** + * The body is a paragraph far taller than a page, so {@code keepTogether()} + * would be ignored (nothing can keep a page-spanning block whole). Yet + * {@code keepWithNext()} still relocates the header, because only the paragraph's + * first line must join it — the distinguishing behavior. + */ + @Test + void relocatesHeaderBasedOnFirstLineOfPageSpanningBody() { + LayoutGraph on = paragraphBody(true); + assertThat(page(on, "HeaderMark")).isEqualTo(page(on, "BodyMark")); + + LayoutGraph off = paragraphBody(false); + assertThat(page(off, "HeaderMark")).isLessThan(page(off, "BodyMark")); + } + + /** + * A multi-part heading — several consecutive keep-with-next siblings (a top + * rule, a banner, a bottom rule) — relocates as a whole unit: the break is + * hoisted before the first member, so no part is stranded above the body. + */ + @Test + void relocatesWholeHeaderRunNotJustLastElement() { + LayoutGraph on = headerRun(true); + int head1 = page(on, "Head1Mark"); + assertThat(page(on, "Head2Mark")).isEqualTo(head1); + assertThat(page(on, "BodyMark")).isEqualTo(head1); + + LayoutGraph off = headerRun(false); + // Default flow: both header parts sit at the page bottom while the body strands. + assertThat(page(off, "Head1Mark")).isEqualTo(page(off, "Head2Mark")); + assertThat(page(off, "BodyMark")).isGreaterThan(page(off, "Head2Mark")); + } + + /** + * A trailing {@code keepWithNext()} header with no following sibling is never + * relocated: with no line to keep it company there is no orphan to avoid, so + * the header stays where it lands. + */ + @Test + void inertWhenNothingFollows() { + try (DocumentSession document = GraphCompose.document() + .pageSize(300, 400) + .margin(DocumentInsets.of(20)) + .create()) { + document.pageFlow().name("Flow").spacing(12) + // Leaves a 40pt gap on page 0 — exactly the header height. + .addSection("Filler", s -> s.addShape(260, 308, GREY)) + .addSection("Header", s -> s.keepWithNext() + .addShape(shape -> shape.name("HeaderMark").size(260, 40).fillColor(INK))) + .build(); + + assertThat(page(document.layoutGraph(), "HeaderMark")).isEqualTo(0); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * When the header plus one following line cannot share a page, relocating to a + * fresh page would not help — so the rule stays inert and the header flows in + * place rather than pointlessly jumping to a page it still cannot share with + * its body. + */ + @Test + void bestEffortWhenHeaderPlusFirstLineExceedAPage() { + try (DocumentSession document = GraphCompose.document() + .pageSize(300, 400) + .margin(DocumentInsets.of(20)) + .create()) { + document.pageFlow().name("Flow").spacing(0) + // Tiny filler so the page is "used" but the near-full-page header still fits. + .addSection("Filler", s -> s.addShape(260, 3, GREY)) + .addSection("Header", s -> s.keepWithNext() + .addShape(shape -> shape.name("HeaderMark").size(260, 355).fillColor(INK))) + .addSection("Body", s -> s.addShape(shape -> shape.name("BodyMark").size(260, 80).fillColor(INK))) + .build(); + + LayoutGraph graph = document.layoutGraph(); + // Header stays on page 0 (not relocated); body still moves, as in default flow. + assertThat(page(graph, "HeaderMark")).isEqualTo(0); + assertThat(page(graph, "BodyMark")).isEqualTo(1); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** Filler fills page 0 to a 40pt gap; header (40pt) fits, header + body (80pt) does not. */ + private static LayoutGraph atomicBody(boolean keepWithNext) { + try (DocumentSession document = GraphCompose.document() + .pageSize(300, 400) + .margin(DocumentInsets.of(20)) + .create()) { + document.pageFlow().name("Flow").spacing(12) + .addSection("Filler", s -> s.addShape(260, 250, GREY)) + .addSection("Header", s -> { + if (keepWithNext) { + s.keepWithNext(); + } + s.addShape(shape -> shape.name("HeaderMark").size(260, 40).fillColor(INK)); + }) + .addSection("Body", s -> s.addShape(shape -> shape.name("BodyMark").size(260, 80).fillColor(INK))) + .build(); + return document.layoutGraph(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** Two 30pt header parts fit in the ~100pt gap; header run + 80pt body does not. */ + private static LayoutGraph headerRun(boolean keepWithNext) { + try (DocumentSession document = GraphCompose.document() + .pageSize(300, 400) + .margin(DocumentInsets.of(20)) + .create()) { + document.pageFlow().name("Flow").spacing(12) + .addSection("Filler", s -> s.addShape(260, 248, GREY)) + .addSection("Head1", s -> { + if (keepWithNext) { + s.keepWithNext(); + } + s.addShape(shape -> shape.name("Head1Mark").size(260, 30).fillColor(INK)); + }) + .addSection("Head2", s -> { + if (keepWithNext) { + s.keepWithNext(); + } + s.addShape(shape -> shape.name("Head2Mark").size(260, 30).fillColor(INK)); + }) + .addSection("Body", s -> s.addShape(shape -> shape.name("BodyMark").size(260, 80).fillColor(INK))) + .build(); + return document.layoutGraph(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** Header sits flush to the page bottom; the body is a paragraph taller than a page. */ + private static LayoutGraph paragraphBody(boolean keepWithNext) { + String pageSpanning = "Experience line. ".repeat(200); + try (DocumentSession document = GraphCompose.document() + .pageSize(300, 400) + .margin(DocumentInsets.of(20)) + .create()) { + document.pageFlow().name("Flow").spacing(12) + // 308 + spacing(12) + header(40) = 360 inner height: header lands flush to the bottom. + .addSection("Filler", s -> s.addShape(260, 308, GREY)) + .addSection("Header", s -> { + if (keepWithNext) { + s.keepWithNext(); + } + s.addShape(shape -> shape.name("HeaderMark").size(260, 40).fillColor(INK)); + }) + .addSection("Body", s -> s.addParagraph(p -> p.name("BodyMark").text(pageSpanning))) + .build(); + return document.layoutGraph(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} From ad546878328648c19de4fbc6ab414a90faefecdd Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 22 Jul 2026 02:09:58 +0100 Subject: [PATCH 2/2] feat(templates): keep single-column CV preset section titles with their bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CV presets emit a section header that flows down the page ahead of its body; when the first line of the body does not fit in the remaining space the header strands at a page bottom apart from its content. Wire the single-column presets to the keep-with-next primitive. Which node to mark is a composition decision: a header that is its own page-flow section marks that section (binding it to the following body section); a header that shares one section with its body marks a nested group around the header (binding it to the body's first line, not the next module). A shared widget cannot tell those apart, so the policy lives in the presets and the widgets stay pure rendering. - Standalone-header presets mark the header section at addSection: BoxedSections, MinimalUnderlined, ModernProfessional, Executive. CenteredHeadline also marks its trailing rule so the title + rule group binds as one run. - FlowSectionHeader (BlueBanner, EditorialBlue) marks its whole rule + banner + rule run so the title block relocates as a unit. - ClassicSerif renders header + body into one section; its header now sits in a nested keep-with-next subsection whose spacing matches the module, so the title stays with the body's first line while placement is unchanged. - LineNode.keepWithNext() (new component plus back-compat constructor) and LineBuilder.keepWithNext() — the line counterpart of the section flag, so a header rule joins its title run. Default off, byte-identical for lines that do not opt in. Multi-column presets place their sections in fixed Row columns that never paginate, so no heading can strand there. --- CHANGELOG.md | 12 ++ .../compose/document/dsl/LineBuilder.java | 31 +++- .../compose/document/node/LineNode.java | 51 +++++- .../presets/PresetHeaderKeepWithNextTest.java | 152 ++++++++++++++++++ .../templates/cv/presets/BoxedSections.java | 7 +- .../cv/presets/CenteredHeadline.java | 16 +- .../templates/cv/presets/ClassicSerif.java | 26 ++- .../templates/cv/presets/Executive.java | 10 +- .../cv/presets/MinimalUnderlined.java | 6 +- .../cv/presets/ModernProfessional.java | 6 +- .../cv/widgets/FlowSectionHeader.java | 13 +- 11 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/presets/PresetHeaderKeepWithNextTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 13daa51c8..7404933f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,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 f01eb7f83..6b5fe1d4d 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 8c2fdfddc..716ac667c 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 000000000..84f77e67e --- /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 412ee053c..9cbdd108e 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 64c1afd55..0cf25e9b3 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 dd78d5316..e4dbab8da 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 71ceca99e..95ae92d35 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 5ff991bb4..a93206767 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 4df656088..67a593362 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 0e6fd0c68..e84e157f2 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())