Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ Open cycle — bug-fix / housekeeping. Entries land here as they merge.
the existing auto-size integration and snapshot tests). No public API or behaviour
change.

- **Table pagination stops re-copying the tail on every page split.** A table that
spans many pages is split page-by-page, and each split re-sliced the shrinking
tail by `List.copyOf`-ing its row and row-height lists — even though the source
layout already holds those lists immutably, so the copy made continuation
O(rows × pages). The body-only slice now reuses the immutable sub-list views
directly. **Output is byte-identical** — same rows in the same order (all table
layout, pagination, and visual-regression tests pass unchanged); a deterministic
allocation probe on a 2,500-row / 68-page table shows warm compile allocation
drop 11,155 KB → 9,851 KB (−11.7%). No public API or behaviour change.

### Deprecations

- **`Font.adjustFontSizeToFit(...)` is deprecated.** The engine-internal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,17 @@ static PreparedNode<TableNode> sliceTablePreparedNode(TableNode source,
combinedSource.addAll(bodySource);
fragmentSourceRows = combinedSource;
} else {
rows = List.copyOf(bodyRows);
rowHeights = List.copyOf(bodyHeights);
// bodyRows / bodyHeights are sub-list views of the source layout's
// rows() / rowHeights(), which resolveTableLayout already built with
// List.copyOf — so the views are unmodifiable as-is. Re-copying them
// here allocated O(slice) per split, and because a table's tail is
// re-sliced on every continuation page that made pagination
// O(rows x pages). Reuse the views directly: the result is
// byte-identical, and the fragment layout is transient — emit reads
// individual rows into fragments, so nothing long-lived retains the
// view beyond what the copy would have retained anyway.
rows = bodyRows;
rowHeights = bodyHeights;
fragmentSourceRows = bodySource;
}
double totalHeight = rowHeights.stream().mapToDouble(Double::doubleValue).sum();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.demcha.compose.document.api;

import static org.assertj.core.api.Assertions.assertThat;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.layout.LayoutGraph;
import com.demcha.compose.document.layout.PlacedFragment;
import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload;
import java.util.List;
import org.junit.jupiter.api.Test;

/**
* Guards the table-pagination slice invariant behind Finding 10. When a table
* spans many pages the engine re-slices the shrinking tail page-by-page — a
* sub-list of a sub-list of … the resolved layout. The body-only slice reuses
* those immutable sub-list views instead of re-copying them, so this test pins
* the contract that survives the optimization: every body row still lands on
* exactly one page, in order, no matter how many times the tail is re-sliced.
*/
class TableSlicePaginationTest {

@Test
void tableSpanningManyPagesPlacesEveryRowOnExactlyOnePage() {
int rowCount = 120;
try (DocumentSession session = GraphCompose.document()
.pageSize(DocumentPageSize.A4)
.margin(24, 24, 24, 24)
.create()) {
session.pageFlow(flow -> flow.addTable(table -> {
table.autoColumns(3);
for (int i = 0; i < rowCount; i++) {
table.row("Row " + i, "Col B " + i, "Col C " + i);
}
}));

LayoutGraph graph = session.layoutGraph();
assertThat(graph.totalPages())
.as("120 rows at A4 should span several pages")
.isGreaterThanOrEqualTo(3);

List<PlacedFragment> rowFragments = graph.fragments().stream()
.filter(fragment -> fragment.payload() instanceof TableRowFragmentPayload)
.toList();

// Exactly one fragment per body row — the chained tail re-slices
// neither drop nor duplicate a row.
assertThat(rowFragments).hasSize(rowCount);

// …and the rows are spread across every page, not collapsed onto one.
assertThat(rowFragments.stream().map(PlacedFragment::pageIndex).distinct().count())
.isEqualTo(graph.totalPages());
}
}
}