postext 0.3.29 → 0.3.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/__tests__/exports.test.js +2 -0
  2. package/dist/__tests__/exports.test.js.map +1 -1
  3. package/dist/__tests__/pipeline/calloutOverflow.test.d.ts +2 -0
  4. package/dist/__tests__/pipeline/calloutOverflow.test.d.ts.map +1 -0
  5. package/dist/__tests__/pipeline/calloutOverflow.test.js +189 -0
  6. package/dist/__tests__/pipeline/calloutOverflow.test.js.map +1 -0
  7. package/dist/__tests__/pipeline/paragraphStyles.test.js +19 -0
  8. package/dist/__tests__/pipeline/paragraphStyles.test.js.map +1 -1
  9. package/dist/__tests__/pipeline/raggedLines.test.d.ts +2 -0
  10. package/dist/__tests__/pipeline/raggedLines.test.d.ts.map +1 -0
  11. package/dist/__tests__/pipeline/raggedLines.test.js +26 -0
  12. package/dist/__tests__/pipeline/raggedLines.test.js.map +1 -0
  13. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.d.ts +2 -0
  14. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.d.ts.map +1 -0
  15. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.js +96 -0
  16. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.js.map +1 -0
  17. package/dist/__tests__/pipeline/spanFloatAtCut.test.d.ts +2 -0
  18. package/dist/__tests__/pipeline/spanFloatAtCut.test.d.ts.map +1 -0
  19. package/dist/__tests__/pipeline/spanFloatAtCut.test.js +159 -0
  20. package/dist/__tests__/pipeline/spanFloatAtCut.test.js.map +1 -0
  21. package/dist/__tests__/pipeline/tableCellImages.test.d.ts +2 -0
  22. package/dist/__tests__/pipeline/tableCellImages.test.d.ts.map +1 -0
  23. package/dist/__tests__/pipeline/tableCellImages.test.js +166 -0
  24. package/dist/__tests__/pipeline/tableCellImages.test.js.map +1 -0
  25. package/dist/__tests__/pipeline/tableCellLists.test.d.ts +2 -0
  26. package/dist/__tests__/pipeline/tableCellLists.test.d.ts.map +1 -0
  27. package/dist/__tests__/pipeline/tableCellLists.test.js +91 -0
  28. package/dist/__tests__/pipeline/tableCellLists.test.js.map +1 -0
  29. package/dist/__tests__/pipeline/tableSlice.test.d.ts +2 -0
  30. package/dist/__tests__/pipeline/tableSlice.test.d.ts.map +1 -0
  31. package/dist/__tests__/pipeline/tableSlice.test.js +226 -0
  32. package/dist/__tests__/pipeline/tableSlice.test.js.map +1 -0
  33. package/dist/__tests__/pipeline/tableSplit.test.d.ts +2 -0
  34. package/dist/__tests__/pipeline/tableSplit.test.d.ts.map +1 -0
  35. package/dist/__tests__/pipeline/tableSplit.test.js +154 -0
  36. package/dist/__tests__/pipeline/tableSplit.test.js.map +1 -0
  37. package/dist/__tests__/pipeline/trailingBand.test.js +67 -0
  38. package/dist/__tests__/pipeline/trailingBand.test.js.map +1 -1
  39. package/dist/__tests__/pipeline/trailingCalloutBalance.test.d.ts +2 -0
  40. package/dist/__tests__/pipeline/trailingCalloutBalance.test.d.ts.map +1 -0
  41. package/dist/__tests__/pipeline/trailingCalloutBalance.test.js +60 -0
  42. package/dist/__tests__/pipeline/trailingCalloutBalance.test.js.map +1 -0
  43. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
  44. package/dist/canvas-backend/renderResourceBlock.js +13 -1
  45. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  46. package/dist/defaults/calloutStyles.d.ts +1 -0
  47. package/dist/defaults/calloutStyles.d.ts.map +1 -1
  48. package/dist/defaults/calloutStyles.js +6 -0
  49. package/dist/defaults/calloutStyles.js.map +1 -1
  50. package/dist/defaults/index.d.ts +1 -1
  51. package/dist/defaults/index.d.ts.map +1 -1
  52. package/dist/defaults/index.js +1 -1
  53. package/dist/defaults/index.js.map +1 -1
  54. package/dist/defaults/tableStyle.d.ts +7 -1
  55. package/dist/defaults/tableStyle.d.ts.map +1 -1
  56. package/dist/defaults/tableStyle.js +35 -1
  57. package/dist/defaults/tableStyle.js.map +1 -1
  58. package/dist/html-backend.d.ts.map +1 -1
  59. package/dist/html-backend.js +32 -19
  60. package/dist/html-backend.js.map +1 -1
  61. package/dist/index.d.ts +4 -4
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +2 -2
  64. package/dist/index.js.map +1 -1
  65. package/dist/knuthPlass/breakpoints.d.ts.map +1 -1
  66. package/dist/knuthPlass/breakpoints.js +6 -2
  67. package/dist/knuthPlass/breakpoints.js.map +1 -1
  68. package/dist/knuthPlass/constants.d.ts +3 -1
  69. package/dist/knuthPlass/constants.d.ts.map +1 -1
  70. package/dist/knuthPlass/constants.js +3 -1
  71. package/dist/knuthPlass/constants.js.map +1 -1
  72. package/dist/pipeline/bandCaps.d.ts +14 -8
  73. package/dist/pipeline/bandCaps.d.ts.map +1 -1
  74. package/dist/pipeline/bandCaps.js +38 -20
  75. package/dist/pipeline/bandCaps.js.map +1 -1
  76. package/dist/pipeline/build.d.ts.map +1 -1
  77. package/dist/pipeline/build.js +582 -123
  78. package/dist/pipeline/build.js.map +1 -1
  79. package/dist/pipeline/calloutLayout.d.ts +7 -0
  80. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  81. package/dist/pipeline/calloutLayout.js +36 -6
  82. package/dist/pipeline/calloutLayout.js.map +1 -1
  83. package/dist/pipeline/columnBalancing.d.ts +17 -1
  84. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  85. package/dist/pipeline/columnBalancing.js +76 -3
  86. package/dist/pipeline/columnBalancing.js.map +1 -1
  87. package/dist/pipeline/config.js +1 -1
  88. package/dist/pipeline/config.js.map +1 -1
  89. package/dist/pipeline/floatPlacement.d.ts +4 -0
  90. package/dist/pipeline/floatPlacement.d.ts.map +1 -1
  91. package/dist/pipeline/floatPlacement.js.map +1 -1
  92. package/dist/pipeline/paragraphContainers.d.ts.map +1 -1
  93. package/dist/pipeline/paragraphContainers.js +4 -1
  94. package/dist/pipeline/paragraphContainers.js.map +1 -1
  95. package/dist/pipeline/raggedLines.d.ts +16 -0
  96. package/dist/pipeline/raggedLines.d.ts.map +1 -0
  97. package/dist/pipeline/raggedLines.js +28 -0
  98. package/dist/pipeline/raggedLines.js.map +1 -0
  99. package/dist/pipeline/resourceLayout.d.ts +55 -1
  100. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  101. package/dist/pipeline/resourceLayout.js +394 -30
  102. package/dist/pipeline/resourceLayout.js.map +1 -1
  103. package/dist/table/model.d.ts +7 -1
  104. package/dist/table/model.d.ts.map +1 -1
  105. package/dist/table/model.js +22 -0
  106. package/dist/table/model.js.map +1 -1
  107. package/dist/types.d.ts +48 -5
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/vdt.d.ts +59 -0
  110. package/dist/vdt.d.ts.map +1 -1
  111. package/dist/vdt.js.map +1 -1
  112. package/package.json +1 -1
  113. package/dist/__tests__/pipeline/raggedUrl.test.d.ts +0 -2
  114. package/dist/__tests__/pipeline/raggedUrl.test.d.ts.map +0 -1
  115. package/dist/__tests__/pipeline/raggedUrl.test.js +0 -22
  116. package/dist/__tests__/pipeline/raggedUrl.test.js.map +0 -1
  117. package/dist/pipeline/raggedUrl.d.ts +0 -15
  118. package/dist/pipeline/raggedUrl.d.ts.map +0 -1
  119. package/dist/pipeline/raggedUrl.js +0 -28
  120. package/dist/pipeline/raggedUrl.js.map +0 -1
@@ -16,15 +16,18 @@ import { measureContentBlock } from './measureContentBlock';
16
16
  import { planParagraphContainers } from './paragraphContainers';
17
17
  import { planParts, derivePartMeasureContext } from './parts';
18
18
  import { layoutCallout, offsetCalloutToAbsolute, pickCalloutStyle, planCallouts, resolveCalloutAttrs, } from './calloutLayout';
19
- import { layoutResourceBlock } from './resourceLayout';
19
+ import { layoutResourceBlock, planTableSlice } from './resourceLayout';
20
20
  import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
21
21
  import { enumerateCurrentPageSlots, measureFloatBand, columnHasFloatBand, fitsStrict, trueBottom, } from './floatSlots';
22
22
  import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
23
23
  import { defaultResourceTypes } from '../defaults/resourceTypes';
24
24
  import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
25
- import { totalGapLines, proposeBalanceLines, collectColumnGaps, firstDivergentColumn, MAX_BALANCING_PASSES } from './columnBalancing';
25
+ import { totalGapLines, proposeBalanceLines, collectColumnGaps, firstDivergentColumn, MAX_BALANCING_PASSES, balanceKey } from './columnBalancing';
26
26
  import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, bandCapLinesAroundZone, } from './bandCaps';
27
- import { raggedUrlLines } from './raggedUrl';
27
+ import { raggedLooseLines } from './raggedLines';
28
+ /** Tolerance for "does this block fit" checks against a column's free
29
+ * height, absorbing floating-point drift between grid multiples. */
30
+ const FIT_EPS = 0.01;
28
31
  export class BuildCancelledError extends Error {
29
32
  constructor() {
30
33
  super('Build cancelled');
@@ -207,6 +210,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
207
210
  };
208
211
  const floatGapPx = bodyStyle.lineHeightPx;
209
212
  const minTextPx = bodyStyle.lineHeightPx * 3;
213
+ /** Fewest body rows the closing slice of a split table carries. */
214
+ const MIN_TAIL_ROWS = 3;
210
215
  /** Offset a resolved resource block's caption/table geometry from
211
216
  * block-relative to absolute page coordinates (mirrors inline placement). */
212
217
  const offsetResourceBlockToAbsolute = (rb, ox, oy) => {
@@ -220,6 +225,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
220
225
  ln.bbox.y += oy;
221
226
  ln.baseline += oy;
222
227
  }
228
+ for (const ln of rb.continuesLines) {
229
+ ln.bbox.x += ox;
230
+ ln.bbox.y += oy;
231
+ ln.baseline += oy;
232
+ }
223
233
  if (rb.captionBar) {
224
234
  rb.captionBar.rect.x += ox;
225
235
  rb.captionBar.rect.y += oy;
@@ -228,6 +238,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
228
238
  for (const cell of rb.table.cells) {
229
239
  cell.rect.x += ox;
230
240
  cell.rect.y += oy;
241
+ if (cell.image) {
242
+ cell.image.rect.x += ox;
243
+ cell.image.rect.y += oy;
244
+ }
231
245
  for (const cl of cell.lines) {
232
246
  cl.bbox.x += ox;
233
247
  cl.bbox.y += oy;
@@ -236,7 +250,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
236
250
  }
237
251
  }
238
252
  };
239
- const layoutFloat = (resourceId, width) => {
253
+ const layoutFloat = (resourceId, width, slice) => {
240
254
  const resource = resourceById.get(resourceId);
241
255
  if (!resource)
242
256
  return null;
@@ -249,17 +263,38 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
249
263
  resourceNumbering,
250
264
  resourceTypes,
251
265
  resources,
266
+ ...(slice ? { slice } : {}),
252
267
  });
253
268
  };
269
+ /** Row count of a table resource (0 for anything else). */
270
+ const tableRowCount = (resourceId) => resourceById.get(resourceId)?.table?.model.rows.length ?? 0;
271
+ /** The slice a pending float stands for: the whole resource, or — for the
272
+ * rest of a split table — its remaining rows, laid out as the closing
273
+ * slice (no marker; the note). */
274
+ const sliceOf = (f) => f.startRow !== undefined && f.startRow > 0
275
+ ? { startRow: f.startRow, endRow: tableRowCount(f.resourceId), continues: false }
276
+ : undefined;
277
+ const sliceKey = (slice) => slice ? `:${slice.startRow}-${slice.endRow}${slice.continues ? '+' : ''}` : '';
278
+ /** Row metrics of a table float laid out in full at a width, memoised. */
279
+ const tableMetricsMemo = new Map();
280
+ const tableMetrics = (resourceId, width) => {
281
+ const key = `${resourceId}:${width.toFixed(2)}`;
282
+ const memo = tableMetricsMemo.get(key);
283
+ if (memo !== undefined)
284
+ return memo;
285
+ const m = layoutFloat(resourceId, width)?.tableRows ?? null;
286
+ tableMetricsMemo.set(key, m);
287
+ return m;
288
+ };
254
289
  /** Height (and caption baseline) of a float at a given width, memoised —
255
290
  * fit checks run for every pending float on every loop iteration. */
256
291
  const floatMeasureMemo = new Map();
