diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c77ad02..d9e37d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -279,6 +279,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 deaa9904..78972e64 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 9edcca23..2b7a1abf 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 98ea62cf..8c8943d4 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 5f2751c3..cd879a10 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 3eea1db5..61ff5a68 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 00000000..ea8c07d5 --- /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); + } + } +}