-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathPageBreaker.java
More file actions
383 lines (347 loc) · 16.5 KB
/
PageBreaker.java
File metadata and controls
383 lines (347 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
package com.demcha.compose.engine.pagination;
import com.demcha.compose.engine.core.Canvas;
import com.demcha.compose.engine.components.core.Entity;
import com.demcha.compose.engine.components.geometry.ContentSize;
import com.demcha.compose.engine.components.layout.Layer;
import com.demcha.compose.engine.components.layout.coordinator.ComputedPosition;
import com.demcha.compose.engine.components.layout.coordinator.Placement;
import com.demcha.compose.engine.components.renderable.BlockText;
import com.demcha.compose.engine.core.EntityManager;
import com.demcha.compose.engine.core.LayoutTraversalContext;
import com.demcha.compose.engine.exceptions.BigSizeElementException;
import com.demcha.compose.engine.render.RenderingSystemECS;
import com.demcha.compose.engine.core.SystemECS;
import lombok.Data;
import lombok.NonNull;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* A utility class responsible for the logic of breaking content across pages.
* <p>
* The main responsibilities of this class are:
* <ol>
* <li>building a child-first pagination order from the resolved hierarchy</li>
* <li>Calculating and assigning a page number and in-page coordinates for each entity.</li>
* <li>Handling "breakable" entities, such as {@link BlockText}, whose content can
* flow onto the next page.</li>
* </ol>
* The class operates in a coordinate system where the Y-axis points downwards. It does not create new pages
* but rather calculates and assigns {@link Placement} components to entities.
* <p>
* A critical invariant of this class is that descendants must be processed before their ancestor containers.
* The current implementation enforces that rule with a priority-based topological walk over the resolved
* hierarchy: only nodes whose children are already processed can enter the ready queue, and unrelated nodes
* are then ordered by layout position and depth. This keeps the algorithm renderer-agnostic while avoiding
* repeated ancestor-chain walks inside a comparator.
*/
@Slf4j
@Data
@Accessors(chain = true)
public class PageBreaker {
private static final Logger LIFECYCLE_LOG = LoggerFactory.getLogger("com.demcha.compose.engine.pagination");
private final EntityManager entityManager;
private PageLayoutCalculator pageLayoutCalculator;
private TextBlockProcessor textBlockProcessor;
/**
* Creates a page breaker bound to one entity manager and its pagination
* helpers.
*
* @param entityManager entity registry for the current document
*/
public PageBreaker(EntityManager entityManager) {
this.entityManager = entityManager;
this.pageLayoutCalculator = new PageLayoutCalculator(entityManager);
this.textBlockProcessor = new TextBlockProcessor(entityManager);
}
// === Paging math =========================================================
/**
* Builds a {@link Placement} for the entity using a replacement Y coordinate
* and explicit page span.
*
* @param entity entity being placed
* @param yPosition resolved Y coordinate inside the target page flow
* @param startPage first page touched by the entity
* @param endPage last page touched by the entity
* @return placement preserving the entity's resolved X position and size
*/
private static Placement setYInPlacement(Entity entity, double yPosition, int startPage, int endPage) {
ComputedPosition computedPosition = entity.require(ComputedPosition.class);
var size = entity.require(ContentSize.class);
return new Placement(computedPosition.x(), yPosition, size.width(), size.height(), startPage, endPage);
}
/**
* Convenience overload that converts a precomputed page-position result into a
* {@link Placement}.
*
* @param entity entity being placed
* @param position resolved position/page-span tuple
* @return placement preserving the entity's resolved X position and size
*/
private static Placement setYInPlacement(Entity entity, YPositionOnPage position) {
return setYInPlacement(entity, position.yPosition(), position.startPage(), position.endPage());
}
/**
* Runs page-breaking for the current entity set.
* <p>
* The ordering step guarantees that descendants are processed before ancestors while still preferring
* top-to-bottom layout order between unrelated subtrees.
*/
public void process(@NonNull Map<UUID, Entity> entities, Canvas canvas) {
LayoutTraversalContext traversalContext = LayoutTraversalContext.from(entityManager);
process(entities, canvas, traversalContext, entityManager.getDepthById());
}
/**
* Runs page-breaking with a caller-supplied traversal snapshot and depth map.
*
* <p>
* This overload is used when layout has already materialized the canonical tree
* snapshot for the pass and pagination should reuse that exact hierarchy view.
* </p>
*
* @param entities entity map for the current document
* @param canvas page canvas configuration
* @param traversalContext canonical parent/child snapshot for this pass
* @param depthById depth map from layout traversal
*/
public void process(@NonNull Map<UUID, Entity> entities,
Canvas canvas,
@NonNull LayoutTraversalContext traversalContext,
@NonNull Map<UUID, Integer> depthById) {
long startNanos = System.nanoTime();
LIFECYCLE_LOG.debug("pagination.pageBreaker.start entities={} roots={}", entities.size(), traversalContext.roots().size());
Offset yOffset = new Offset();
List<Map.Entry<UUID, Entity>> orderedEntities = orderedForPagination(
entities,
traversalContext.parentById(),
traversalContext.childrenByParent(),
depthById);
orderedEntities.forEach(e -> {
Entity entity = e.getValue();
if (Breakable.class.isAssignableFrom(entity.getRender().getClass())) {
if (entity.hasAssignable(BlockText.class)) {
definePlacementForBlockText(canvas, entity, yOffset);
} else {
definePlacement(canvas, entity, yOffset, true);
}
} else {
definePlacement(canvas, entity, yOffset, false);
}
});
LIFECYCLE_LOG.debug(
"pagination.pageBreaker.end entities={} ordered={} durationMs={}",
entities.size(),
orderedEntities.size(),
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos));
}
/**
* Builds one pagination order for the whole pass without repeated pairwise ancestry checks.
*
* <p>The queue only admits nodes whose descendants are already processed, which preserves
* the child-before-parent contract. Between unrelated ready nodes we still use layout
* geometry so the order remains visually intuitive and deterministic.</p>
*/
private List<Map.Entry<UUID, Entity>> orderedForPagination(Map<UUID, Entity> entities,
Map<UUID, UUID> parentById,
Map<UUID, List<UUID>> childrenByParent,
Map<UUID, Integer> depthById) {
Map<UUID, Integer> remainingChildren = new LinkedHashMap<>();
PriorityQueue<UUID> ready = new PriorityQueue<>(paginationPriority(entities, depthById));
List<Map.Entry<UUID, Entity>> ordered = new ArrayList<>(entities.size());
for (UUID entityId : entities.keySet()) {
int childCount = 0;
for (UUID childId : childrenByParent.getOrDefault(entityId, List.of())) {
if (entities.containsKey(childId)) {
childCount++;
}
}
remainingChildren.put(entityId, childCount);
if (childCount == 0) {
ready.add(entityId);
}
}
while (!ready.isEmpty()) {
UUID entityId = ready.poll();
Entity entity = entities.get(entityId);
if (entity == null) {
continue;
}
ordered.add(Map.entry(entityId, entity));
UUID parentId = parentById.get(entityId);
if (parentId == null || !remainingChildren.containsKey(parentId)) {
continue;
}
int unresolvedChildren = remainingChildren.merge(parentId, -1, Integer::sum);
if (unresolvedChildren == 0) {
ready.add(parentId);
}
}
if (ordered.size() != entities.size()) {
throw new IllegalStateException("Cycle detected while computing pagination order.");
}
return ordered;
}
/**
* Returns the resolved layout Y-coordinate used for pagination ordering.
* <p>
* We use {@link ComputedPosition} rather than renderer-specific coordinates because pagination operates on
* the layout result, before the final rendering pass writes output streams.
*/
private double positionY(Entity entity) {
return entity.require(ComputedPosition.class).y();
}
/**
* Ready-queue ordering for unrelated nodes in the pagination walk.
*
* <p>The priority keys are chosen so that visually higher entities and deeper
* subtree leaves are processed first:</p>
* <ol>
* <li>Y-position descending — top-of-page entities come first in engine coordinates</li>
* <li>Depth descending — deeper children break ties in favour of leaves</li>
* <li>UUID string — deterministic fallback for bit-identical layouts</li>
* </ol>
*
* @param entities entity map used during this pagination pass
* @param depthById precomputed depth map from the layout traversal
* @return comparator for the {@link java.util.PriorityQueue} used in
* {@link #orderedForPagination}
*/
private Comparator<UUID> paginationPriority(Map<UUID, Entity> entities, Map<UUID, Integer> depthById) {
// Pre-compute Y and depth so PriorityQueue compares stay alloc-free.
// The previous implementation called requireEntity(entities, id) twice
// per compare (once for Y, once for depth) and finally fell back to
// UUID::toString which allocates a 36-char string for every tie-break.
Map<UUID, PaginationKey> keys = new java.util.HashMap<>(entities.size() * 2);
for (UUID id : entities.keySet()) {
Entity entity = entities.get(id);
double y = entity.require(ComputedPosition.class).y();
int depth = depthOf(id, entity, depthById);
keys.put(id, new PaginationKey(y, depth));
}
return (left, right) -> {
PaginationKey leftKey = keys.get(left);
PaginationKey rightKey = keys.get(right);
int yCompare = Double.compare(rightKey.y(), leftKey.y()); // Y descending
if (yCompare != 0) {
return yCompare;
}
int depthCompare = Integer.compare(rightKey.depth(), leftKey.depth()); // depth descending
if (depthCompare != 0) {
return depthCompare;
}
return left.compareTo(right); // UUID.compareTo() is alloc-free (MSB then LSB)
};
}
/**
* Lightweight precomputed sort key for the pagination priority queue.
*/
private record PaginationKey(double y, int depth) {
}
/**
* Retrieves an entity from the pagination map, throwing if absent.
*
* @param entities entity map active during the current pagination pass
* @param entityId id to look up
* @return the entity, never {@code null}
* @throws IllegalStateException if the entity is missing
*/
private Entity requireEntity(Map<UUID, Entity> entities, UUID entityId) {
Entity entity = entities.get(entityId);
if (entity == null) {
throw new IllegalStateException("Entity not found for pagination: " + entityId);
}
return entity;
}
/**
* Returns the layout depth of an entity, falling back to its {@link Layer}
* value when the depth map has no entry (e.g. for dynamically added entities).
*
* @param entityId entity id to look up
* @param entity the resolved entity instance
* @param depthById precomputed depth map from the layout traversal
* @return depth value, zero if neither source has a value
*/
private int depthOf(UUID entityId, Entity entity, Map<UUID, Integer> depthById) {
return depthById.getOrDefault(entityId, entity.getComponent(Layer.class)
.map(Layer::value)
.orElse(0));
}
/**
* Determines and adds a {@link Placement} component to an entity.
* This method updates the entity's vertical position based on the total {@code yOffset},
* calculates its position on the page, and stores the result in a new {@code Placement} component.
* <p>
* For non-breakable entities, the calculator may shift the whole object to the next page. That shift can
* propagate upward into parent container sizing before this method writes the final placement.
*
* @param canvas The canvas configuration.
* @param entity The entity to process.
* @param yOffset The total Y-axis offset accumulated from previous elements.
* @param isBreakable A flag indicating whether the element can be broken across pages.
*/
private void definePlacement(Canvas canvas, Entity entity, Offset yOffset, boolean isBreakable) {
var computedPosition = entity.require(ComputedPosition.class);
entity.require(ContentSize.class);
log.debug("Defining position for {}", entity);
YPositionOnPage position;
try {
position = pageLayoutCalculator.definePositionOnPage(computedPosition.y() + yOffset.y(), entity, 0, canvas, yOffset, isBreakable);
} catch (Exception e) {
log.error("Error while defining position for {}", entity.printInfo(), e);
throw new RuntimeException(entity.printInfo(), e);
}
Placement placement = setYInPlacement(entity, position);
entity.addComponent(placement);
}
/**
* Handles pagination for block-text entities, including line-level splitting
* before the container-level {@link Placement} is written.
*
* @param canvas page canvas configuration
* @param entity block-text entity
* @param yOffset running pagination offset
*/
private void definePlacementForBlockText(Canvas canvas, Entity entity, Offset yOffset) {
try {
//definition a blockText
textBlockProcessor.processPageBreakerBlockText(entity, entityManager, canvas, yOffset);
//definition a placementForBlockTextContainer
definePlacement(canvas, entity, yOffset, true);
} catch (BigSizeElementException | IOException ex) {
log.error("Error while defining position for block text {}", entity.printInfo(), ex);
throw new RuntimeException(String.format("Error while defining position for block text %s, %s", entity.printInfo(), ex));
}
}
/**
* Initiates the page-breaking process for all entities managed by the {@link EntityManager}.
* This method automatically finds the active rendering system ({@link RenderingSystemECS}) to retrieve
* canvas information ({@link Canvas}).
*
*/
public void process() {
log.debug("Breaking pages");
RenderingSystemECS renderingSystemECS = null;
log.debug("Definition a RenderingSystemECS");
for (SystemECS system : entityManager.getSystems().getStream().toList()) {
if (RenderingSystemECS.class.isAssignableFrom(system.getClass())) {
renderingSystemECS = (RenderingSystemECS) system;
break;
}
}
if (renderingSystemECS == null) {
log.error("No RenderingSystemECS found");
throw new IllegalStateException("No RenderingSystemECS found");
}
process(entityManager.getEntities(), renderingSystemECS.canvas());
}
}