Skip to content

feat(engine): keep a heading with the first slice of a table or list below it#428

Merged
DemchaAV merged 3 commits into
developfrom
feat/keep-with-next-splittable
Jul 22, 2026
Merged

feat(engine): keep a heading with the first slice of a table or list below it#428
DemchaAV merged 3 commits into
developfrom
feat/keep-with-next-splittable

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why

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 — including
SPLITTABLE tables and lists. So a keep-with-next heading directly above a
page-spanning table(...) or list computed needed = heading + wholeBodyHeight,
which exceeds the page; the guard needed <= activeInnerHeight() failed and the
heading stayed put — the exact orphan keepWithNext() exists to prevent, just one
body kind short of working.

What changed

  • New seam NodeDefinition.firstSliceHeight(PreparedNode<E>) (@Beta/@Internal
    SPI). The default returns the whole measured content height, so a truly atomic leaf
    (image, chart, shape) is kept whole — the previous behaviour. leadingUnitHeight's
    leaf branch now delegates to it, so each leaf's split granularity lives in its own
    definition instead of an instanceof switch in the compiler.
  • Paragraph override returns its first visual line — the instanceof PreparedParagraphLayout logic moves out of LayoutCompiler into
    TextFlowSupport.paragraphFirstSliceHeight.
  • Table override (NodeDefinitionSupport.tableFirstSliceHeight) returns its
    repeated header rows plus first body row. This mirrors splitTable's minimal head,
    which requires rowCount > headerCount, so the smallest slice it will place is the
    repeated header rows plus one body row; with no repeated header it is the first row.
  • List override returns its first item (TextFlowSupport.listFirstSliceHeight),
    the same value splitList uses for the first placeable unit.
  • Gated behind keepWithNext() (off by default), and leadingUnitHeight runs only in
    that lookahead — so layouts that do not opt in are byte-identical. 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, and the value
    gates a page break, never geometry.
  • CHANGELOG and docs/recipes/keep-together.md now describe the first slice across
    paragraph, table, and list bodies (previously they said non-paragraph bodies were
    kept whole).

Verification

./mvnw -B -ntp clean verify across core + render-pdf + templates + testing + qa →
BUILD SUCCESS. Core 392 green (incl. PublicApiNoEngineLeakTest,
PublicApiSinceTagCoverageTest); qa 651 green; render-pdf 120 green; javadoc
gate clean.

SectionKeepWithNextTest grows 6 → 8 — two new tests mirroring the existing
paragraph case:

  • relocatesHeaderBasedOnFirstSliceOfPageSpanningTable — header flush at a page bottom
    above a page-spanning table (with a repeated header row); asserts the header
    relocates onto the table's start page when opted in, and strands (header page < table
    page) when not.
  • relocatesHeaderBasedOnFirstItemOfPageSpanningList — the list counterpart.

Both fail without the production change (the whole-height default keeps needed > activeInnerHeight, so no relocation fires).

Lane: shared-engine — layout/pagination seam; no public authoring surface touched.

Merge order: stacked on #426 (base feat/keep-with-next); merge after #426, or
retarget to develop once #426 lands.

DemchaAV added 2 commits July 22, 2026 01:49
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.
…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.
@DemchaAV
DemchaAV changed the base branch from feat/keep-with-next to develop July 22, 2026 09:42
# Conflicts:
#	CHANGELOG.md
#	core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java
#	docs/recipes/keep-together.md
#	qa/src/test/java/com/demcha/compose/document/dsl/SectionKeepWithNextTest.java
@DemchaAV
DemchaAV merged commit 4866bf5 into develop Jul 22, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the feat/keep-with-next-splittable branch July 22, 2026 10:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant