From 13386ef7ee761d0724b3841df38cdb96c466e9b9 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 11 Jul 2026 02:14:57 +0100 Subject: [PATCH] refactor(engine): extract the splittable-leaf placement into its own compiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compileSplittableLeaf held the trickiest pagination code — the loop that splits a leaf across pages through NodeDefinition.split, emitting each piece and advancing the page cursor — inline in LayoutCompiler. Move it verbatim to a package-private SplittableLeafCompiler.compile, trimming LayoutCompiler by ~165 lines and giving the split loop a home and a direct test surface. Byte-identical: the body is unchanged except addPlacedFragments(...) is inlined to fragments.addAll(CompositeDecoration.toPlacedFragments(...)) (its exact definition) so the collaborator needs nothing private from LayoutCompiler. New SplittableLeafCompilerTest drives the whole-fit, no-progress-guard, and too-large branches in isolation with a stub NodeDefinition. --- .../document/layout/LayoutCompiler.java | 167 +------------ .../layout/SplittableLeafCompiler.java | 220 ++++++++++++++++++ .../layout/SplittableLeafCompilerTest.java | 90 +++++++ 3 files changed, 311 insertions(+), 166 deletions(-) create mode 100644 src/main/java/com/demcha/compose/document/layout/SplittableLeafCompiler.java create mode 100644 src/test/java/com/demcha/compose/document/layout/SplittableLeafCompilerTest.java diff --git a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index 2d299ec6..11dff7d8 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -170,7 +170,7 @@ private void compileNode(PreparedNode prepared, } if (definition.paginationPolicy(node) == PaginationPolicy.SPLITTABLE) { - compileSplittableLeaf(prepared, definition, path, semanticName, parentPath, childIndex, depth, regionX, + SplittableLeafCompiler.compile(prepared, definition, path, semanticName, parentPath, childIndex, depth, regionX, availableWidth, state, prepareContext, fragmentContext, nodes, fragments); return; } @@ -859,171 +859,6 @@ private void placeStackLayer(DocumentNode child, layerCtx); } - private void compileSplittableLeaf(PreparedNode prepared, - NodeDefinition definition, - String path, - String semanticName, - String parentPath, - int childIndex, - int depth, - double regionX, - double availableWidth, - CompilerState state, - PrepareContext prepareContext, - FragmentContext fragmentContext, - List nodes, - List fragments) { - Margin originalMargin = toMargin(prepared.node().margin()); - Padding originalPadding = toPadding(prepared.node().padding()); - - PreparedNode current = prepared; - double firstPlacementX = Double.NaN; - double firstPlacementY = Double.NaN; - int startPage = -1; - int endPage = -1; - - while (current != null) { - DocumentNode currentNode = current.node(); - Margin currentMargin = toMargin(currentNode.margin()); - Padding currentPadding = toPadding(currentNode.padding()); - MeasureResult pieceMeasure = current.measureResult(); - double pieceOuterHeight = pieceMeasure.height() + currentMargin.vertical(); - double fullPageOuterHeight = state.activeInnerHeight(); - - if (pieceOuterHeight <= state.remainingHeight() + EPS) { - state.touchPage(); - if (startPage < 0) { - startPage = state.pageIndex; - } - double placementX = regionX + currentMargin.left(); - double placementY = state.pageTop() - state.usedHeight - currentMargin.top() - pieceMeasure.height(); - FragmentPlacement placement = new FragmentPlacement( - path, - parentPath, - childIndex, - depth, - state.pageIndex, - placementX, - placementY, - pieceMeasure.width(), - pieceMeasure.height(), - startPage, - state.pageIndex, - currentMargin, - currentPadding); - addPlacedFragments(definition.emitFragments(current, fragmentContext, placement), placement, fragments); - - if (Double.isNaN(firstPlacementX)) { - firstPlacementX = placementX; - firstPlacementY = placementY; - } - endPage = state.pageIndex; - state.usedHeight += pieceOuterHeight; - current = null; - continue; - } - - double remainingBoxHeight = Math.max(0.0, state.remainingHeight() - currentMargin.vertical()); - if (remainingBoxHeight <= EPS && state.usedHeight > EPS) { - state.newPage(); - continue; - } - - SplitRequest splitRequest = new SplitRequest( - new BoxConstraints(availableWidth, remainingBoxHeight), - remainingBoxHeight, - Math.max(0.0, fullPageOuterHeight - currentMargin.vertical()), - prepareContext); - PreparedSplitResult splitResult = definition.split(current, splitRequest); - PreparedNode head = splitResult.head(); - PreparedNode tail = splitResult.tail(); - - if (head == null) { - if (state.usedHeight > EPS) { - state.newPage(); - continue; - } - throw AtomicNodeTooLargeException.forNode(path, pieceOuterHeight, fullPageOuterHeight); - } - if (tail != null && tail.equals(current)) { - throw new IllegalStateException("Split did not make progress for node '" + path - + "'. The node's NodeDefinition.split() returned the original input as the tail — " - + "check the definition for an infinite split loop and ensure each split advances."); - } - - DocumentNode headNode = head.node(); - Margin headMargin = toMargin(headNode.margin()); - Padding headPadding = toPadding(headNode.padding()); - MeasureResult headMeasure = head.measureResult(); - double headOuterHeight = headMeasure.height() + headMargin.vertical(); - - if (headOuterHeight > state.remainingHeight() + EPS) { - if (state.usedHeight > EPS) { - state.newPage(); - continue; - } - throw AtomicNodeTooLargeException.forNode(path, headOuterHeight, fullPageOuterHeight); - } - - state.touchPage(); - if (startPage < 0) { - startPage = state.pageIndex; - } - - double placementX = regionX + headMargin.left(); - double placementY = state.pageTop() - state.usedHeight - headMargin.top() - headMeasure.height(); - FragmentPlacement placement = new FragmentPlacement( - path, - parentPath, - childIndex, - depth, - state.pageIndex, - placementX, - placementY, - headMeasure.width(), - headMeasure.height(), - startPage, - state.pageIndex, - headMargin, - headPadding); - addPlacedFragments(definition.emitFragments(head, fragmentContext, placement), placement, fragments); - - if (Double.isNaN(firstPlacementX)) { - firstPlacementX = placementX; - firstPlacementY = placementY; - } - endPage = state.pageIndex; - state.usedHeight += headOuterHeight; - - current = tail; - if (current != null) { - state.newPage(); - } - } - - MeasureResult originalMeasure = prepared.measureResult(); - nodes.add(new PlacedNode( - path, - semanticName, - prepared.node().nodeKind(), - parentPath, - childIndex, - depth, - depth, - firstPlacementX, - firstPlacementY, - firstPlacementX, - firstPlacementY, - originalMeasure.width(), - originalMeasure.height(), - startPage, - endPage, - originalMeasure.width(), - originalMeasure.height(), - originalMargin, - originalPadding)); - } - /** * Compiles a composite or leaf node inside a fixed slot. * diff --git a/src/main/java/com/demcha/compose/document/layout/SplittableLeafCompiler.java b/src/main/java/com/demcha/compose/document/layout/SplittableLeafCompiler.java new file mode 100644 index 00000000..7f7e8db8 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/layout/SplittableLeafCompiler.java @@ -0,0 +1,220 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.document.exceptions.AtomicNodeTooLargeException; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.engine.components.style.Margin; +import com.demcha.compose.engine.components.style.Padding; + +import java.util.List; + +import static com.demcha.compose.document.layout.DocumentNodeAdapters.toMargin; +import static com.demcha.compose.document.layout.DocumentNodeAdapters.toPadding; + +/** + * Places a {@link com.demcha.compose.document.node.PaginationPolicy#SPLITTABLE} + * leaf across pages. The leaf is measured, and while it does not fit in the + * remaining page height it is split through its {@code NodeDefinition.split}; + * each piece is emitted on the page it lands on, advancing the page cursor as + * needed, until the tail is exhausted. A single {@link PlacedNode} spanning the + * first to the last touched page is recorded for the whole leaf. + * + *

The compiler cursor ({@link CompilerState}) is mutated as pages advance; + * the caller owns the {@code nodes} / {@code fragments} lists this appends to. + * Package-private — engine surface, not public API.

+ * + * @author Artem Demchyshyn + */ +final class SplittableLeafCompiler { + + private static final double EPS = 1e-6; + + private SplittableLeafCompiler() { + // Utility class, no instantiation. + } + + /** + * Compiles a splittable leaf, paginating it across as many pages as needed. + * + * @param prepared the prepared leaf (measured at its region width) + * @param definition the leaf's node definition (supplies split + emit) + * @param path the leaf's layout path + * @param semanticName the leaf's semantic name + * @param parentPath the parent's layout path + * @param childIndex the leaf's index among its siblings + * @param depth the leaf's tree depth + * @param regionX the content region's left edge + * @param availableWidth the content width available to the leaf + * @param state the compiler cursor (mutated as pages advance) + * @param prepareContext the node preparation context (drives re-measurement of tails) + * @param fragmentContext the fragment emission context + * @param nodes accumulator for placed nodes (appended to) + * @param fragments accumulator for placed fragments (appended to) + * @throws AtomicNodeTooLargeException if a piece cannot fit on an empty page + */ + static void compile(PreparedNode prepared, + NodeDefinition definition, + String path, + String semanticName, + String parentPath, + int childIndex, + int depth, + double regionX, + double availableWidth, + CompilerState state, + PrepareContext prepareContext, + FragmentContext fragmentContext, + List nodes, + List fragments) { + Margin originalMargin = toMargin(prepared.node().margin()); + Padding originalPadding = toPadding(prepared.node().padding()); + + PreparedNode current = prepared; + double firstPlacementX = Double.NaN; + double firstPlacementY = Double.NaN; + int startPage = -1; + int endPage = -1; + + while (current != null) { + DocumentNode currentNode = current.node(); + Margin currentMargin = toMargin(currentNode.margin()); + Padding currentPadding = toPadding(currentNode.padding()); + MeasureResult pieceMeasure = current.measureResult(); + double pieceOuterHeight = pieceMeasure.height() + currentMargin.vertical(); + double fullPageOuterHeight = state.activeInnerHeight(); + + if (pieceOuterHeight <= state.remainingHeight() + EPS) { + state.touchPage(); + if (startPage < 0) { + startPage = state.pageIndex; + } + double placementX = regionX + currentMargin.left(); + double placementY = state.pageTop() - state.usedHeight - currentMargin.top() - pieceMeasure.height(); + FragmentPlacement placement = new FragmentPlacement( + path, + parentPath, + childIndex, + depth, + state.pageIndex, + placementX, + placementY, + pieceMeasure.width(), + pieceMeasure.height(), + startPage, + state.pageIndex, + currentMargin, + currentPadding); + fragments.addAll(CompositeDecoration.toPlacedFragments( + definition.emitFragments(current, fragmentContext, placement), placement)); + + if (Double.isNaN(firstPlacementX)) { + firstPlacementX = placementX; + firstPlacementY = placementY; + } + endPage = state.pageIndex; + state.usedHeight += pieceOuterHeight; + current = null; + continue; + } + + double remainingBoxHeight = Math.max(0.0, state.remainingHeight() - currentMargin.vertical()); + if (remainingBoxHeight <= EPS && state.usedHeight > EPS) { + state.newPage(); + continue; + } + + SplitRequest splitRequest = new SplitRequest( + new BoxConstraints(availableWidth, remainingBoxHeight), + remainingBoxHeight, + Math.max(0.0, fullPageOuterHeight - currentMargin.vertical()), + prepareContext); + PreparedSplitResult splitResult = definition.split(current, splitRequest); + PreparedNode head = splitResult.head(); + PreparedNode tail = splitResult.tail(); + + if (head == null) { + if (state.usedHeight > EPS) { + state.newPage(); + continue; + } + throw AtomicNodeTooLargeException.forNode(path, pieceOuterHeight, fullPageOuterHeight); + } + if (tail != null && tail.equals(current)) { + throw new IllegalStateException("Split did not make progress for node '" + path + + "'. The node's NodeDefinition.split() returned the original input as the tail — " + + "check the definition for an infinite split loop and ensure each split advances."); + } + + DocumentNode headNode = head.node(); + Margin headMargin = toMargin(headNode.margin()); + Padding headPadding = toPadding(headNode.padding()); + MeasureResult headMeasure = head.measureResult(); + double headOuterHeight = headMeasure.height() + headMargin.vertical(); + + if (headOuterHeight > state.remainingHeight() + EPS) { + if (state.usedHeight > EPS) { + state.newPage(); + continue; + } + throw AtomicNodeTooLargeException.forNode(path, headOuterHeight, fullPageOuterHeight); + } + + state.touchPage(); + if (startPage < 0) { + startPage = state.pageIndex; + } + + double placementX = regionX + headMargin.left(); + double placementY = state.pageTop() - state.usedHeight - headMargin.top() - headMeasure.height(); + FragmentPlacement placement = new FragmentPlacement( + path, + parentPath, + childIndex, + depth, + state.pageIndex, + placementX, + placementY, + headMeasure.width(), + headMeasure.height(), + startPage, + state.pageIndex, + headMargin, + headPadding); + fragments.addAll(CompositeDecoration.toPlacedFragments( + definition.emitFragments(head, fragmentContext, placement), placement)); + + if (Double.isNaN(firstPlacementX)) { + firstPlacementX = placementX; + firstPlacementY = placementY; + } + endPage = state.pageIndex; + state.usedHeight += headOuterHeight; + + current = tail; + if (current != null) { + state.newPage(); + } + } + + MeasureResult originalMeasure = prepared.measureResult(); + nodes.add(new PlacedNode( + path, + semanticName, + prepared.node().nodeKind(), + parentPath, + childIndex, + depth, + depth, + firstPlacementX, + firstPlacementY, + firstPlacementX, + firstPlacementY, + originalMeasure.width(), + originalMeasure.height(), + startPage, + endPage, + originalMeasure.width(), + originalMeasure.height(), + originalMargin, + originalPadding)); + } +} diff --git a/src/test/java/com/demcha/compose/document/layout/SplittableLeafCompilerTest.java b/src/test/java/com/demcha/compose/document/layout/SplittableLeafCompilerTest.java new file mode 100644 index 00000000..8612208c --- /dev/null +++ b/src/test/java/com/demcha/compose/document/layout/SplittableLeafCompilerTest.java @@ -0,0 +1,90 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.document.exceptions.AtomicNodeTooLargeException; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.SpacerNode; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.engine.components.style.Margin; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Engine-level contract for {@link SplittableLeafCompiler}, extracted from + * {@link LayoutCompiler}. Whole-document pagination is guarded end-to-end by the + * qa parity / snapshot suites; these pin the split loop's decisions — a piece + * that fits whole, the no-progress guard, and the too-large throw — in isolation + * with a stub {@link NodeDefinition} so the branches are exercised directly. + */ +class SplittableLeafCompilerTest { + + // 200×100 canvas, 10pt margins → ~80pt of inner content height per page. + private final CompilerState state = new CompilerState(LayoutCanvas.from(200, 100, Margin.of(10))); + + @SuppressWarnings("unchecked") + private final NodeDefinition definition = mock(NodeDefinition.class); + private final PrepareContext prepareContext = mock(PrepareContext.class); + private final FragmentContext fragmentContext = mock(FragmentContext.class); + private final List nodes = new ArrayList<>(); + private final List fragments = new ArrayList<>(); + + private static PreparedNode leaf(double height) { + DocumentNode node = new SpacerNode("leaf", 100, height, DocumentInsets.zero(), DocumentInsets.zero()); + return PreparedNode.leaf(node, new MeasureResult(100, height)); + } + + private void compile(PreparedNode prepared) { + SplittableLeafCompiler.compile(prepared, definition, "leaf", "leaf", null, 0, 1, + 10, 180, state, prepareContext, fragmentContext, nodes, fragments); + } + + @Test + void aPieceThatFitsWholeIsPlacedOnASinglePage() { + when(definition.emitFragments(any(), any(), any())) + .thenReturn(List.of(new LayoutFragment("leaf", 0, 0.0, 0.0, 100, 10, "band"))); + + compile(leaf(10)); // 10pt fits in the ~80pt page. + + assertThat(nodes).singleElement().satisfies(n -> { + assertThat(n.startPage()).isZero(); + assertThat(n.endPage()).isZero(); + }); + assertThat(fragments).hasSize(1); + assertThat(state.usedHeight).isGreaterThan(0.0); + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) // a regressed no-progress guard loops forever; fail fast instead. + void aSplitThatReturnsTheOriginalAsItsTailIsRejectedAsNoProgress() { + // The piece cannot fit whole, so it is split; the definition returns the + // input as the tail — an infinite split loop the compiler must reject. + PreparedNode tooTall = leaf(10_000); + when(definition.split(any(), any())) + .thenReturn(new PreparedSplitResult<>(leaf(20), tooTall)); + + assertThatThrownBy(() -> compile(tooTall)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("did not make progress"); + } + + @Test + void aPieceThatCannotBeSplitOnAnEmptyPageThrowsTooLarge() { + // Doesn't fit whole and the definition can't split it (head == null) on a + // fresh page — it can never be placed, so the too-large error is raised. + when(definition.split(any(), any())) + .thenReturn(new PreparedSplitResult<>(null, null)); + + assertThatThrownBy(() -> compile(leaf(10_000))) + .isInstanceOf(AtomicNodeTooLargeException.class) + .hasMessageContaining("page capacity"); + } +}