Skip to content

feat(engine): keep a heading with the first line of its following block#426

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

feat(engine): keep a heading with the first line of its following block#426
DemchaAV merged 2 commits into
developfrom
feat/keep-with-next

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

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, CSS
    break-after: avoid. A marked 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, it relocates
    so the heading stays with its body. Distinct from keepTogether() (whole-block),
    so it works even when the body spans pages.
  • LayoutCompiler run-aware lookahead (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 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.
  • leadingUnitHeight descends the following block's first-child chain to its
    first 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.
  • Node / DSL surfaceDocumentNode.keepWithNext() defaults false;
    SectionNode gains the component with a back-compat 13-arg constructor, so
    ModuleBuilder and the convenience chain are unchanged. Default off, so layouts
    that 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 inert
there); 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; the
javadoc gate is clean.

Notes

  • Paragraph-first-line vs whole-unit. The lead is a true first line for a body
    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 NodeDefinition first-slice
    seam.
  • Engine-only. Wiring the shared CV / cover-letter header widgets
    (SectionHeader, FlowSectionHeader) to opt in is a separate follow-up PR.

Lane: shared-engine (LayoutCompiler pagination) + a canonical DSL/node
addition (keepWithNext() on DocumentNode / SectionNode / SectionBuilder).

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.
@DemchaAV

Copy link
Copy Markdown
Owner Author

Stacked follow-up: #428 (based on this branch) extends keepWithNext() to relocate a heading above a page-spanning table or list, not just a paragraph.

It adds a NodeDefinition.firstSliceHeight(prepared) seam so a splittable leaf contributes only its first slice — the table its repeated header rows plus first body row, the list its first item — instead of the whole-height estimate this PR uses for non-paragraph leaves. Off-by-default and byte-identical for non-opted-in layouts, same as here.

Merge after this PR, or retarget #428 to develop once this lands.

@DemchaAV
DemchaAV merged commit 13df063 into develop Jul 22, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the feat/keep-with-next branch July 22, 2026 10:23
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