Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/
public final class SectionBuilder extends AbstractFlowBuilder<SectionBuilder, SectionNode> {
private boolean keepTogether = false;
private boolean keepWithNext = false;

/**
* Creates a section builder.
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -298,8 +299,49 @@ private void compileComposite(PreparedNode<DocumentNode> prepared,
thisChildRegionWidth = Math.max(0.0, (pageRegionWidth - margin.horizontal()) - padding.horizontal());
thisChildRegionX = state.marginLeftForPage(childStartPage) + margin.left() + padding.left();
}
PreparedNode<DocumentNode> 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<DocumentNode> 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,
Expand Down Expand Up @@ -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 &mdash; 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.
*
* <p>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.</p>
*
* @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<DocumentNode> prepared = prepareForRegionWidth(prepareContext, node, regionWidth);
double topReservation = margin.top() + padding.top();

if (prepared.isComposite()) {
CompositeLayoutSpec layoutSpec = prepared.requireCompositeLayout();
@SuppressWarnings("unchecked")
NodeDefinition<DocumentNode> definition = (NodeDefinition<DocumentNode>) registry.definitionFor(node);
List<DocumentNode> 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<LayoutFragment> emitted,
FragmentPlacement placement,
List<PlacedFragment> fragments) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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 <em>whole</em> 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.</p>
*
* <p>"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.</p>
*
* @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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.
Expand All @@ -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<DocumentNode> 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).
*
Expand Down
45 changes: 45 additions & 0 deletions docs/recipes/keep-together.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading