feat(engine): keep a heading with the first line of its following block#426
Merged
Conversation
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.
This was referenced Jul 22, 2026
Owner
Author
|
Stacked follow-up: #428 (based on this branch) extends It adds a Merge after this PR, or retarget #428 to |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A section header emitted as its own page-flow 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 (a long experience section) makes it inert and the orphan
returns.
What changed
SectionBuilder.keepWithNext()/keepWithNext(boolean)— opt-in, CSSbreak-after: avoid. A marked section may not be the last placed block on itspage 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, it relocates
so the heading stays with its body. Distinct from
keepTogether()(whole-block),so it works even when the body spans pages.
LayoutCompilerrun-aware lookahead (the vertical child loop) — at the startof a run of consecutive keep-with-next siblings it sums the run plus the next
block's leading line and hoists the page break before the run's first member,
so a multi-part heading (rule + banner + rule) relocates as one unit rather than
leaving a part stranded above its body.
leadingUnitHeightdescends the following block's first-child chain to itsfirst paragraph line, measured at the same width the child is placed at. An
indivisible or non-paragraph-splittable first unit (image, row, table, list) is
kept whole. Best-effort: a heading plus one line that cannot share a page flows
in place; inert when nothing follows.
DocumentNode.keepWithNext()defaultsfalse;SectionNodegains the component with a back-compat 13-arg constructor, soModuleBuilderand the convenience chain are unchanged. Default off, so layoutsthat do not opt in are byte-identical.
Verification
./mvnw -B -ntp -f aggregator/pom.xml clean verify -pl :graph-compose-core,…,:graph-compose-qa,:graph-compose-coverage -am→ BUILD SUCCESS. +6 tests in
SectionKeepWithNextTest: atomic relocate;default-strand baseline (behaviour off unless opted in); first-line paragraph
relocate over a page-spanning body (distinct from
keepTogether, which is inertthere); whole-header-run relocate; inert-when-nothing-follows; best-effort when
heading + one line exceed a page. Existing
SectionKeepTogetherTest,PaginationEdgeCaseTest, and the layout-snapshot regressions are unchanged; thejavadoc gate is clean.
Notes
that starts with text (the common heading-over-prose/entries case). A body whose
first unit is a table or list is currently kept as a whole first unit, so a
heading directly above a page-spanning table is a no-op (not a regression). A
follow-up adds a first-row / first-item lead via a
NodeDefinitionfirst-sliceseam.
(
SectionHeader,FlowSectionHeader) to opt in is a separate follow-up PR.Lane: shared-engine (
LayoutCompilerpagination) + a canonical DSL/nodeaddition (
keepWithNext()onDocumentNode/SectionNode/SectionBuilder).