Skip to content

Commit afa50bf

Browse files
committed
feat(engine): keep a heading with the first line of its following block
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.
1 parent 585b84e commit afa50bf

7 files changed

Lines changed: 472 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ for this cycle.
4646

4747
### Public API
4848

49+
- **Keep a heading with its content** — `SectionBuilder.keepWithNext()`. A section
50+
marked keep-with-next is never left stranded as the last block on a page apart from
51+
the content it introduces: when the section plus the first line of the following
52+
block would overflow the remaining page space (but fit on a fresh page), the section
53+
relocates to the next page so the heading stays glued to its body. Distinct from
54+
`keepTogether()`, which relocates a *whole* block — keep-with-next binds only the
55+
first following line, the right tool for a boxed section title above a long,
56+
page-spanning body. Inert when nothing follows (a trailing heading is never moved)
57+
and best-effort when the heading plus one line cannot share a page. Default off, so
58+
layouts that do not opt in are unchanged.
4959
- **Reproducible PDF output** (`@Beta`). `PdfFixedLayoutBackend.builder().deterministic(true)`
5060
(or `.deterministic(Instant)` for an explicit timestamp) pins the document
5161
CreationDate / ModDate and derives the PDF `/ID` from the document metadata instead

‎core/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111
public final class SectionBuilder extends AbstractFlowBuilder<SectionBuilder, SectionNode> {
1212
private boolean keepTogether = false;
13+
private boolean keepWithNext = false;
1314

1415
/**
1516
* Creates a section builder.
@@ -48,10 +49,41 @@ public SectionBuilder keepTogether(boolean value) {
4849
return this;
4950
}
5051

52+
/**
53+
* Keeps this section with the block that follows it: when the section plus the
54+
* first line of the next block would not fit in the remaining page space (but
55+
* fit on a fresh page), the section relocates to the next page rather than
56+
* stranding at a page bottom apart from the content it introduces. This is the
57+
* orphaned-heading fix for a boxed section title — it keeps the title with only
58+
* the first line of a long, page-spanning body, unlike {@link #keepTogether()}
59+
* which would try to keep the whole body together. The rule is inert when
60+
* nothing follows the section on the page.
61+
*
62+
* @return this builder
63+
* @since 2.0.0
64+
*/
65+
public SectionBuilder keepWithNext() {
66+
this.keepWithNext = true;
67+
return this;
68+
}
69+
70+
/**
71+
* Sets whether the section stays with the block that follows it.
72+
*
73+
* @param value true to keep the section with the first line of the next block
74+
* @return this builder
75+
* @since 2.0.0
76+
*/
77+
public SectionBuilder keepWithNext(boolean value) {
78+
this.keepWithNext = value;
79+
return this;
80+
}
81+
5182
@Override
5283
protected SectionNode buildNode() {
5384
return new SectionNode(name(), children(), spacing(), padding(), margin(), fillColor(),
54-
stroke(), cornerRadius(), borders(), keepTogether, anchor(), bleed(), bookmarkOptions());
85+
stroke(), cornerRadius(), borders(), keepTogether, anchor(), bleed(), bookmarkOptions(),
86+
keepWithNext);
5587
}
5688