257
- const measureFloat = (resourceId, width) => {
258
- const key = `${resourceId}:${width.toFixed(2)}`;
292
+ const measureFloat = (resourceId, width, slice) => {
293
+ const key = `${resourceId}:${width.toFixed(2)}${sliceKey(slice)}`;
259
294
  const memo = floatMeasureMemo.get(key);
260
295
  if (memo !== undefined)
261
296
  return memo;
262
- const laid = layoutFloat(resourceId, width);
297
+ const laid = layoutFloat(resourceId, width, slice);
263
298
  let m = null;
264
299
  if (laid) {
265
300
  // A bottom band aligns the float's LAST text line to the grid: the
@@ -269,7 +304,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
269
304
  const rb = laid.block;
270
305
  const bodyBottom = rb.bodyRect.y + rb.bodyRect.height;
271
306
  let lastBaseline;
272
- for (const ln of [...rb.captionLines, ...rb.noteLines]) {
307
+ for (const ln of [...rb.captionLines, ...rb.noteLines, ...rb.continuesLines]) {
273
308
  if (ln.bbox.y < bodyBottom - 0.5)
274
309
  continue;
275
310
  if (lastBaseline === undefined || ln.baseline > lastBaseline)
@@ -285,12 +320,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
285
320
  };
286
321
  /** Measure + build a float block at horizontal offset `x` (y = 0), or null
287
322
  * when the resource id is unknown. Caller offsets it to its final `y`. */
288
- const buildFloatBlock = (resourceId, x, width) => {
289
- const laid = layoutFloat(resourceId, width);
323
+ const buildFloatBlock = (resourceId, x, width, slice) => {
324
+ const laid = layoutFloat(resourceId, width, slice);
290
325
  if (!laid)
291
326
  return null;
292
327
  const { block: rb, totalHeight } = laid;
293
- const blk = createVDTBlock(`float-${resourceId}`, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
328
+ const id = slice && slice.startRow > 0 ? `float-${resourceId}-cont-${slice.startRow}` : `float-${resourceId}`;
329
+ const blk = createVDTBlock(id, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
294
330
  blk.resourceBlock = rb;
295
331
  blk.dirty = false;
296
332
  blk.snappedToGrid = false;
@@ -317,24 +353,136 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
317
353
  }
318
354
  return 'span';
319
355
  };
356
+ /** Floats (or slices) placed so far — the progress signal of the drain
357
+ * loop, which a split does not shorten the queue for. */
358
+ let floatsPlaced = 0;
359
+ /**
360
+ * The slice of a table float that fits a fresh band of `avail` px, when
361
+ * the whole (rest of the) table does not: the leading rows within the
362
+ * band, cut where `planTableSlice` allows, verified against the band
363
+ * geometry and shrunk row by row until it fits. `tableStyle.overflow`
364
+ * decides what becomes of the rows left over — a rest float to continue
365
+ * on the next page (`'split'`), nothing (`'clip'`) — or, for `'hide'`,
366
+ * that the table is dropped. Returns null for a figure or a table with
367
+ * nothing to cut, which are force-placed like before.
368
+ */
369
+ const splitTableFloat = (f, width, position, targetCols, contentArea, avail) => {
370
+ const rowCount = tableRowCount(f.resourceId);
371
+ if (rowCount === 0)
372
+ return null;
373
+ const overflow = resolved.tableStyle.overflow;
374
+ if (overflow === 'hide')
375
+ return 'skip';
376
+ const metrics = tableMetrics(f.resourceId, width);
377
+ if (!metrics)
378
+ return null;
379
+ const startRow = f.startRow ?? 0;
380
+ const firstBody = startRow > 0 ? Math.max(startRow, metrics.headerRowCount) : metrics.headerRowCount;
381
+ if (firstBody >= rowCount)
382
+ return null;
383
+ // Overhead of a continuing slice at this width — caption, gaps, marker —
384
+ // from a one-row probe; the row heights come from the metrics.
385
+ const probe = layoutFloat(f.resourceId, width, { startRow, endRow: firstBody + 1, continues: true });
386
+ if (!probe)
387
+ return null;
388
+ const overhead = probe.totalHeight - probe.block.bodyRect.height;
389
+ // A top band rounds up to the grid: the tallest float it holds within
390
+ // `avail` is the grid multiple below it, less the gap.
391
+ const hMax = Math.floor((avail + 0.01) / baselineGrid) * baselineGrid - floatGapPx;
392
+ let end = planTableSlice(metrics, startRow, hMax - overhead);
393
+ // Nothing fits: carry the smallest slice anyway (it overflows, as a
394
+ // dominating figure would) rather than stall the queue.
395
+ const floor = firstBody + 1;
396
+ if (end < floor)
397
+ end = floor;
398
+ // A last page holding a row or two under a repeated header reads as a
399
+ // stranded tail: give the closing slice at least `MIN_TAIL_ROWS` rows by
400
+ // handing some back from this one (at a breakable edge, not under a
401
+ // group head), when this slice can spare them.
402
+ const tail = rowCount - end;
403
+ if (overflow === 'split' && tail > 0 && tail < MIN_TAIL_ROWS) {
404
+ let e = rowCount - MIN_TAIL_ROWS;
405
+ while (e > floor && (!(metrics.breakableAfter[e - 1] ?? true) || metrics.groupHeaderRow[e - 1]))
406
+ e--;
407
+ if (e >= floor && e >= end - MIN_TAIL_ROWS)
408
+ end = e;
409
+ }
410
+ const fits = (slice) => {
411
+ const measure = measureFloat(f.resourceId, width, slice);
412
+ if (!measure)
413
+ return true;
414
+ const { need } = measureFloatBand(position, measure, targetCols, contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms));
415
+ return need <= avail + 0.01;
416
+ };
417
+ const sliceFor = (e) => ({
418
+ startRow,
419
+ endRow: e,
420
+ continues: e < rowCount && overflow === 'split',
421
+ });
422
+ let slice = sliceFor(end);
423
+ // The caption may wrap differently with its suffix, the closing slice
424
+ // carries the note instead of the marker: verify, backing off a row at
425
+ // a time (over breakable edges) when the band still overflows.
426
+ for (let guard = 0; guard < 8 && end > floor && !fits(slice); guard++) {
427
+ do
428
+ end--;
429
+ while (end > floor && !(metrics.breakableAfter[end - 1] ?? true));
430
+ slice = sliceFor(end);
431
+ }
432
+ const rest = slice.continues ? { ...f, startRow: end } : undefined;
433
+ return rest ? { slice, rest } : { slice };
434
+ };
320
435
  /**
321
436
  * Reserve a float band on `targetCols` (one column, or every text column
322
437
  * of the band for a page-span float) and position the float there.
323
438
  * `'fresh'` is the freshly-opened-page rule: keep three lines of text room
324
439
  * once a band already holds a float, but force-place a dominating float
325
- * on an all-text band so the queue always progresses. `'strict'` is the
326
- * current-page rule: the band must fit in each column's remaining height
327
- * (below its content), keeping the text room only next to another band.
328
- * Only mutates page geometry when it places.
440
+ * on an all-text band so the queue always progresses — a table is cut to
441
+ * the band instead and continues on the next page (see
442
+ * {@link splitTableFloat}). `'strict'` is the current-page rule: the band
443
+ * must fit in each column's remaining height (below its content), keeping
444
+ * the text room only next to another band. Only mutates page geometry
445
+ * when it places.
329
446
  */
330
- const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode) => {
447
+ /** The band a float would take in a slot — its height (`need`) and the
448
+ * float's `y` — without reserving it. `null` when the float cannot be
449
+ * measured. */
450
+ const probeFloatBand = (page, f, targetCols, position, pageSpan, anchorToCap) => {
331
451
  const first = targetCols[0];
332
452
  const width = pageSpan ? page.contentArea.width : first.bbox.width;
333
453
  const xLeft = pageSpan ? page.contentArea.x : first.bbox.x;
334
- const measure = measureFloat(f.resourceId, width);
454
+ const slice = sliceOf(f);
455
+ const measure = measureFloat(f.resourceId, width, slice);
335
456
  if (!measure)
457
+ return null;
458
+ // A bottom band normally anchors to the column's true foot (under a
459
+ // trailing cap, the page bottom — the closing-page figure). Before a
460
+ // page-span box the cap IS the band's foot: the figure hugs the text
461
+ // and the box follows both.
462
+ const bottomOf = (c) => anchorToCap ? c.bbox.y + c.bbox.height : trueBottom(c, uncappedBottoms);
463
+ const { need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, bottomOf);
464
+ return { need, y, measure, slice, width, xLeft };
465
+ };
466
+ /** Free height `col` keeps for the flow once a float band `probe` is
467
+ * reserved in it at `position` (mirrors the reservation arithmetic of
468
+ * `placeFloatInColumns`; a capped column absorbs a bottom band in its
469
+ * remembered true bottom, so its free height does not change). */
470
+ const availableAfterBand = (col, position, probe, anchorToCap) => {
471
+ if (position === 'top')
472
+ return Math.max(0, col.availableHeight - probe.need);
473
+ if (uncappedBottoms.has(col) && !anchorToCap)
474
+ return col.availableHeight;
475
+ const newHeight = Math.max(0, probe.y - floatGapPx - col.bbox.y);
476
+ return Math.max(0, col.availableHeight - (col.bbox.height - newHeight));
477
+ };
478
+ const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode, anchorToCap = false) => {
479
+ const first = targetCols[0];
480
+ const probe = probeFloatBand(page, f, targetCols, position, pageSpan, anchorToCap);
481
+ if (!probe)
336
482
  return 'skip';
337
- const { need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms));
483
+ const { width, xLeft } = probe;
484
+ let { slice, measure, need, y } = probe;
485
+ let rest;
338
486
  if (mode === 'fresh') {
339
487
  let minAvail = Infinity;
340
488
  let anyReserved = false;
@@ -346,10 +494,25 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
346
494
  }
347
495
  if (need > minAvail - minTextPx && anyReserved)
348
496
  return 'defer';
497
+ if (need > minAvail + 0.01) {
498
+ // Dominating the band: a table is cut to it.
499
+ const split = splitTableFloat(f, width, position, targetCols, page.contentArea, minAvail);
500
+ if (split === 'skip')
501
+ return 'skip';
502
+ if (split) {
503
+ slice = split.slice;
504
+ rest = split.rest;
505
+ const m = measureFloat(f.resourceId, width, slice);
506
+ if (!m)
507
+ return 'skip';
508
+ measure = m;
509
+ ({ need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms)));
510
+ }
511
+ }
349
512
  }
350
513
  else {
351
514
  for (const c of targetCols) {
352
- if (position === 'bottom' && uncappedBottoms.has(c)) {
515
+ if (position === 'bottom' && uncappedBottoms.has(c) && !anchorToCap) {
353
516
  // Trailing cap: the band must lie entirely below the level cut.
354
517
  if (y - floatGapPx < c.bbox.y + c.bbox.height - 0.01)
355
518
  return 'defer';
@@ -359,7 +522,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
359
522
  return 'defer';
360
523
  }
361
524
  }
362
- const built = buildFloatBlock(f.resourceId, xLeft, width);
525
+ const built = buildFloatBlock(f.resourceId, xLeft, width, slice);
363
526
  if (!built)
364
527
  return 'skip';
365
528
  for (const col of targetCols) {
@@ -373,7 +536,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
373
536
  }
374
537
  else {
375
538
  const capped = uncappedBottoms.get(col);
376
- if (capped !== undefined) {
539
+ if (capped !== undefined && !anchorToCap) {
377
540
  uncappedBottoms.set(col, capped - need);
378
541
  }
379
542
  else {
@@ -381,6 +544,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
381
544
  const reserved = col.bbox.height - newHeight;
382
545
  col.bbox.height = newHeight;
383
546
  col.availableHeight = Math.max(0, col.availableHeight - reserved);
547
+ // Anchored to a cap: the band is the column's content down to
548
+ // the cut; the true bottom shrinks by it too, so a span box
549
+ // measuring its room never cuts across the figure.
550
+ if (capped !== undefined)
551
+ uncappedBottoms.set(col, capped - need);
384
552
  }
385
553
  r.bottom += need;
386
554
  }
@@ -391,7 +559,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
391
559
  built.block.pageIndex = page.index;
392
560
  built.block.columnIndex = first.index;
393
561
  (page.floats ??= []).push(built.block);
394
- return 'placed';
562
+ floatsPlaced++;
563
+ return rest ? { rest } : 'placed';
564
+ };
565
+ /** Apply a slot outcome to the queue at `i`: drop a placed float, keep a
566
+ * deferred one, swap in the rest of a split table. Returns the index to
567
+ * continue from. */
568
+ const settle = (i, r) => {
569
+ if (r === 'defer')
570
+ return i + 1;
571
+ if (typeof r === 'object') {
572
+ pendingFloats[i] = r.rest;
573
+ return i + 1;
574
+ }
575
+ pendingFloats.splice(i, 1);
576
+ return i;
395
577
  };
396
578
  const positionsFor = (f) => f.position === 'auto' ? ['top', 'bottom'] : [f.position];
397
579
  /** Reserve top/bottom bands on a freshly opened page and position as many
@@ -433,21 +615,76 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
433
615
  if (r !== 'defer')
434
616
  break;
435
617
  }
436
- if (r === 'defer')
437
- i++;
438
- else
439
- pendingFloats.splice(i, 1);
618
+ i = settle(i, r);
440
619
  }
441
620
  }
442
621
  };
622
+ /** The keep-together box content block `idx` opens, when it is one that
623
+ * lays out inline in a column of the current band (`span: 'column'`,
624
+ * placement `here`): its height at a column width, so the floats placed
625
+ * right before it can tell whether a slot would starve it. */
626
+ const keepTogetherBoxAt = (idx) => {
627
+ const b = contentBlocks[idx];
628
+ if (!b || b.type !== 'containerStart' || b.containerName !== 'callout')
629
+ return null;
630
+ const plan = calloutPlan.get(idx);
631
+ const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
632
+ if (!plan || !style || !style.keepTogether)
633
+ return null;
634
+ const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
635
+ if (placement !== 'here')
636
+ return null;
637
+ const page = doc.pages[cursor.pageIndex];
638
+ if (span === 'page' && bandColumns(page, currentBand(page, cursor)).length > 1)
639
+ return null;
640
+ const L = makeCalloutLayouter(idx, plan, style);
641
+ const heights = new Map();
642
+ return {
643
+ heightAt: (width) => {
644
+ const key = Math.round(width * 100);
645
+ let h = heights.get(key);
646
+ if (h === undefined) {
647
+ const r = L.layoutRange(CUT_START, L.end, width, 'probe', false);
648
+ h = r.totalHeight + Math.max(pendingSpacing, r.marginTopPx);
649
+ heights.set(key, h);
650
+ }
651
+ return h;
652
+ },
653
+ };
654
+ };
655
+ /** Whether reserving float `f` in `slot` would leave the keep-together
656
+ * box that comes next with no column of the current band to land in —
657
+ * when, without the float, one of them (the cursor's, or an empty one
658
+ * after it) still holds it. A float yields to such a box, as a
659
+ * compositor would: the box stays in flow and the figure takes the next
660
+ * slot (usually the next page) instead of pushing the box off the page
661
+ * and leaving the column with the figure alone. */
662
+ const slotStarvesBox = (page, f, slot, box, anchorToCap) => {
663
+ const cursorCol = page.columns[cursor.columnIndex];
664
+ if (!cursorCol)
665
+ return false;
666
+ const candidates = bandColumns(page, currentBand(page, cursor))
667
+ .filter((c) => c.kind !== 'span' && c.index >= cursorCol.index && (c === cursorCol || c.blocks.length === 0));
668
+ const fitsIn = (c, available) => available >= box.heightAt(c.bbox.width) - 0.01;
669
+ if (!candidates.some((c) => fitsIn(c, c.availableHeight)))
670
+ return false;
671
+ const probe = probeFloatBand(page, f, slot.cols, slot.position, slot.pageSpan, anchorToCap);
672
+ if (!probe)
673
+ return false;
674
+ const after = (c) => slot.cols.includes(c) ? availableAfterBand(c, slot.position, probe, anchorToCap) : c.availableHeight;
675
+ return !candidates.some((c) => fitsIn(c, after(c)));
676
+ };
443
677
  /** Offer every pending float the free slots of the current page after the
444
678
  * cursor (bottom of the referencing column, top / bottom of the next
445
679
  * empty columns; the band bottom for page-span floats). Runs before each
446
- * block is placed, so a float lands in the first gap after its reference. */
447
- const tryPlacePendingFloatsOnCurrentPage = (preferTop = false) => {
680
+ * block is placed, so a float lands in the first gap after its reference.
681
+ * `nextBlockIdx` is that block: a keep-together box it opens holds the
682
+ * slots that would starve it (see `slotStarvesBox`). */
683
+ const tryPlacePendingFloatsOnCurrentPage = (preferTop = false, nextBlockIdx) => {
448
684
  if (pendingFloats.length === 0)
449
685
  return;
450
686
  const page = doc.pages[cursor.pageIndex];
687
+ const box = nextBlockIdx !== undefined ? keepTogetherBoxAt(nextBlockIdx) : null;
451
688
  for (let i = 0; i < pendingFloats.length;) {
452
689
  const f = pendingFloats[i];
453
690
  let r = 'defer';
@@ -455,18 +692,24 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
455
692
  // A page-span box comes next: the head of an empty column keeps the
456
693
  // band cuttable under the float (the box then sits below both the
457
694
  // text and the figure), where the referencing column's foot would
458
- // wall the box off the page.
695
+ // wall the box off the page. A page-span figure waits for the box
696
+ // itself, which cuts the band under the text and sets the figure
697
+ // there, above the box (`placeSpanFloatsAtCut`) — its only slot here
698
+ // would be the band's foot, under the box.
699
+ if (preferTop && f.span === 'page' && bandColumns(page, currentBand(page, cursor)).length > 1) {
700
+ i++;
701
+ continue;
702
+ }
459
703
  if (preferTop)
460
704
  slots = [...slots.filter((s) => s.position === 'top'), ...slots.filter((s) => s.position !== 'top')];
461
705
  for (const slot of slots) {
462
- r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict');
706
+ if (box && slotStarvesBox(page, f, slot, box, preferTop))
707
+ continue;
708
+ r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict', preferTop);
463
709
  if (r !== 'defer')
464
710
  break;
465
711
  }
466
- if (r === 'defer')
467
- i++;
468
- else
469
- pendingFloats.splice(i, 1);
712
+ i = settle(i, r);
470
713
  }
471
714
  };
472
715
  /** Reserve floats on each freshly opened content page. Passed only to the
@@ -481,7 +724,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
481
724
  return false;
482
725
  const plan = calloutPlan.get(idx);
483
726
  const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
484
- if (!style || style.span !== 'page' || style.placement === 'fixed')
727
+ if (!style)
728
+ return false;
729
+ const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
730
+ if (span !== 'page' || placement === 'fixed')
485
731
  return false;
486
732
  const page = doc.pages[cursor.pageIndex];
487
733
  return bandColumns(page, currentBand(page, cursor)).length > 1;
@@ -494,12 +740,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
494
740
  tryPlacePendingFloatsOnCurrentPage();
495
741
  let guard = 0;
496
742
  while (pendingFloats.length > 0 && guard++ < 1000) {
497
- const before = pendingFloats.length;
743
+ const before = floatsPlaced;
498
744
  const startPageIndex = cursor.pageIndex;
499
745
  do {
500
746
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
501
747
  } while (cursor.pageIndex === startPageIndex);
502
- if (pendingFloats.length === before)
748
+ if (floatsPlaced === before)
503
749
  break; // safety: no progress
504
750
  }
505
751
  };
@@ -585,7 +831,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
585
831
  for (const [spanIndex, cap] of bandCaps) {
586
832
  if (cap.startContentIndex !== contentIndex || cap.startPart !== part)
587
833
  continue;
588
- applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms, cap.zone);
834
+ applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms, cap.zone, cap.kind === 'trailing');
589
835
  activeCap = { spanIndex, pageIndex: page.index, band };
590
836
  bandCapsApplied.add(spanIndex);
591
837
  break;
@@ -620,13 +866,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
620
866
  const cols = bandColumns(page, band).filter((c) => c.bbox.height > 0.5);
621
867
  if (cols.length < 2 || cols.some((c) => c.forcedBreak))
622
868
  return;
623
- if (bandCaps?.has(boundaryIndex)) {
624
- if (activeCap && activeCap.spanIndex === boundaryIndex
625
- && activeCap.pageIndex === page.index && activeCap.band === band) {
626
- spanPlacedInBand.add(boundaryIndex);
627
- }
869
+ if (activeCap && activeCap.spanIndex === boundaryIndex
870
+ && activeCap.pageIndex === page.index && activeCap.band === band) {
871
+ spanPlacedInBand.add(boundaryIndex);
628
872
  return;
629
873
  }
874
+ // A cap in force for this boundary that did not open the band the
875
+ // boundary is reached in (an earlier cap moved the flow under it, or
876
+ // its own band overflowed) still gets a fresh proposal below: the
877
+ // driver replaces a cap that no longer applies with it, and ignores it
878
+ // while retrying an applied cap a line taller.
630
879
  if (activeCap && activeCap.pageIndex === page.index && activeCap.band === band)
631
880
  return;
632
881
  if (!cols.some((c) => c.blocks.length > 0))
@@ -637,11 +886,22 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
637
886
  const aroundZone = zone ? bandCapLinesAroundZone(cols, baselineGrid, zone, (c) => columnBottom(c, uncappedBottoms)) : null;
638
887
  if (aroundZone === null && Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5)
639
888
  return;
889
+ // Float bands reserved at the columns' feet are the band's content too:
890
+ // the level cut must leave them room under the text (a figure placed
891
+ // before a span box then anchors to the cut, see `anchorToCap`).
892
+ const footBands = cols.reduce((sum, c) => sum + reservedOf(c).bottom, 0);
893
+ // A figure heading an otherwise empty column keeps its slot: the cut
894
+ // goes no higher than the band it takes, or the capped pass evicts it
895
+ // to a page of its own.
896
+ const top = bandTop(cols);
897
+ const floatHeads = cols
898
+ .filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0)
899
+ .map((c) => Math.ceil((c.bbox.y - top - 0.01) / baselineGrid));
640
900
  bandCapProposals.set(boundaryIndex, {
641
901
  kind: 'trailing',
642
902
  startContentIndex: bandStart.contentIndex,
643
903
  startPart: bandStart.part,
644
- lines: aroundZone ?? bandCapLines(cols, baselineGrid),
904
+ lines: aroundZone ?? Math.max(bandCapLines(cols, baselineGrid, footBands), ...floatHeads),
645
905
  retries: 0,
646
906
  ...(aroundZone !== null ? { zone } : {}),
647
907
  });
@@ -734,6 +994,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
734
994
  doc.blocks.push(child);
735
995
  }
736
996
  };
997
+ const CUT_START = { child: 0, line: 0 };
737
998
  const makeCalloutLayouter = (startIdx, plan, style) => {
738
999
  const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
739
1000
  const realAt = [];
@@ -743,12 +1004,15 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
743
1004
  });
744
1005
  const layoutRange = (from, to, width, frameId, continuation) => {
745
1006
  let n = 0;
1007
+ // A cut inside child `to.child` includes that child (its first
1008
+ // `to.line` lines); a cut at a child's head excludes it.
1009
+ const toChild = to.line > 0 ? to.child + 1 : to.child;
746
1010
  return layoutCallout({
747
1011
  style,
748
1012
  attrs: plan.attrs,
749
1013
  continuation,
750
- children: children.slice(from, to),
751
- childStartIdx: startIdx + 1 + from,
1014
+ children: children.slice(from.child, toChild),
1015
+ childStartIdx: startIdx + 1 + from.child,
752
1016
  width,
753
1017
  ctx: measureCtx,
754
1018
  resolved,
@@ -756,49 +1020,64 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
756
1020
  frameId,
757
1021
  nextChildId: () => `${frameId}-c${n++}`,
758
1022
  paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
1023
+ ...(from.line > 0 ? { lineFrom: from.line } : {}),
1024
+ ...(to.line > 0 ? { lineTo: to.line } : {}),
759
1025
  });
760
1026
  };
761
- return { children, childBase: startIdx + 1, realAt, layoutRange };
1027
+ return { children, childBase: startIdx + 1, realAt, end: { child: children.length, line: 0 }, layoutRange };
762
1028
  };
763
- /** The longest leading fragment of the children from `from` on whose box
764
- * is at most `roomPx` tall — at least one child, and at least one left
765
- * for the rest. The full layout's child geometry picks the candidate
766
- * (box bottom = child bottom + the box's tail below its last child);
767
- * the candidate is then laid out for real and shortened while it does
768
- * not fit. `null` when not even the first child fits. */
769
- const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation) => {
770
- const starts = L.realAt.filter((k) => k >= from);
771
- if (starts.length < 2)
772
- return null;
773
- const full = L.layoutRange(from, L.children.length, width, frameId, continuation);
1029
+ /** The longest leading fragment of the box from cut `from` on whose box
1030
+ * is at most `roomPx` tall. Cuts fall between children or between the
1031
+ * lines of a text child, and every fragment keeps at least the style's
1032
+ * `splitMinLines` lines on its side of the cut (a figure or a display
1033
+ * formula counts as one line). The full layout's geometry ranks the
1034
+ * candidates (box bottom = content bottom at the cut + the box's tail
1035
+ * below its last child); the deepest candidate that fits is laid out
1036
+ * for real and taken when it truly fits. `null` when none does. */
1037
+ const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation, minLines) => {
1038
+ const full = L.layoutRange(from, L.end, width, frameId, continuation);
774
1039
  const lastChild = full.children[full.children.length - 1];
775
1040
  if (!lastChild)
776
1041
  return null;
777
1042
  const tail = full.totalHeight - (lastChild.bbox.y + lastChild.bbox.height);
778
- /** Frame-relative bottom of the last laid-out child before position `to`. */
779
- const bottomBefore = (to) => {
780
- let bottom = 0;
781
- for (const c of full.children) {
782
- if (c.contentIndex !== undefined && c.contentIndex < L.childBase + to) {
783
- bottom = Math.max(bottom, c.bbox.y + c.bbox.height);
1043
+ const totalLines = full.children.reduce((n, c) => n + Math.max(1, c.lines.length), 0);
1044
+ /** Candidate cuts with the head's content bottom and line count. */
1045
+ const candidates = [];
1046
+ let linesBefore = 0;
1047
+ for (let i = 0; i < full.children.length; i++) {
1048
+ const c = full.children[i];
1049
+ const k = (c.contentIndex ?? L.childBase) - L.childBase;
1050
+ const cuttable = c.type !== 'resource' && c.type !== 'mathDisplay' && c.lines.length > 1;
1051
+ // The first laid-out child of a continuation opens after `from.line`
1052
+ // lines: cuts inside it are counted from the child's own head.
1053
+ const lineBase = k === from.child ? from.line : 0;
1054
+ if (cuttable) {
1055
+ for (let l = 1; l < c.lines.length; l++) {
1056
+ const line = c.lines[l - 1];
1057
+ candidates.push({ cut: { child: k, line: lineBase + l }, bottom: line.bbox.y + line.bbox.height, headLines: linesBefore + l });
784
1058
  }
785
1059
  }
786
- return bottom;
787
- };
788
- let j = starts.length - 1;
789
- while (j >= 1 && bottomBefore(starts[j]) + tail > roomPx + 0.01)
790
- j--;
791
- for (; j >= 1; j--) {
792
- const to = starts[j];
793
- const result = L.layoutRange(from, to, width, frameId, continuation);
1060
+ linesBefore += Math.max(1, c.lines.length);
1061
+ if (i < full.children.length - 1) {
1062
+ candidates.push({ cut: { child: k + 1, line: 0 }, bottom: c.bbox.y + c.bbox.height, headLines: linesBefore });
1063
+ }
1064
+ }
1065
+ const min = Math.max(1, minLines);
1066
+ const viable = candidates
1067
+ .filter((c) => c.headLines >= min && totalLines - c.headLines >= min)
1068
+ .sort((a, b) => b.bottom - a.bottom);
1069
+ for (const c of viable) {
1070
+ if (c.bottom + tail > roomPx + 0.01)
1071
+ continue;
1072
+ const result = L.layoutRange(from, c.cut, width, frameId, continuation);
794
1073
  if (result.totalHeight <= roomPx + 0.01)
795
- return { to, result };
1074
+ return { to: c.cut, result };
796
1075
  }
797
1076
  return null;
798
1077
  };
799
- /** Absolute content indices of the children in `[from, to)` of `L`, for
800
- * the fragment's source range. */
801
- const fragmentRange = (L, from, to) => ({ firstChildIdx: L.childBase + from, lastChildIdx: L.childBase + to - 1 });
1078
+ /** Absolute content indices of the children a fragment from cut `from`
1079
+ * to cut `to` of `L` touches, for the fragment's source range. */
1080
+ const fragmentRange = (L, from, to) => ({ firstChildIdx: L.childBase + from.child, lastChildIdx: L.childBase + (to.line > 0 ? to.child : to.child - 1) });
802
1081
  const markFragment = (result, part, continued) => {
803
1082
  if (result.frame.callout) {
804
1083
  result.frame.callout.part = part;
@@ -879,9 +1158,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
879
1158
  * referenced by the very block before the box, so a cut that spills
880
1159
  * that block into the figure's column leaves the figure no slot after
881
1160
  * its reference (it would fall off the page, and the box with it). The
882
- * box then cuts under the text and the figure alike, the slack under
883
- * the figure being the compositor's usual trade; a figure referenced
884
- * earlier keeps the cap route, which flows text under it. */
1161
+ * box then cuts under the text and the figure alike; a figure
1162
+ * referenced earlier keeps the cap route, which flows text under it. */
885
1163
  const lastBlockBefore = () => {
886
1164
  let j = startIdx - 1;
887
1165
  while (j >= 0 && isMarkerBlock(contentBlocks[j]))
@@ -897,13 +1175,69 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
897
1175
  return false;
898
1176
  if (!isBandLevel(textCols))
899
1177
  return false;
900
- const textBottom = bandUsedBottom(textCols);
901
1178
  const before = lastBlockBefore();
902
- return floatOnly.every((c) => c.bbox.y <= textBottom + baselineGrid + 0.5 && topFloatRefOf.get(c) === before);
1179
+ // However tall the figure is: the text before the box is all placed,
1180
+ // so no cut could level the columns any better — the box goes under
1181
+ // both, the slack under the text being the compositor's trade.
1182
+ return floatOnly.every((c) => topFloatRefOf.get(c) === before);
1183
+ };
1184
+ /**
1185
+ * A page-span figure referenced before the box takes the cut first: the
1186
+ * band is closed level under the text, the figure spans the page right
1187
+ * there (where the text ended, as the compositor reads it), and the box
1188
+ * goes on below — or to the next page when it no longer fits. Only when
1189
+ * the band can be cut for the box (level, or capped for it) and the
1190
+ * figure fits between the cut and the band bottom; otherwise the figure
1191
+ * stays pending for the ordinary slots. Returns whether any was set.
1192
+ */
1193
+ const placeSpanFloatsAtCut = (page, capActiveHere) => {
1194
+ let placedAny = false;
1195
+ for (let i = 0; i < pendingFloats.length;) {
1196
+ const f = pendingFloats[i];
1197
+ const cols = bandColumns(page, currentBand(page, cursor));
1198
+ if (f.span !== 'page' || cols.length < 2 || !((capActiveHere && !placedAny) || levelForBox(cols))) {
1199
+ i++;
1200
+ continue;
1201
+ }
1202
+ const width = page.contentArea.width;
1203
+ const slice = sliceOf(f);
1204
+ const measure = measureFloat(f.resourceId, width, slice);
1205
+ if (!measure) {
1206
+ i++;
1207
+ continue;
1208
+ }
1209
+ const cutY = gridUp(page, bandUsedBottom(cols));
1210
+ const spacing = cols.some((c) => c.blocks.length > 0) ? floatGapPx : 0;
1211
+ const need = needFor(spacing, measure.height, floatGapPx);
1212
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
1213
+ if (cutY + need > bandBottom + 0.01) {
1214
+ i++;
1215
+ continue;
1216
+ }
1217
+ const built = buildFloatBlock(f.resourceId, page.contentArea.x, width, slice);
1218
+ if (!built) {
1219
+ i++;
1220
+ continue;
1221
+ }
1222
+ if (capActiveHere && !placedAny) {
1223
+ uncapBand(cols, uncappedBottoms);
1224
+ spanPlacedInBand.add(startIdx);
1225
+ }
1226
+ closeBandAndInsertSpan(page, cols, cutY, built.block, need, cursor, spacing, built.height);
1227
+ // The float was built at (x, 0): move its inner geometry down to
1228
+ // where the span column put it.
1229
+ offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, built.block.bbox.y);
1230
+ built.block.contentIndex = f.firstBlockIdx;
1231
+ doc.blocks.push(built.block);
1232
+ floatsPlaced++;
1233
+ placedAny = true;
1234
+ pendingFloats.splice(i, 1);
1235
+ }
1236
+ return placedAny;
903
1237
  };
904
- /** Position in the children the fragment to place starts at, and its
905
- * 0-based index among the fragments (0 = the box, or its head). */
906
- let from = 0;
1238
+ /** Cut the fragment to place starts at, and its 0-based index among
1239
+ * the fragments (0 = the box, or its head). */
1240
+ let from = CUT_START;
907
1241
  let part = 0;
908
1242
  let frameId = firstFrameId;
909
1243
  /** The box already moved to a fresh page (or sits on an empty one):
@@ -912,7 +1246,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
912
1246
  for (;;) {
913
1247
  let page = doc.pages[cursor.pageIndex];
914
1248
  const continuation = part > 0;
915
- const layoutAt = (width) => L.layoutRange(from, L.children.length, width, frameId, continuation);
1249
+ const layoutAt = (width) => L.layoutRange(from, L.end, width, frameId, continuation);
916
1250
  const result = layoutAt(page.contentArea.width);
917
1251
  /** Where the box would cut the current band, and whether it fits (room
918
1252
  * is measured against the columns' TRUE bottoms — a capped band keeps
@@ -936,11 +1270,70 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
936
1270
  // Is this band capped for this very box? Then it cuts at the band's
937
1271
  // used bottom (at most the cap) even when the last column is short.
938
1272
  const cap = part === 0 ? bandCaps?.get(startIdx) : undefined;
939
- const capActive = cap !== undefined
1273
+ let capActive = cap !== undefined
940
1274
  && activeCap !== null
941
1275
  && activeCap.spanIndex === startIdx
942
1276
  && activeCap.pageIndex === page.index
943
1277
  && activeCap.band === currentBand(page, cursor);
1278
+ // A page-span figure referenced before the box cuts the band first
1279
+ // and the box measures the fresh band under it. A barrier box then
1280
+ // takes every other pending float — the current page's free slots,
1281
+ // else pages opened ahead — before it lands.
1282
+ if (part === 0) {
1283
+ if (placeSpanFloatsAtCut(page, capActive))
1284
+ capActive = false;
1285
+ if (style.floatBarrier && pendingFloats.length > 0) {
1286
+ const pageBefore = cursor.pageIndex;
1287
+ const bandBefore = currentBand(page, cursor);
1288
+ // An uneven, uncapped band the figures are about to leave behind
1289
+ // gets the cap that levels it: a span cap when a page-span figure
1290
+ // would fit under the level cut — the capped pass sets it there
1291
+ // and the box follows — else a trailing cap, the figure and the
1292
+ // box moving on and the band ending level like a closing one.
1293
+ // Only a page-span figure is planned for here; column figures keep
1294
+ // the ordinary slots (their level is the band cap's own business).
1295
+ const first = pendingFloats.find((f) => f.span === 'page');
1296
+ if (first && !capActive && cap === undefined && bandStart && registeredBand
1297
+ && registeredBand.pageIndex === page.index && registeredBand.band === bandBefore) {
1298
+ const cols = bandColumns(page, bandBefore).filter((c) => c.bbox.height > 0.5);
1299
+ if (cols.length > 1 && !isBandLevel(cols) && cols.some((c) => c.blocks.length > 0)) {
1300
+ const lines = bandCapLines(cols, baselineGrid);
1301
+ const capBottom = bandTop(cols) + lines * baselineGrid;
1302
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
1303
+ const m = measureFloat(first.resourceId, page.contentArea.width, sliceOf(first));
1304
+ const figureFits = m !== null && capBottom + needFor(floatGapPx, m.height, floatGapPx) <= bandBottom + 0.01;
1305
+ if (figureFits) {
1306
+ bandCapProposals.set(startIdx, {
1307
+ kind: 'span',
1308
+ startContentIndex: bandStart.contentIndex,
1309
+ startPart: bandStart.part,
1310
+ lines,
1311
+ retries: 0,
1312
+ });
1313
+ }
1314
+ else {
1315
+ proposeTrailingCap(startIdx);
1316
+ }
1317
+ }
1318
+ }
1319
+ tryPlacePendingFloatsOnCurrentPage();
1320
+ drainPendingFloats();
1321
+ if (cursor.pageIndex !== pageBefore || currentBand(doc.pages[cursor.pageIndex], cursor) !== bandBefore) {
1322
+ // A figure took a page ahead and the box follows it there. A
1323
+ // band cut level for it (trailing cap) counts as delivered —
1324
+ // the columns stay cut — and balancing never stretches the
1325
+ // page's last column back to the bottom. A span cap that could
1326
+ // not seat the figure is left undelivered for the driver to
1327
+ // grow or drop.
1328
+ if (capActive && cap?.kind === 'trailing')
1329
+ spanPlacedInBand.add(startIdx);
1330
+ if (doc.pages[pageBefore].columns.some((c) => c.blocks.length > 0))
1331
+ forcedBreakPages.add(pageBefore);
1332
+ }
1333
+ capActive = false;
1334
+ }
1335
+ page = doc.pages[cursor.pageIndex];
1336
+ }
944
1337
  const fit = forceHere ? (measureBand(true) ?? measureBand(false)) : measureBand(!capActive);
945
1338
  let action = null;
946
1339
  if (fit) {
@@ -960,21 +1353,30 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
960
1353
  band = measureBand(false);
961
1354
  }
962
1355
  if (band) {
963
- const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation);
1356
+ const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation, style.splitMinLines);
964
1357
  if (fragment) {
965
1358
  action = { kind: 'split', fit: band, need: needFor(band.spacing, fragment.result.totalHeight, 0), fragment };
966
1359
  }
967
1360
  }
968
1361
  }
969
- if (!action && forceHere && fit)
1362
+ if (!action && forceHere && fit) {
970
1363
  action = { kind: 'whole', fit, need: fit.need, result };
1364
+ (doc.warnings ??= []).push({
1365
+ kind: 'calloutOverflow',
1366
+ pageIndex: cursor.pageIndex,
1367
+ columnIndex: cursor.columnIndex,
1368
+ sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
1369
+ sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
1370
+ overflowPx: Math.max(0, fit.spacing + result.totalHeight - fit.roomPx),
1371
+ });
1372
+ }
971
1373
  if (action) {
972
1374
  if (capActive) {
973
1375
  uncapBand(action.fit.cols, uncappedBottoms);
974
1376
  spanPlacedInBand.add(startIdx);
975
1377
  }
976
1378
  const placed = action.kind === 'whole' ? action.result : action.fragment.result;
977
- const to = action.kind === 'whole' ? L.children.length : action.fragment.to;
1379
+ const to = action.kind === 'whole' ? L.end : action.fragment.to;
978
1380
  if (part > 0 || action.kind === 'split')
979
1381
  markFragment(placed, part, action.kind === 'split');
980
1382
  const spanCol = closeBandAndInsertSpan(page, action.fit.cols, action.fit.cutY, placed.frame, action.need, cursor, action.fit.spacing, placed.totalHeight);
@@ -1011,13 +1413,23 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1011
1413
  // (or, for a splittable box, for the whole box flush with the
1012
1414
  // band bottom).
1013
1415
  const cols = bandColumns(page, currentBand(page, cursor));
1014
- const lines = bandCapLines(cols, baselineGrid);
1015
- const capBottom = bandTop(cols) + lines * baselineGrid;
1416
+ // A column holding only a figure at its head must keep that slot
1417
+ // in the capped pass: the cut can go no higher than the band the
1418
+ // figure takes, or the figure falls off the page and the box after it.
1419
+ const top = bandTop(cols);
1420
+ const floatHeads = cols
1421
+ .filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0)
1422
+ .map((c) => Math.ceil((c.bbox.y - top - 0.01) / baselineGrid));
1423
+ const lines = Math.max(bandCapLines(cols, baselineGrid), ...floatHeads);
1424
+ const capBottom = top + lines * baselineGrid;
1016
1425
  const spacing = Math.max(pendingSpacing, result.marginTopPx);
1017
1426
  const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
1018
1427
  const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
1428
+ // A splittable box is worth the cut when its head fits flush with
1429
+ // the band bottom, the rest going on to the next page.
1019
1430
  const fitsAfterCut = capBottom + need + minRoomPx <= bandBottom + 0.01
1020
- || (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01);
1431
+ || (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01)
1432
+ || (splittable && splitCalloutFragment(L, from, page.contentArea.width, bandBottom - capBottom - spacing, frameId, continuation, style.splitMinLines) !== null);
1021
1433
  if (fitsAfterCut) {
1022
1434
  bandCapProposals.set(startIdx, {
1023
1435
  kind: 'span',
@@ -1036,7 +1448,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1036
1448
  proposeTrailingCap(startIdx);
1037
1449
  markForcedBreak();
1038
1450
  }
1039
- else if (capActive && cap.kind === 'trailing') {
1451
+ else if (capActive && cap?.kind === 'trailing') {
1040
1452
  // Reached inside the band cut level for it: delivered even though
1041
1453
  // the box moves on — the columns stay cut.
1042
1454
  spanPlacedInBand.add(startIdx);
@@ -1261,7 +1673,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1261
1673
  const firstFrameId = `block-${blockIdCounter++}`;
1262
1674
  const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
1263
1675
  const L = makeCalloutLayouter(startIdx, plan, style);
1264
- const layoutAt = (width) => L.layoutRange(0, L.children.length, width, firstFrameId, false);
1676
+ const layoutAt = (width) => L.layoutRange(CUT_START, L.end, width, firstFrameId, false);
1265
1677
  // Fixed boxes leave the flow entirely.
1266
1678
  if (placement === 'fixed') {
1267
1679
  placeCalloutFixed(startIdx, plan, style, layoutAt);
@@ -1279,22 +1691,42 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1279
1691
  }
1280
1692
  }
1281
1693
  const splittable = !style.keepTogether;
1282
- let from = 0;
1694
+ let from = CUT_START;
1283
1695
  let part = 0;
1284
1696
  let frameId = firstFrameId;
1697
+ /** Times the box left an EMPTY short column (see below) — bounded. */
1698
+ let shortColumnMoves = 0;
1285
1699
  for (;;) {
1286
1700
  let curCol = currentColumn(doc, cursor);
1287
1701
  const continuation = part > 0;
1288
- const result = L.layoutRange(from, L.children.length, curCol.bbox.width, frameId, continuation);
1289
- const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
1702
+ const result = L.layoutRange(from, L.end, curCol.bbox.width, frameId, continuation);
1703
+ // Column balancing: a box closing its column takes the column's gap
1704
+ // above it (the trailing-callout lever), so its foot lands on the
1705
+ // last grid slot — level with the column beside it. Any fragment
1706
+ // qualifies, as long as something sits above it to push down from:
1707
+ // text, or the float band at the head of an otherwise empty column.
1708
+ const balanceBefore = curCol.blocks.length > 0 || reservedOf(curCol).top > 0
1709
+ ? (balanceExtraPx?.get(balanceKey(startIdx, part)) ?? 0)
1710
+ : 0;
1711
+ const spacing = (curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx)) + balanceBefore;
1290
1712
  const roomPx = curCol.availableHeight - spacing;
1291
1713
  let fragment = null;
1292
1714
  if (result.totalHeight > roomPx + 0.01) {
1293
1715
  // The (rest of the) box does not fit the column: a splittable box
1294
1716
  // leaves the head that fits here…
1295
1717
  if (splittable)
1296
- fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation);
1297
- if (!fragment && curCol.blocks.length > 0) {
1718
+ fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation, style.splitMinLines);
1719
+ // …otherwise it moves whole to the next column — also out of an
1720
+ // EMPTY column that float bands or a band cap have cut short, when
1721
+ // a full column would hold it (bounded, so a run of short columns
1722
+ // cannot make it wander forever). Only a box taller than a full
1723
+ // column is placed anyway, overflowing (a layout warning says so).
1724
+ const shortColumn = shortColumnMoves < 4
1725
+ && curCol.bbox.height < contentArea.height - baselineGrid
1726
+ && result.totalHeight <= contentArea.height + 0.01;
1727
+ if (!fragment && (curCol.blocks.length > 0 || shortColumn)) {
1728
+ if (curCol.blocks.length === 0)
1729
+ shortColumnMoves++;
1298
1730
  // …otherwise it moves whole to the next column. Keep-with-next: a
1299
1731
  // run of headings at the column's tail travels with the box.
1300
1732
  // Skipped when the column holds nothing else (rolling back again
@@ -1315,13 +1747,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1315
1747
  return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
1316
1748
  }
1317
1749
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1318
- curCol = currentColumn(doc, cursor);
1319
- // Columns of different widths (oneAndHalf): re-lay out for the new one.
1320
- if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
1321
- continue;
1322
- }
1750
+ // Try the next column afresh: it may be short too (a float band
1751
+ // reserved on the page it opened), or of another width (oneAndHalf).
1752
+ continue;
1323
1753
  }
1324
1754
  // An empty column that is still too short: placed anyway, overflowing.
1755
+ if (!fragment && curCol.blocks.length === 0 && result.totalHeight > curCol.availableHeight - spacing + 0.01) {
1756
+ (doc.warnings ??= []).push({
1757
+ kind: 'calloutOverflow',
1758
+ pageIndex: cursor.pageIndex,
1759
+ columnIndex: cursor.columnIndex,
1760
+ sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
1761
+ sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
1762
+ overflowPx: result.totalHeight + spacing - curCol.availableHeight,
1763
+ });
1764
+ }
1325
1765
  }
1326
1766
  // Floats first-referenced inside the box still enqueue in reading order
1327
1767
  // (only once the box is committed, so a keep-with-next replay does not
@@ -1330,10 +1770,17 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1330
1770
  for (let i = startIdx + 1; i <= plan.endIdx; i++)
1331
1771
  enqueueFloatsFor(i);
1332
1772
  const placed = fragment ? fragment.result : result;
1333
- const to = fragment ? fragment.to : L.children.length;
1773
+ const to = fragment ? fragment.to : L.end;
1334
1774
  if (part > 0 || fragment)
1335
1775
  markFragment(placed, part, fragment !== null);
1336
- const spacingBefore = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx);
1776
+ const spacingBefore = (curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx)) + balanceBefore;
1777
+ if (balanceBefore > 0)
1778
+ addBalanceExtra(curCol, balanceBefore);
1779
+ // Spacing collapses at a column top, so the lever's push under a
1780
+ // float band is consumed here: the box then opens that far down.
1781
+ if (balanceBefore > 0 && curCol.blocks.length === 0) {
1782
+ curCol.availableHeight = Math.max(0, curCol.availableHeight - balanceBefore);
1783
+ }
1337
1784
  enterBand(startIdx, 0);
1338
1785
  placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
1339
1786
  enterBand(startIdx, 0);
@@ -1367,7 +1814,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1367
1814
  // order. Then enqueue the floats first-referenced in this block, so the
1368
1815
  // next page opened while placing it (or any later block) reserves their
1369
1816
  // band and the next iteration offers them the slots that follow.
1370
- tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
1817
+ tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx), blockIdx);
1371
1818
  enqueueFloatsFor(blockIdx);
1372
1819
  // --- Directives ----------------------------------------------------
1373
1820
  if (rawBlock.type === 'directive') {
@@ -1486,8 +1933,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1486
1933
  // takes every pending float first — in the current page's free
1487
1934
  // slots, else on pages opened ahead of it — so no float escapes
1488
1935
  // past it. The page stays balanceable (no forced break).
1489
- if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier) {
1490
- tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
1936
+ // A page-span barrier box drains inside `placeCalloutSpan`, once
1937
+ // the page-span figures before it have taken the band cut.
1938
+ if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier && !spanBoxAt(blockIdx)) {
1939
+ tryPlacePendingFloatsOnCurrentPage();
1491
1940
  drainPendingFloats();
1492
1941
  }
1493
1942
  // `span: 'page'` boxes in multi-column layouts branch to the
@@ -1506,11 +1955,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1506
1955
  ? paragraphContainers.byId.get(rawBlock.containerId)
1507
1956
  : undefined;
1508
1957
  if (pc) {
1958
+ // Margins collapse with the pending spacing; a negative one pulls
1959
+ // the flow up past it instead (the container starts inside the
1960
+ // space the previous block left, or the next block inside the
1961
+ // container's).
1962
+ const collapse = (margin) => margin < 0 ? pendingSpacing + margin : Math.max(pendingSpacing, margin);
1509
1963
  if (rawBlock.type === 'containerStart') {
1510
- pendingSpacing = Math.max(pendingSpacing, pc.marginTopPx);
1964
+ pendingSpacing = collapse(pc.marginTopPx);
1511
1965
  }
1512
1966
  else if (contentBlocks[blockIdx - 1]?.type !== 'paragraph') {
1513
- pendingSpacing = Math.max(pendingSpacing, pc.marginBottomPx);
1967
+ pendingSpacing = collapse(pc.marginBottomPx);
1514
1968
  }
1515
1969
  }
1516
1970
  continue;
@@ -1677,8 +2131,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1677
2131
  // List items may split too — orphan/widow protection per-list is gated by
1678
2132
  // `avoidOrphansInLists` / `avoidWidowsInLists`; bullet stays on first part.
1679
2133
  const canSplit = vdtType === 'paragraph' || vdtType === 'blockquote' || vdtType === 'listItem';
1680
- // A justified line a link leaves with too few spaces is set ragged.
1681
- let remainingLines = [...raggedUrlLines(measured.lines, style.textAlign, rawBlock.text)];
2134
+ // A justified line the breaker could not fill (a link breaking at its
2135
+ // joints, a last word that cannot come up) is set ragged, not stretched.
2136
+ let remainingLines = [...raggedLooseLines(measured.lines, style.textAlign)];
1682
2137
  let partIndex = 0;
1683
2138
  /** Times this block left an EMPTY short column (see `shortColumn`) —
1684
2139
  * bounded so a page whose columns are all short (footnotes, design
@@ -1808,7 +2263,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1808
2263
  : 1;
1809
2264
  const splitAt = remainingLines.length - 1;
1810
2265
  if (splitAt >= effectiveWidowMin) {
1811
- if (spacingBefore > 0)
2266
+ if (spacingBefore !== 0)
1812
2267
  curCol.availableHeight -= spacingBefore;
1813
2268
  const splitLines = remainingLines.slice(0, splitAt);
1814
2269
  const blk = createVDTBlock(id, vdtType, style.fontString, style.color, style.textAlign);
@@ -1884,8 +2339,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1884
2339
  const shortColumn = shortColumnMoves < 4
1885
2340
  && !uncappedBottoms.has(curCol)
1886
2341
  && curCol.bbox.height < contentArea.height - baselineGrid;
1887
- // Block fits in current column
1888
- if (effectiveRemainHeight <= effectiveAvailable) {
2342
+ // Block fits in current column (a hair of tolerance: a capped column
2343
+ // and the grid lines balancing adds above a block differ by floating
2344
+ // point noise, which must not push the block over the cut).
2345
+ if (effectiveRemainHeight <= effectiveAvailable + FIT_EPS) {
1889
2346
  // Heading keep-with-next: never leave a heading as the last block of a
1890
2347
  // column. If the following (non-heading) block wouldn't have room to
1891
2348
  // place at least its widow-minimum number of lines after this heading,
@@ -1930,8 +2387,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1930
2387
  continue;
1931
2388
  }
1932
2389
  }
1933
- // Consume spacing
1934
- if (spacingBefore > 0) {
2390
+ // Consume spacing (negative: a container margin pulling the block up)
2391
+ if (spacingBefore !== 0) {
1935
2392
  curCol.availableHeight -= spacingBefore;
1936
2393
  }
1937
2394
  const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
@@ -1994,7 +2451,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1994
2451
  // the grid (e.g. marginBottom is an exact multiple of baselineGrid),
1995
2452
  // don't round up to the next line.
1996
2453
  const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
1997
- h = snappedBottom - usedHeight;
2454
+ // A negative margin below a container tail may snap the flow back
2455
+ // above the text's own bottom; never below the block's top.
2456
+ h = Math.max(0, snappedBottom - usedHeight);
1998
2457
  }
1999
2458
  placeBlockInColumn(blk, h, curCol, cursor);
2000
2459
  finalizeListItem(blk, partIndex === 0);
@@ -2040,8 +2499,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2040
2499
  slackWeight: resolved.bodyText.slackWeight,
2041
2500
  });
2042
2501
  if (choice.splitAt > 0) {
2043
- // Consume spacing
2044
- if (spacingBefore > 0) {
2502
+ // Consume spacing (negative: a container margin pulling the block up)
2503
+ if (spacingBefore !== 0) {
2045
2504
  curCol.availableHeight -= spacingBefore;
2046
2505
  }
2047
2506
  const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
@@ -2242,12 +2701,12 @@ export function buildDocument(content, config, cache, options) {
2242
2701
  };
2243
2702
  const inColumn = gaps
2244
2703
  .filter((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)
2245
- .flatMap((g) => g.candidates.map((c) => c.contentIndex));
2704
+ .flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
2246
2705
  if (blacklist(new Set(inColumn)))
2247
2706
  return true;
2248
2707
  const onPage = gaps
2249
2708
  .filter((g) => g.pageIndex === div.pageIndex)
2250
- .flatMap((g) => g.candidates.map((c) => c.contentIndex));
2709
+ .flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
2251
2710
  if (blacklist(new Set(onPage)))
2252
2711
  return true;
2253
2712
  return blacklist(null);