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 7cb54f5e5770626fe6a99808a436098c3133c075 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 22 Jul 2026 10:19:25 +0100 Subject: [PATCH 2/2] feat(engine): keep a heading with the first slice of a table or list below it keepWithNext() relocated a stranded heading only when the block below it started with a paragraph. LayoutCompiler.leadingUnitHeight returned a true first-line lead for a paragraph but the whole outer height for every other leaf, so a heading above a page-spanning table or list computed needed = heading + wholeBodyHeight > page, the guard needed <= activeInnerHeight() failed, and the heading stayed put. Add a NodeDefinition.firstSliceHeight(prepared) seam and route leadingUnitHeight's leaf branch through it, so each leaf's split granularity lives in its own definition instead of a type switch in the compiler: - default returns the whole measured content height, keeping an atomic leaf (image, chart, shape) whole; - the paragraph returns its first visual line (the instanceof PreparedParagraphLayout branch moves out of LayoutCompiler); - the table returns its repeated header rows plus first body row, mirroring splitTable's minimal head (rowCount > headerCount); - the list returns its first item. Gated behind keepWithNext(), off by default, so layouts that do not opt in are byte-identical. CHANGELOG and the keep-together recipe now describe the first slice across paragraph, table, and list bodies. --- CHANGELOG.md | 17 ++-- .../document/layout/LayoutCompiler.java | 17 ++-- .../document/layout/NodeDefinition.java | 30 +++++++ .../layout/NodeDefinitionSupport.java | 30 +++++++ .../document/layout/TextFlowSupport.java | 39 ++++++++ .../layout/definitions/ListDefinition.java | 5 ++ .../definitions/ParagraphDefinition.java | 5 ++ .../layout/definitions/TableDefinition.java | 5 ++ docs/recipes/keep-together.md | 20 +++-- .../document/dsl/SectionKeepWithNextTest.java | 88 +++++++++++++++++++ 10 files changed, 232 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13daa51c8..1d6ace4ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,14 +48,17 @@ for this cycle. - **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 + the content it introduces: when the section plus the first slice 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. + relocates to the next page so the heading stays glued to its body. The first slice is + a paragraph's first line, a table's repeated header rows plus first body row, or a + list's first item, so the rule holds whether the following block is atomic or a + page-spanning table or list. Distinct from `keepTogether()`, which relocates a + *whole* block — keep-with-next binds only the start of the following block, 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 the + first slice 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/layout/LayoutCompiler.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index 2b7a1abf8..f50100c50 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,7 +1,6 @@ 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; @@ -1069,13 +1068,15 @@ private double leadingUnitHeight(DocumentNode node, double regionWidth, PrepareC 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(); + // The leading unit of a splittable leaf is its first slice: a paragraph's + // first visual line, a table's repeated header rows plus first body row, a + // list's first item. An indivisible (atomic) leaf has no smaller unit, so + // the default seam returns the whole content height and the block is kept + // whole. Delegating keeps each node type's split granularity in its own + // definition instead of special-casing types here. + @SuppressWarnings("unchecked") + NodeDefinition definition = (NodeDefinition) registry.definitionFor(node); + return topReservation + definition.firstSliceHeight(prepared); } private void addPlacedFragments(List emitted, diff --git a/core/src/main/java/com/demcha/compose/document/layout/NodeDefinition.java b/core/src/main/java/com/demcha/compose/document/layout/NodeDefinition.java index 56ab91470..fdad87c1b 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/NodeDefinition.java +++ b/core/src/main/java/com/demcha/compose/document/layout/NodeDefinition.java @@ -107,6 +107,36 @@ default List emitOverlayFragments(PreparedNode prepared, FragmentPlacement placement) { return List.of(); } + + /** + * Returns the height of the node's first indivisible pagination slice, as a + * hint for the keep-with-next heading lookahead. An overriding splittable + * leaf returns the inner height of its first slice measured from the top of + * its content region — a paragraph its first visual line, a table its + * repeated header rows plus first body row, a list its first item — + * excluding its outer margin and top reservation (margin-top + padding-top), + * which the caller adds. The default returns the whole measured content + * height, so an atomic leaf, which has no smaller unit, is kept whole. + * + *

Consumed only by the opt-in + * {@link com.demcha.compose.document.node.DocumentNode#keepWithNext()} + * lookahead in {@code LayoutCompiler}, so the heading stays with the start + * of its body rather than the whole (possibly page-spanning) block. The + * value gates a page-break decision, never any geometry, so it is + * deliberately approximate: a small over- or under-estimate only shifts a + * cosmetic page break by one line. In particular the default's whole + * content box already includes the leaf's own padding, so the caller's + * added top reservation slightly over-reserves for a padded atomic leaf + * — harmless, since it only makes an already-indivisible block a touch + * more eager to relocate.

+ * + * @param prepared prepared node whose first slice is being measured + * @return the first slice's height in points + * @since 2.0.0 + */ + default double firstSliceHeight(PreparedNode prepared) { + return prepared.measureResult().height(); + } } diff --git a/core/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java b/core/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java index 3629e0cde..c8fafa56e 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java +++ b/core/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java @@ -530,6 +530,36 @@ public static PreparedSplitResult splitTable(PreparedNode return new PreparedSplitResult<>(head, tail); } + /** + * First-slice content height of a prepared table: the repeated header rows + * plus the first body row. This mirrors the smallest head {@link #splitTable} + * will emit — a head must carry more than the repeated header rows + * ({@code rowCount > headerCount}), so its minimum is the header rows plus + * one body row. With no repeated header ({@code repeatedHeaderRowCount == 0}) + * this is just the first row. Backs + * {@link com.demcha.compose.document.layout.definitions.TableDefinition#firstSliceHeight}. + * + * @param prepared prepared table node + * @return the summed height of the repeated header rows and the first body + * row, or the whole content height for a table with no rows + */ + public static double tableFirstSliceHeight(PreparedNode prepared) { + TableNode node = prepared.node(); + TableLayoutSupport.PreparedTableLayout preparedTable = prepared + .requirePreparedLayout(TableLayoutSupport.PreparedTableLayout.class); + List rowHeights = preparedTable.resolvedLayout().rowHeights(); + if (rowHeights.isEmpty()) { + return prepared.measureResult().height(); + } + int headerCount = Math.min(node.repeatedHeaderRowCount(), rowHeights.size()); + int sliceRows = Math.min(headerCount + 1, rowHeights.size()); + double height = 0.0; + for (int i = 0; i < sliceRows; i++) { + height += rowHeights.get(i); + } + return height; + } + /** * Emits row fragments for a prepared table. * diff --git a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java index 28b3ea341..11e2ea2ac 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -115,6 +115,25 @@ public static PreparedSplitResult splitParagraph(PreparedNode(head, tail); } + /** + * First-slice content height of a prepared paragraph: the height of its + * first visual line, which is the smallest unit {@link #splitParagraph} + * places on a page. Backs + * {@link com.demcha.compose.document.layout.definitions.ParagraphDefinition#firstSliceHeight}. + * + * @param prepared prepared paragraph node + * @return the first visual line's height, or the whole content height when + * the paragraph has no visual lines + */ + public static double paragraphFirstSliceHeight(PreparedNode prepared) { + PreparedParagraphLayout layout = prepared.requirePreparedLayout(PreparedParagraphLayout.class); + List lines = layout.visualLines(); + if (lines.isEmpty()) { + return prepared.measureResult().height(); + } + return lines.get(0).lineHeight(); + } + // ------------------------------------------------------------------ // List entry points // ------------------------------------------------------------------ @@ -289,6 +308,26 @@ public static PreparedSplitResult splitList(PreparedNode pre return new PreparedSplitResult<>(head, tail); } + /** + * First-slice content height of a prepared list: the height of its first + * item. Backs + * {@link com.demcha.compose.document.layout.definitions.ListDefinition#firstSliceHeight} + * — the leading unit that anchors a keep-with-next heading to a + * page-spanning list is its first item. + * + * @param prepared prepared list node + * @return the first item's height, or the whole content height for an empty + * list + */ + public static double listFirstSliceHeight(PreparedNode prepared) { + PreparedListLayout layout = prepared.requirePreparedLayout(PreparedListLayout.class); + List items = layout.items(); + if (items.isEmpty()) { + return prepared.measureResult().height(); + } + return items.get(0).paragraphLayout().totalHeight(); + } + /** * Emits one paragraph fragment per list item so items paginate * independently. diff --git a/core/src/main/java/com/demcha/compose/document/layout/definitions/ListDefinition.java b/core/src/main/java/com/demcha/compose/document/layout/definitions/ListDefinition.java index e33738302..6e220393c 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/definitions/ListDefinition.java +++ b/core/src/main/java/com/demcha/compose/document/layout/definitions/ListDefinition.java @@ -49,4 +49,9 @@ public List emitFragments(PreparedNode prepared, FragmentPlacement placement) { return emitListFragments(prepared, placement); } + + @Override + public double firstSliceHeight(PreparedNode prepared) { + return listFirstSliceHeight(prepared); + } } diff --git a/core/src/main/java/com/demcha/compose/document/layout/definitions/ParagraphDefinition.java b/core/src/main/java/com/demcha/compose/document/layout/definitions/ParagraphDefinition.java index c7c1ffc5b..591fdb3bf 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/definitions/ParagraphDefinition.java +++ b/core/src/main/java/com/demcha/compose/document/layout/definitions/ParagraphDefinition.java @@ -48,4 +48,9 @@ public List emitFragments(PreparedNode prepared, FragmentPlacement placement) { return emitParagraphFragments(prepared, placement); } + + @Override + public double firstSliceHeight(PreparedNode prepared) { + return paragraphFirstSliceHeight(prepared); + } } diff --git a/core/src/main/java/com/demcha/compose/document/layout/definitions/TableDefinition.java b/core/src/main/java/com/demcha/compose/document/layout/definitions/TableDefinition.java index 31fcb6bb8..419731ba8 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/definitions/TableDefinition.java +++ b/core/src/main/java/com/demcha/compose/document/layout/definitions/TableDefinition.java @@ -48,4 +48,9 @@ public List emitFragments(PreparedNode prepared, FragmentPlacement placement) { return emitTableFragments(prepared, ctx, placement); } + + @Override + public double firstSliceHeight(PreparedNode prepared) { + return tableFirstSliceHeight(prepared); + } } diff --git a/docs/recipes/keep-together.md b/docs/recipes/keep-together.md index 61ff5a68f..0538e198f 100644 --- a/docs/recipes/keep-together.md +++ b/docs/recipes/keep-together.md @@ -51,7 +51,7 @@ 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 +section **plus the first slice** 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. @@ -67,21 +67,23 @@ document.pageFlow() ``` 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. +the **first slice** of the following block, 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. +The **first slice** is the first line when the following block is a paragraph, the +repeated header rows plus the first body row when it is a table, and the first item +when it is a list — so the heading is kept with the *start* of a page-spanning table +or list, not just prose. When the following block is a truly indivisible unit (an +image, a chart, a horizontal row), the whole unit is that first slice and the heading +is kept with it. 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 +- **Best-effort.** If the heading plus the first slice cannot share a page at all, the heading flows in place rather than jumping to a page it still cannot share with its body. 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 index ea8c07d5d..9d4e946f8 100644 --- a/qa/src/test/java/com/demcha/compose/document/dsl/SectionKeepWithNextTest.java +++ b/qa/src/test/java/com/demcha/compose/document/dsl/SectionKeepWithNextTest.java @@ -72,6 +72,38 @@ void relocatesHeaderBasedOnFirstLineOfPageSpanningBody() { assertThat(page(off, "HeaderMark")).isLessThan(page(off, "BodyMark")); } + /** + * The body's first unit is a page-spanning table (taller than a page, so + * {@code keepTogether()} would be inert). {@code keepWithNext()} still + * relocates the header, because only the table's first slice — its repeated + * header row plus first body row — must join it. Without a real first-slice + * estimate the whole-table height would exceed a page and the header would + * strand, so this is the distinguishing behavior for a splittable table body. + */ + @Test + void relocatesHeaderBasedOnFirstSliceOfPageSpanningTable() { + LayoutGraph on = tableBody(true); + assertThat(page(on, "HeaderMark")).isEqualTo(page(on, "TableMark")); + + LayoutGraph off = tableBody(false); + assertThat(page(off, "HeaderMark")).isLessThan(page(off, "TableMark")); + } + + /** + * The body's first unit is a page-spanning list. {@code keepWithNext()} + * relocates the header to join the list's first item, where the whole-list + * height estimate would otherwise strand it — the list counterpart of the + * table case above. + */ + @Test + void relocatesHeaderBasedOnFirstItemOfPageSpanningList() { + LayoutGraph on = listBody(true); + assertThat(page(on, "HeaderMark")).isEqualTo(page(on, "ListMark")); + + LayoutGraph off = listBody(false); + assertThat(page(off, "HeaderMark")).isLessThan(page(off, "ListMark")); + } + /** * 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 @@ -216,4 +248,60 @@ private static LayoutGraph paragraphBody(boolean keepWithNext) { throw new RuntimeException(e); } } + + /** Header sits flush to the page bottom; the body is a table taller than a page. */ + private static LayoutGraph tableBody(boolean keepWithNext) { + 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.addTable(t -> { + t.name("TableMark").autoColumns(2).header("Column A", "Column B").repeatHeader(); + for (int i = 0; i < 40; i++) { + t.row("Row " + i, "Value " + i); + } + })) + .build(); + return document.layoutGraph(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** Header sits flush to the page bottom; the body is a list taller than a page. */ + private static LayoutGraph listBody(boolean keepWithNext) { + 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.addList(l -> { + l.name("ListMark"); + for (int i = 0; i < 40; i++) { + l.addItem("List item number " + i + " with enough text to measure a real line height"); + } + })) + .build(); + return document.layoutGraph(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } }