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
17 changes: 10 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,14 +281,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.
- `LineBuilder.keepWithNext()` — the line counterpart of
`SectionBuilder.keepWithNext()`, so a full-width header rule joins its banner's
keep-with-next run and the whole title block (rule + banner + rule) relocates
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<DocumentNode> definition = (NodeDefinition<DocumentNode>) registry.definitionFor(node);
return topReservation + definition.firstSliceHeight(prepared);
}

private void addPlacedFragments(List<LayoutFragment> emitted,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,36 @@ default List<LayoutFragment> emitOverlayFragments(PreparedNode<E> 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 &mdash; a paragraph its first visual line, a table its
* repeated header rows plus first body row, a list its first item &mdash;
* 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.
*
* <p>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
* &mdash; harmless, since it only makes an already-indivisible block a touch
* more eager to relocate.</p>
*
* @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<E> prepared) {
return prepared.measureResult().height();
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,36 @@ public static PreparedSplitResult<TableNode> splitTable(PreparedNode<TableNode>
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 &mdash; 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<TableNode> prepared) {
TableNode node = prepared.node();
TableLayoutSupport.PreparedTableLayout preparedTable = prepared
.requirePreparedLayout(TableLayoutSupport.PreparedTableLayout.class);
List<Double> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ public static PreparedSplitResult<ParagraphNode> splitParagraph(PreparedNode<Par
return new PreparedSplitResult<>(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<ParagraphNode> prepared) {
PreparedParagraphLayout layout = prepared.requirePreparedLayout(PreparedParagraphLayout.class);
List<ParagraphLine> lines = layout.visualLines();
if (lines.isEmpty()) {
return prepared.measureResult().height();
}
return lines.get(0).lineHeight();
}

// ------------------------------------------------------------------
// List entry points
// ------------------------------------------------------------------
Expand Down Expand Up @@ -289,6 +308,26 @@ public static PreparedSplitResult<ListNode> splitList(PreparedNode<ListNode> 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}
* &mdash; 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<ListNode> prepared) {
PreparedListLayout layout = prepared.requirePreparedLayout(PreparedListLayout.class);
List<PreparedListItemLayout> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,9 @@ public List<LayoutFragment> emitFragments(PreparedNode<ListNode> prepared,
FragmentPlacement placement) {
return emitListFragments(prepared, placement);
}

@Override
public double firstSliceHeight(PreparedNode<ListNode> prepared) {
return listFirstSliceHeight(prepared);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,9 @@ public List<LayoutFragment> emitFragments(PreparedNode<ParagraphNode> prepared,
FragmentPlacement placement) {
return emitParagraphFragments(prepared, placement);
}

@Override
public double firstSliceHeight(PreparedNode<ParagraphNode> prepared) {
return paragraphFirstSliceHeight(prepared);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,9 @@ public List<LayoutFragment> emitFragments(PreparedNode<TableNode> prepared,
FragmentPlacement placement) {
return emitTableFragments(prepared, ctx, placement);
}

@Override
public double firstSliceHeight(PreparedNode<TableNode> prepared) {
return tableFirstSliceHeight(prepared);
}
}
20 changes: 11 additions & 9 deletions docs/recipes/keep-together.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
}
Loading