5789
/**

‎core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.demcha.compose.document.layout;
22

33
import com.demcha.compose.document.exceptions.AtomicNodeTooLargeException;
4+
import com.demcha.compose.document.layout.payloads.PreparedParagraphLayout;
45
import com.demcha.compose.document.layout.payloads.PreparedStackLayout;
56
import com.demcha.compose.document.node.DocumentNode;
67
import com.demcha.compose.document.node.LayerStackNode;
@@ -298,8 +299,49 @@ private void compileComposite(PreparedNode<DocumentNode> prepared,
298299
thisChildRegionWidth = Math.max(0.0, (pageRegionWidth - margin.horizontal()) - padding.horizontal());
299300
thisChildRegionX = state.marginLeftForPage(childStartPage) + margin.left() + padding.left();
300301
}
302+
PreparedNode<DocumentNode> childPrepared =
303+
prepareForRegionWidth(prepareContext, child, thisChildRegionWidth);
304+
305+
// Opt-in keep-with-next: at the start of a run of consecutive
306+
// keep-with-next siblings, ensure the whole run plus the first line of the
307+
// block it introduces shares one page. Hoisting the break to before the
308+
// run's first member lets a multi-part heading (rule + banner + rule)
309+
// relocate as a unit, so no part is stranded above its body. Inert when
310+
// the run ends the flow (nothing to introduce) and best-effort when the
311+
// run plus one line cannot fit even on a fresh page — matching
312+
// keepTogether's fallback. Gated on keepWithNext(), so layouts that do not
313+
// opt in are byte-identical.
314+
if (child.keepWithNext()
315+
&& (index == 0 || !children.get(index - 1).keepWithNext())
316+
&& state.usedHeight > EPS) {
317+
int runEnd = index;
318+
while (runEnd < children.size() && children.get(runEnd).keepWithNext()) {
319+
runEnd++;
320+
}
321+
if (runEnd < children.size()) {
322+
double needed = 0.0;
323+
for (int k = index; k < runEnd; k++) {
324+
PreparedNode<DocumentNode> memberPrepared = k == index
325+
? childPrepared
326+
: prepareForRegionWidth(prepareContext, children.get(k), childRegionWidth);
327+
needed += memberPrepared.measureResult().height()
328+
+ toMargin(children.get(k).margin()).vertical()
329+
+ layoutSpec.spacing();
330+
}
331+
needed += leadingUnitHeight(children.get(runEnd), childRegionWidth, prepareContext);
332+
// Relocate only when the run + first line genuinely fits on a fresh
333+
// page (EPS, not CAPACITY_TOLERANCE): a run that would still overflow
334+
// the next page by a hair should stay put rather than strand there and
335+
// waste this page too.
336+
if (needed > state.remainingHeight() + EPS
337+
&& needed <= state.activeInnerHeight() + EPS) {
338+
state.newPage();
339+
}
340+
}
341+
}
342+
301343
compileNode(
302-
prepareForRegionWidth(prepareContext, child, thisChildRegionWidth),
344+
childPrepared,
303345
path,
304346
index,
305347
depth + 1,
@@ -988,6 +1030,54 @@ private double childAvailableWidth(double regionWidth, DocumentNode node) {
9881030
return Math.max(0.0, regionWidth - margin.horizontal());
9891031
}
9901032

1033+
/**
1034+
* Best-effort height a node consumes to place its first flow line: the top
1035+
* reservation (margin-top + padding-top) plus the leading unit of the content
1036+
* below it &mdash; the first child's leading unit for a vertical composite, the
1037+
* first visual line for a paragraph, or the whole outer height for any other
1038+
* leaf or an atomic (row / stack) composite that cannot split.
1039+
*
1040+
* <p>Used only by the opt-in {@link DocumentNode#keepWithNext()} lookahead to
1041+
* decide whether a heading would strand apart from the block it introduces. A
1042+
* small over- or under-estimate only shifts a cosmetic page break by one line;
1043+
* it can never affect placement correctness, since the value gates a
1044+
* {@code newPage()} decision, not any geometry.</p>
1045+
*
1046+
* @param node the following sibling whose first line anchors the heading
1047+
* @param regionWidth the content-region width the sibling is measured at
1048+
* @param prepareContext measurement context
1049+
* @return the height consumed down to the bottom of the node's first flow line
1050+
*/
1051+
private double leadingUnitHeight(DocumentNode node, double regionWidth, PrepareContext prepareContext) {
1052+
Margin margin = toMargin(node.margin());
1053+
Padding padding = toPadding(node.padding());
1054+
PreparedNode<DocumentNode> prepared = prepareForRegionWidth(prepareContext, node, regionWidth);
1055+
double topReservation = margin.top() + padding.top();
1056+
1057+
if (prepared.isComposite()) {
1058+
CompositeLayoutSpec layoutSpec = prepared.requireCompositeLayout();
1059+
@SuppressWarnings("unchecked")
1060+
NodeDefinition<DocumentNode> definition = (NodeDefinition<DocumentNode>) registry.definitionFor(node);
1061+
List<DocumentNode> children = definition.children(node);
1062+
// A horizontal row or layer stack is atomic (never splits) and an empty
1063+
// vertical box has no first line — the whole box is the leading unit.
1064+
if (layoutSpec.axis() != CompositeLayoutSpec.Axis.VERTICAL || children.isEmpty()) {
1065+
return prepared.measureResult().height() + margin.vertical();
1066+
}
1067+
double availableWidth = childAvailableWidth(regionWidth, node);
1068+
double innerRegionWidth = Math.max(0.0, availableWidth - padding.horizontal());
1069+
return topReservation + leadingUnitHeight(children.get(0), innerRegionWidth, prepareContext);
1070+
}
1071+
1072+
// A paragraph is anchored by its first visual line; every other leaf (image,
1073+
// shape, chart, non-paragraph splittable) moves as a whole indivisible unit.
1074+
if (prepared.preparedLayout() instanceof PreparedParagraphLayout paragraph
1075+
&& !paragraph.visualLines().isEmpty()) {
1076+
return topReservation + paragraph.visualLines().get(0).lineHeight();
1077+
}
1078+
return prepared.measureResult().height() + margin.vertical();
1079+
}
1080+
9911081
private void addPlacedFragments(List<LayoutFragment> emitted,
9921082
FragmentPlacement placement,
9931083
List<PlacedFragment> fragments) {

‎core/src/main/java/com/demcha/compose/document/node/DocumentNode.java‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,39 @@ default boolean keepTogether() {
7272
return false;
7373
}
7474

75+
/**
76+
* Whether this node must stay with the block that follows it — it may not be
77+
* left as the last placed block on its page when a subsequent sibling with
78+
* content exists. When the node plus the first line of the following content
79+
* would not fit in the remaining page space (but do fit on a fresh page), the
80+
* compiler relocates the node to the next page so it stays glued to what it
81+
* introduces (CSS {@code break-after: avoid} semantics). This is the
82+
* orphaned-heading fix: a boxed section title never strands at a page bottom
83+
* apart from its body.
84+
*
85+
* <p>Default {@code false} (normal flow). The rule is inert when nothing
86+
* follows the node on the page (a trailing heading is not relocated), and
87+
* best-effort: if the node plus one following line cannot fit even on a fresh
88+
* page, the node flows in place. Unlike {@link #keepTogether()}, which keeps
89+
* the <em>whole</em> block together, this keeps the node with only the first
90+
* line of the next block — the right tool for a heading above a long,
91+
* page-spanning body.</p>
92+
*
93+
* <p>"First line" applies to a following block whose first flow unit is a line
94+
* of text (the common case — a heading above prose or list entries). When the
95+
* following block's first unit is indivisible (an image, a shape, a row) or a
96+
* non-text splittable (a table or list), the node is instead kept with that
97+
* whole first block, so a heading above a body taller than a page that starts
98+
* with such a unit is left in place. Runs of consecutive keep-with-next
99+
* siblings relocate together, with the break hoisted before the run.</p>
100+
*
101+
* @return true to keep this node with the first line of the following block
102+
* @since 2.0.0
103+
*/
104+
default boolean keepWithNext() {
105+
return false;
106+
}
107+
75108
/**
76109
* Edges on which this node bleeds past the page content margin to the
77110
* trimmed physical page edge. Default {@link DocumentBleed#none()} (normal

‎core/src/main/java/com/demcha/compose/document/node/SectionNode.java‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
* or {@link DocumentBleed#none()} for normal in-margin placement
2727
* @param bookmarkOptions optional PDF outline entry placed at the section's top on
2828
* its start page, or {@code null} for none
29+
* @param keepWithNext when {@code true}, the section stays with the block that
30+
* follows it — it is relocated to the next page rather than
31+
* stranded at a page bottom apart from the first line of the
32+
* following content (see {@link DocumentNode#keepWithNext()})
2933
* @author Artem Demchyshyn
3034
*/
3135
public record SectionNode(
@@ -41,7 +45,8 @@ public record SectionNode(
4145
boolean keepTogether,
4246
String anchor,
4347
DocumentBleed bleed,
44-
DocumentBookmarkOptions bookmarkOptions
48+
DocumentBookmarkOptions bookmarkOptions,
49+
boolean keepWithNext
4550
) implements DocumentNode {
4651
/**
4752
* Normalizes optional section fields and validates child spacing.
@@ -61,6 +66,41 @@ public record SectionNode(
6166
}
6267
}
6368

69+
/**
70+
* Backward-compatible constructor without the keep-with-next flag (defaults to
71+
* normal flow).
72+
*
73+
* @param name node name
74+
* @param children child nodes
75+
* @param spacing vertical spacing
76+
* @param padding inner padding
77+
* @param margin outer margin
78+
* @param fillColor optional background fill
79+
* @param stroke optional uniform border stroke
80+
* @param cornerRadius optional render-only corner radius
81+
* @param borders optional per-side borders
82+
* @param keepTogether keep-together relocation flag
83+
* @param anchor optional navigation anchor name
84+
* @param bleed optional bleed declaration
85+
* @param bookmarkOptions optional PDF outline entry, or {@code null} for none
86+
*/
87+
public SectionNode(String name,
88+
List<DocumentNode> children,
89+
double spacing,
90+
DocumentInsets padding,
91+
DocumentInsets margin,
92+
DocumentColor fillColor,
93+
DocumentStroke stroke,
94+
DocumentCornerRadius cornerRadius,
95+
DocumentBorders borders,
96+
boolean keepTogether,
97+
String anchor,
98+
DocumentBleed bleed,
99+
DocumentBookmarkOptions bookmarkOptions) {
100+
this(name, children, spacing, padding, margin, fillColor, stroke, cornerRadius, borders, keepTogether,
101+
anchor, bleed, bookmarkOptions, false);
102+
}
103+
64104
/**
65105
* Backward-compatible constructor without a bookmark (defaults to none).
66106
*

‎docs/recipes/keep-together.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,48 @@ Two boundaries to know:
4141
`Row`, `LayerStackNode`, `ShapeContainerNode`, and `CanvasLayerNode` are
4242
already atomic by design and never split — `keepTogether()` exists for the
4343
*composites* (sections, modules, timelines) that flow by default.
44+
45+
## Keep a heading with its content: `keepWithNext()`
46+
47+
`keepTogether()` keeps a *whole* block on one page — wrong for a section
48+
heading above a long, page-spanning body: the body never fits, so the request
49+
is ignored and the heading can still strand at a page bottom, apart from what
50+
it introduces (a boxed title torn from its background is the visible symptom).
51+
52+
`keepWithNext()` is the tool for that case. A section marked keep-with-next is
53+
never left as the last block on a page when a sibling follows it: if the
54+
section **plus the first line** of the following block would overflow the
55+
remaining space (but fit on a fresh page), the section relocates to the next
56+
page so the heading stays glued to its body.
57+
58+
```java
59+
document.pageFlow()
60+
.addSection("ExperienceTitle", s -> s
61+
.keepWithNext() // title follows its body down
62+
.softPanel(DocumentColor.rgb(238, 240, 242), 4, 10)
63+
.addParagraph(p -> p.text("PROFESSIONAL EXPERIENCE")))
64+
.addSection("ExperienceBody", s -> s // a long, page-spanning list
65+
.addParagraph(/* … many entries … */))
66+
.build();
67+
```
68+
69+
The difference from `keepTogether()`: keep-with-next binds the heading to only
70+
the **first following line**, not the whole body — so it works even when the
71+
body spans several pages.
72+
73+
"First line" means the first line of text when the following block starts with
74+
prose or list entries (the usual heading-over-body case). When it instead starts
75+
with an indivisible unit (an image, a row) or a table/list, the heading is kept
76+
with that whole first block. Consecutive keep-with-next sections relocate as one
77+
run, so a multi-part heading (rule + banner + rule) moves together.
78+
79+
Two boundaries, mirroring `keepTogether()`:
80+
81+
- **Inert when nothing follows.** A trailing heading (no following sibling, or
82+
nothing that places a line on the page) is never relocated — there is no
83+
orphan to avoid.
84+
- **Best-effort.** If the heading plus one following line cannot share a page
85+
at all, the heading flows in place rather than jumping to a page it still
86+
cannot share with its body.
87+
88+
Default off — sections without the opt-in flow exactly as before.

0 commit comments

Comments
 (0)