diff --git a/CHANGELOG.md b/CHANGELOG.md index a69fb198e..52244a09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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); + } + } }