postext 0.3.29 → 0.3.30

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 (97) 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/spanBoxHeadAfterCut.test.d.ts +2 -0
  8. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.d.ts.map +1 -0
  9. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.js +96 -0
  10. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.js.map +1 -0
  11. package/dist/__tests__/pipeline/spanFloatAtCut.test.d.ts +2 -0
  12. package/dist/__tests__/pipeline/spanFloatAtCut.test.d.ts.map +1 -0
  13. package/dist/__tests__/pipeline/spanFloatAtCut.test.js +155 -0
  14. package/dist/__tests__/pipeline/spanFloatAtCut.test.js.map +1 -0
  15. package/dist/__tests__/pipeline/tableCellImages.test.d.ts +2 -0
  16. package/dist/__tests__/pipeline/tableCellImages.test.d.ts.map +1 -0
  17. package/dist/__tests__/pipeline/tableCellImages.test.js +166 -0
  18. package/dist/__tests__/pipeline/tableCellImages.test.js.map +1 -0
  19. package/dist/__tests__/pipeline/tableCellLists.test.d.ts +2 -0
  20. package/dist/__tests__/pipeline/tableCellLists.test.d.ts.map +1 -0
  21. package/dist/__tests__/pipeline/tableCellLists.test.js +91 -0
  22. package/dist/__tests__/pipeline/tableCellLists.test.js.map +1 -0
  23. package/dist/__tests__/pipeline/tableSlice.test.d.ts +2 -0
  24. package/dist/__tests__/pipeline/tableSlice.test.d.ts.map +1 -0
  25. package/dist/__tests__/pipeline/tableSlice.test.js +226 -0
  26. package/dist/__tests__/pipeline/tableSlice.test.js.map +1 -0
  27. package/dist/__tests__/pipeline/tableSplit.test.d.ts +2 -0
  28. package/dist/__tests__/pipeline/tableSplit.test.d.ts.map +1 -0
  29. package/dist/__tests__/pipeline/tableSplit.test.js +154 -0
  30. package/dist/__tests__/pipeline/tableSplit.test.js.map +1 -0
  31. package/dist/__tests__/pipeline/trailingCalloutBalance.test.d.ts +2 -0
  32. package/dist/__tests__/pipeline/trailingCalloutBalance.test.d.ts.map +1 -0
  33. package/dist/__tests__/pipeline/trailingCalloutBalance.test.js +60 -0
  34. package/dist/__tests__/pipeline/trailingCalloutBalance.test.js.map +1 -0
  35. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
  36. package/dist/canvas-backend/renderResourceBlock.js +13 -1
  37. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  38. package/dist/defaults/calloutStyles.d.ts +1 -0
  39. package/dist/defaults/calloutStyles.d.ts.map +1 -1
  40. package/dist/defaults/calloutStyles.js +6 -0
  41. package/dist/defaults/calloutStyles.js.map +1 -1
  42. package/dist/defaults/index.d.ts +1 -1
  43. package/dist/defaults/index.d.ts.map +1 -1
  44. package/dist/defaults/index.js +1 -1
  45. package/dist/defaults/index.js.map +1 -1
  46. package/dist/defaults/tableStyle.d.ts +7 -1
  47. package/dist/defaults/tableStyle.d.ts.map +1 -1
  48. package/dist/defaults/tableStyle.js +35 -1
  49. package/dist/defaults/tableStyle.js.map +1 -1
  50. package/dist/html-backend.d.ts.map +1 -1
  51. package/dist/html-backend.js +32 -19
  52. package/dist/html-backend.js.map +1 -1
  53. package/dist/index.d.ts +4 -4
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +2 -2
  56. package/dist/index.js.map +1 -1
  57. package/dist/knuthPlass/breakpoints.d.ts.map +1 -1
  58. package/dist/knuthPlass/breakpoints.js +6 -2
  59. package/dist/knuthPlass/breakpoints.js.map +1 -1
  60. package/dist/knuthPlass/constants.d.ts +3 -1
  61. package/dist/knuthPlass/constants.d.ts.map +1 -1
  62. package/dist/knuthPlass/constants.js +3 -1
  63. package/dist/knuthPlass/constants.js.map +1 -1
  64. package/dist/pipeline/bandCaps.d.ts +4 -2
  65. package/dist/pipeline/bandCaps.d.ts.map +1 -1
  66. package/dist/pipeline/bandCaps.js +5 -3
  67. package/dist/pipeline/bandCaps.js.map +1 -1
  68. package/dist/pipeline/build.d.ts.map +1 -1
  69. package/dist/pipeline/build.js +547 -104
  70. package/dist/pipeline/build.js.map +1 -1
  71. package/dist/pipeline/calloutLayout.d.ts +7 -0
  72. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  73. package/dist/pipeline/calloutLayout.js +36 -6
  74. package/dist/pipeline/calloutLayout.js.map +1 -1
  75. package/dist/pipeline/columnBalancing.d.ts +17 -1
  76. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  77. package/dist/pipeline/columnBalancing.js +72 -2
  78. package/dist/pipeline/columnBalancing.js.map +1 -1
  79. package/dist/pipeline/config.js +1 -1
  80. package/dist/pipeline/config.js.map +1 -1
  81. package/dist/pipeline/floatPlacement.d.ts +4 -0
  82. package/dist/pipeline/floatPlacement.d.ts.map +1 -1
  83. package/dist/pipeline/floatPlacement.js.map +1 -1
  84. package/dist/pipeline/resourceLayout.d.ts +55 -1
  85. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  86. package/dist/pipeline/resourceLayout.js +394 -30
  87. package/dist/pipeline/resourceLayout.js.map +1 -1
  88. package/dist/table/model.d.ts +7 -1
  89. package/dist/table/model.d.ts.map +1 -1
  90. package/dist/table/model.js +22 -0
  91. package/dist/table/model.js.map +1 -1
  92. package/dist/types.d.ts +48 -5
  93. package/dist/types.d.ts.map +1 -1
  94. package/dist/vdt.d.ts +55 -0
  95. package/dist/vdt.d.ts.map +1 -1
  96. package/dist/vdt.js.map +1 -1
  97. package/package.json +1 -1
@@ -16,13 +16,13 @@ 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
27
  import { raggedUrlLines } from './raggedUrl';
28
28
  export class BuildCancelledError extends Error {
@@ -207,6 +207,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
207
207
  };
208
208
  const floatGapPx = bodyStyle.lineHeightPx;
209
209
  const minTextPx = bodyStyle.lineHeightPx * 3;
210
+ /** Fewest body rows the closing slice of a split table carries. */
211
+ const MIN_TAIL_ROWS = 3;
210
212
  /** Offset a resolved resource block's caption/table geometry from
211
213
  * block-relative to absolute page coordinates (mirrors inline placement). */
212
214
  const offsetResourceBlockToAbsolute = (rb, ox, oy) => {
@@ -220,6 +222,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
220
222
  ln.bbox.y += oy;
221
223
  ln.baseline += oy;
222
224
  }
225
+ for (const ln of rb.continuesLines) {
226
+ ln.bbox.x += ox;
227
+ ln.bbox.y += oy;
228
+ ln.baseline += oy;
229
+ }
223
230
  if (rb.captionBar) {
224
231
  rb.captionBar.rect.x += ox;
225
232
  rb.captionBar.rect.y += oy;
@@ -228,6 +235,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
228
235
  for (const cell of rb.table.cells) {
229
236
  cell.rect.x += ox;
230
237
  cell.rect.y += oy;
238
+ if (cell.image) {
239
+ cell.image.rect.x += ox;
240
+ cell.image.rect.y += oy;
241
+ }
231
242
  for (const cl of cell.lines) {
232
243
  cl.bbox.x += ox;
233
244
  cl.bbox.y += oy;
@@ -236,7 +247,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
236
247
  }
237
248
  }
238
249
  };
239
- const layoutFloat = (resourceId, width) => {
250
+ const layoutFloat = (resourceId, width, slice) => {
240
251
  const resource = resourceById.get(resourceId);
241
252
  if (!resource)
242
253
  return null;
@@ -249,17 +260,38 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
249
260
  resourceNumbering,
250
261
  resourceTypes,
251
262
  resources,
263
+ ...(slice ? { slice } : {}),
252
264
  });
253
265
  };
266
+ /** Row count of a table resource (0 for anything else). */
267
+ const tableRowCount = (resourceId) => resourceById.get(resourceId)?.table?.model.rows.length ?? 0;
268
+ /** The slice a pending float stands for: the whole resource, or — for the
269
+ * rest of a split table — its remaining rows, laid out as the closing
270
+ * slice (no marker; the note). */
271
+ const sliceOf = (f) => f.startRow !== undefined && f.startRow > 0
272
+ ? { startRow: f.startRow, endRow: tableRowCount(f.resourceId), continues: false }
273
+ : undefined;
274
+ const sliceKey = (slice) => slice ? `:${slice.startRow}-${slice.endRow}${slice.continues ? '+' : ''}` : '';
275
+ /** Row metrics of a table float laid out in full at a width, memoised. */
276
+ const tableMetricsMemo = new Map();
277
+ const tableMetrics = (resourceId, width) => {
278
+ const key = `${resourceId}:${width.toFixed(2)}`;
279
+ const memo = tableMetricsMemo.get(key);
280
+ if (memo !== undefined)
281
+ return memo;
282
+ const m = layoutFloat(resourceId, width)?.tableRows ?? null;
283
+ tableMetricsMemo.set(key, m);
284
+ return m;
285
+ };
254
286
  /** Height (and caption baseline) of a float at a given width, memoised —
255
287
  * fit checks run for every pending float on every loop iteration. */
256
288
  const floatMeasureMemo = new Map();
257
- const measureFloat = (resourceId, width) => {
258
- const key = `${resourceId}:${width.toFixed(2)}`;
289
+ const measureFloat = (resourceId, width, slice) => {
290
+ const key = `${resourceId}:${width.toFixed(2)}${sliceKey(slice)}`;
259
291
  const memo = floatMeasureMemo.get(key);
260
292
  if (memo !== undefined)
261
293
  return memo;
262
- const laid = layoutFloat(resourceId, width);
294
+ const laid = layoutFloat(resourceId, width, slice);
263
295
  let m = null;
264
296
  if (laid) {
265
297
  // A bottom band aligns the float's LAST text line to the grid: the
@@ -269,7 +301,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
269
301
  const rb = laid.block;
270
302
  const bodyBottom = rb.bodyRect.y + rb.bodyRect.height;
271
303
  let lastBaseline;
272
- for (const ln of [...rb.captionLines, ...rb.noteLines]) {
304
+ for (const ln of [...rb.captionLines, ...rb.noteLines, ...rb.continuesLines]) {
273
305
  if (ln.bbox.y < bodyBottom - 0.5)
274
306
  continue;
275
307
  if (lastBaseline === undefined || ln.baseline > lastBaseline)
@@ -285,12 +317,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
285
317
  };
286
318
  /** Measure + build a float block at horizontal offset `x` (y = 0), or null
287
319
  * 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);
320
+ const buildFloatBlock = (resourceId, x, width, slice) => {
321
+ const laid = layoutFloat(resourceId, width, slice);
290
322
  if (!laid)
291
323
  return null;
292
324
  const { block: rb, totalHeight } = laid;
293
- const blk = createVDTBlock(`float-${resourceId}`, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
325
+ const id = slice && slice.startRow > 0 ? `float-${resourceId}-cont-${slice.startRow}` : `float-${resourceId}`;
326
+ const blk = createVDTBlock(id, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
294
327
  blk.resourceBlock = rb;
295
328
  blk.dirty = false;
296
329
  blk.snappedToGrid = false;
@@ -317,24 +350,136 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
317
350
  }
318
351
  return 'span';
319
352
  };
353
+ /** Floats (or slices) placed so far — the progress signal of the drain
354
+ * loop, which a split does not shorten the queue for. */
355
+ let floatsPlaced = 0;
356
+ /**
357
+ * The slice of a table float that fits a fresh band of `avail` px, when
358
+ * the whole (rest of the) table does not: the leading rows within the
359
+ * band, cut where `planTableSlice` allows, verified against the band
360
+ * geometry and shrunk row by row until it fits. `tableStyle.overflow`
361
+ * decides what becomes of the rows left over — a rest float to continue
362
+ * on the next page (`'split'`), nothing (`'clip'`) — or, for `'hide'`,
363
+ * that the table is dropped. Returns null for a figure or a table with
364
+ * nothing to cut, which are force-placed like before.
365
+ */
366
+ const splitTableFloat = (f, width, position, targetCols, contentArea, avail) => {
367
+ const rowCount = tableRowCount(f.resourceId);
368
+ if (rowCount === 0)
369
+ return null;
370
+ const overflow = resolved.tableStyle.overflow;
371
+ if (overflow === 'hide')
372
+ return 'skip';
373
+ const metrics = tableMetrics(f.resourceId, width);
374
+ if (!metrics)
375
+ return null;
376
+ const startRow = f.startRow ?? 0;
377
+ const firstBody = startRow > 0 ? Math.max(startRow, metrics.headerRowCount) : metrics.headerRowCount;
378
+ if (firstBody >= rowCount)
379
+ return null;
380
+ // Overhead of a continuing slice at this width — caption, gaps, marker —
381
+ // from a one-row probe; the row heights come from the metrics.
382
+ const probe = layoutFloat(f.resourceId, width, { startRow, endRow: firstBody + 1, continues: true });
383
+ if (!probe)
384
+ return null;
385
+ const overhead = probe.totalHeight - probe.block.bodyRect.height;
386
+ // A top band rounds up to the grid: the tallest float it holds within
387
+ // `avail` is the grid multiple below it, less the gap.
388
+ const hMax = Math.floor((avail + 0.01) / baselineGrid) * baselineGrid - floatGapPx;
389
+ let end = planTableSlice(metrics, startRow, hMax - overhead);
390
+ // Nothing fits: carry the smallest slice anyway (it overflows, as a
391
+ // dominating figure would) rather than stall the queue.
392
+ const floor = firstBody + 1;
393
+ if (end < floor)
394
+ end = floor;
395
+ // A last page holding a row or two under a repeated header reads as a
396
+ // stranded tail: give the closing slice at least `MIN_TAIL_ROWS` rows by
397
+ // handing some back from this one (at a breakable edge, not under a
398
+ // group head), when this slice can spare them.
399
+ const tail = rowCount - end;
400
+ if (overflow === 'split' && tail > 0 && tail < MIN_TAIL_ROWS) {
401
+ let e = rowCount - MIN_TAIL_ROWS;
402
+ while (e > floor && (!(metrics.breakableAfter[e - 1] ?? true) || metrics.groupHeaderRow[e - 1]))
403
+ e--;
404
+ if (e >= floor && e >= end - MIN_TAIL_ROWS)
405
+ end = e;
406
+ }
407
+ const fits = (slice) => {
408
+ const measure = measureFloat(f.resourceId, width, slice);
409
+ if (!measure)
410
+ return true;
411
+ const { need } = measureFloatBand(position, measure, targetCols, contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms));
412
+ return need <= avail + 0.01;
413
+ };
414
+ const sliceFor = (e) => ({
415
+ startRow,
416
+ endRow: e,
417
+ continues: e < rowCount && overflow === 'split',
418
+ });
419
+ let slice = sliceFor(end);
420
+ // The caption may wrap differently with its suffix, the closing slice
421
+ // carries the note instead of the marker: verify, backing off a row at
422
+ // a time (over breakable edges) when the band still overflows.
423
+ for (let guard = 0; guard < 8 && end > floor && !fits(slice); guard++) {
424
+ do
425
+ end--;
426
+ while (end > floor && !(metrics.breakableAfter[end - 1] ?? true));
427
+ slice = sliceFor(end);
428
+ }
429
+ const rest = slice.continues ? { ...f, startRow: end } : undefined;
430
+ return rest ? { slice, rest } : { slice };
431
+ };
320
432
  /**
321
433
  * Reserve a float band on `targetCols` (one column, or every text column
322
434
  * of the band for a page-span float) and position the float there.
323
435
  * `'fresh'` is the freshly-opened-page rule: keep three lines of text room
324
436
  * 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.
437
+ * on an all-text band so the queue always progresses — a table is cut to
438
+ * the band instead and continues on the next page (see
439
+ * {@link splitTableFloat}). `'strict'` is the current-page rule: the band
440
+ * must fit in each column's remaining height (below its content), keeping
441
+ * the text room only next to another band. Only mutates page geometry
442
+ * when it places.
329
443
  */
330
- const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode) => {
444
+ /** The band a float would take in a slot — its height (`need`) and the
445
+ * float's `y` — without reserving it. `null` when the float cannot be
446
+ * measured. */
447
+ const probeFloatBand = (page, f, targetCols, position, pageSpan, anchorToCap) => {
331
448
  const first = targetCols[0];
332
449
  const width = pageSpan ? page.contentArea.width : first.bbox.width;
333
450
  const xLeft = pageSpan ? page.contentArea.x : first.bbox.x;
334
- const measure = measureFloat(f.resourceId, width);
451
+ const slice = sliceOf(f);
452
+ const measure = measureFloat(f.resourceId, width, slice);
335
453
  if (!measure)
454
+ return null;
455
+ // A bottom band normally anchors to the column's true foot (under a
456
+ // trailing cap, the page bottom — the closing-page figure). Before a
457
+ // page-span box the cap IS the band's foot: the figure hugs the text
458
+ // and the box follows both.
459
+ const bottomOf = (c) => anchorToCap ? c.bbox.y + c.bbox.height : trueBottom(c, uncappedBottoms);
460
+ const { need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, bottomOf);
461
+ return { need, y, measure, slice, width, xLeft };
462
+ };
463
+ /** Free height `col` keeps for the flow once a float band `probe` is
464
+ * reserved in it at `position` (mirrors the reservation arithmetic of
465
+ * `placeFloatInColumns`; a capped column absorbs a bottom band in its
466
+ * remembered true bottom, so its free height does not change). */
467
+ const availableAfterBand = (col, position, probe, anchorToCap) => {
468
+ if (position === 'top')
469
+ return Math.max(0, col.availableHeight - probe.need);
470
+ if (uncappedBottoms.has(col) && !anchorToCap)
471
+ return col.availableHeight;
472
+ const newHeight = Math.max(0, probe.y - floatGapPx - col.bbox.y);
473
+ return Math.max(0, col.availableHeight - (col.bbox.height - newHeight));
474
+ };
475
+ const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode, anchorToCap = false) => {
476
+ const first = targetCols[0];
477
+ const probe = probeFloatBand(page, f, targetCols, position, pageSpan, anchorToCap);
478
+ if (!probe)
336
479
  return 'skip';
337
- const { need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms));
480
+ const { width, xLeft } = probe;
481
+ let { slice, measure, need, y } = probe;
482
+ let rest;
338
483
  if (mode === 'fresh') {
339
484
  let minAvail = Infinity;
340
485
  let anyReserved = false;
@@ -346,10 +491,25 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
346
491
  }
347
492
  if (need > minAvail - minTextPx && anyReserved)
348
493
  return 'defer';
494
+ if (need > minAvail + 0.01) {
495
+ // Dominating the band: a table is cut to it.
496
+ const split = splitTableFloat(f, width, position, targetCols, page.contentArea, minAvail);
497
+ if (split === 'skip')
498
+ return 'skip';
499
+ if (split) {
500
+ slice = split.slice;
501
+ rest = split.rest;
502
+ const m = measureFloat(f.resourceId, width, slice);
503
+ if (!m)
504
+ return 'skip';
505
+ measure = m;
506
+ ({ need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms)));
507
+ }
508
+ }
349
509
  }
350
510
  else {
351
511
  for (const c of targetCols) {
352
- if (position === 'bottom' && uncappedBottoms.has(c)) {
512
+ if (position === 'bottom' && uncappedBottoms.has(c) && !anchorToCap) {
353
513
  // Trailing cap: the band must lie entirely below the level cut.
354
514
  if (y - floatGapPx < c.bbox.y + c.bbox.height - 0.01)
355
515
  return 'defer';
@@ -359,7 +519,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
359
519
  return 'defer';
360
520
  }
361
521
  }
362
- const built = buildFloatBlock(f.resourceId, xLeft, width);
522
+ const built = buildFloatBlock(f.resourceId, xLeft, width, slice);
363
523
  if (!built)
364
524
  return 'skip';
365
525
  for (const col of targetCols) {
@@ -373,7 +533,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
373
533
  }
374
534
  else {
375
535
  const capped = uncappedBottoms.get(col);
376
- if (capped !== undefined) {
536
+ if (capped !== undefined && !anchorToCap) {
377
537
  uncappedBottoms.set(col, capped - need);
378
538
  }
379
539
  else {
@@ -381,6 +541,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
381
541
  const reserved = col.bbox.height - newHeight;
382
542
  col.bbox.height = newHeight;
383
543
  col.availableHeight = Math.max(0, col.availableHeight - reserved);
544
+ // Anchored to a cap: the band is the column's content down to
545
+ // the cut; the true bottom shrinks by it too, so a span box
546
+ // measuring its room never cuts across the figure.
547
+ if (capped !== undefined)
548
+ uncappedBottoms.set(col, capped - need);
384
549
  }
385
550
  r.bottom += need;
386
551
  }
@@ -391,7 +556,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
391
556
  built.block.pageIndex = page.index;
392
557
  built.block.columnIndex = first.index;
393
558
  (page.floats ??= []).push(built.block);
394
- return 'placed';
559
+ floatsPlaced++;
560
+ return rest ? { rest } : 'placed';
561
+ };
562
+ /** Apply a slot outcome to the queue at `i`: drop a placed float, keep a
563
+ * deferred one, swap in the rest of a split table. Returns the index to
564
+ * continue from. */
565
+ const settle = (i, r) => {
566
+ if (r === 'defer')
567
+ return i + 1;
568
+ if (typeof r === 'object') {
569
+ pendingFloats[i] = r.rest;
570
+ return i + 1;
571
+ }
572
+ pendingFloats.splice(i, 1);
573
+ return i;
395
574
  };
396
575
  const positionsFor = (f) => f.position === 'auto' ? ['top', 'bottom'] : [f.position];
397
576
  /** Reserve top/bottom bands on a freshly opened page and position as many
@@ -433,21 +612,76 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
433
612
  if (r !== 'defer')
434
613
  break;
435
614
  }
436
- if (r === 'defer')
437
- i++;
438
- else
439
- pendingFloats.splice(i, 1);
615
+ i = settle(i, r);
440
616
  }
441
617
  }
442
618
  };
619
+ /** The keep-together box content block `idx` opens, when it is one that
620
+ * lays out inline in a column of the current band (`span: 'column'`,
621
+ * placement `here`): its height at a column width, so the floats placed
622
+ * right before it can tell whether a slot would starve it. */
623
+ const keepTogetherBoxAt = (idx) => {
624
+ const b = contentBlocks[idx];
625
+ if (!b || b.type !== 'containerStart' || b.containerName !== 'callout')
626
+ return null;
627
+ const plan = calloutPlan.get(idx);
628
+ const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
629
+ if (!plan || !style || !style.keepTogether)
630
+ return null;
631
+ const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
632
+ if (placement !== 'here')
633
+ return null;
634
+ const page = doc.pages[cursor.pageIndex];
635
+ if (span === 'page' && bandColumns(page, currentBand(page, cursor)).length > 1)
636
+ return null;
637
+ const L = makeCalloutLayouter(idx, plan, style);
638
+ const heights = new Map();
639
+ return {
640
+ heightAt: (width) => {
641
+ const key = Math.round(width * 100);
642
+ let h = heights.get(key);
643
+ if (h === undefined) {
644
+ const r = L.layoutRange(CUT_START, L.end, width, 'probe', false);
645
+ h = r.totalHeight + Math.max(pendingSpacing, r.marginTopPx);
646
+ heights.set(key, h);
647
+ }
648
+ return h;
649
+ },
650
+ };
651
+ };
652
+ /** Whether reserving float `f` in `slot` would leave the keep-together
653
+ * box that comes next with no column of the current band to land in —
654
+ * when, without the float, one of them (the cursor's, or an empty one
655
+ * after it) still holds it. A float yields to such a box, as a
656
+ * compositor would: the box stays in flow and the figure takes the next
657
+ * slot (usually the next page) instead of pushing the box off the page
658
+ * and leaving the column with the figure alone. */
659
+ const slotStarvesBox = (page, f, slot, box, anchorToCap) => {
660
+ const cursorCol = page.columns[cursor.columnIndex];
661
+ if (!cursorCol)
662
+ return false;
663
+ const candidates = bandColumns(page, currentBand(page, cursor))
664
+ .filter((c) => c.kind !== 'span' && c.index >= cursorCol.index && (c === cursorCol || c.blocks.length === 0));
665
+ const fitsIn = (c, available) => available >= box.heightAt(c.bbox.width) - 0.01;
666
+ if (!candidates.some((c) => fitsIn(c, c.availableHeight)))
667
+ return false;
668
+ const probe = probeFloatBand(page, f, slot.cols, slot.position, slot.pageSpan, anchorToCap);
669
+ if (!probe)
670
+ return false;
671
+ const after = (c) => slot.cols.includes(c) ? availableAfterBand(c, slot.position, probe, anchorToCap) : c.availableHeight;
672
+ return !candidates.some((c) => fitsIn(c, after(c)));
673
+ };
443
674
  /** Offer every pending float the free slots of the current page after the
444
675
  * cursor (bottom of the referencing column, top / bottom of the next
445
676
  * 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) => {
677
+ * block is placed, so a float lands in the first gap after its reference.
678
+ * `nextBlockIdx` is that block: a keep-together box it opens holds the
679
+ * slots that would starve it (see `slotStarvesBox`). */
680
+ const tryPlacePendingFloatsOnCurrentPage = (preferTop = false, nextBlockIdx) => {
448
681
  if (pendingFloats.length === 0)
449
682
  return;
450
683
  const page = doc.pages[cursor.pageIndex];
684
+ const box = nextBlockIdx !== undefined ? keepTogetherBoxAt(nextBlockIdx) : null;
451
685
  for (let i = 0; i < pendingFloats.length;) {
452
686
  const f = pendingFloats[i];
453
687
  let r = 'defer';
@@ -455,18 +689,24 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
455
689
  // A page-span box comes next: the head of an empty column keeps the
456
690
  // band cuttable under the float (the box then sits below both the
457
691
  // text and the figure), where the referencing column's foot would
458
- // wall the box off the page.
692
+ // wall the box off the page. A page-span figure waits for the box
693
+ // itself, which cuts the band under the text and sets the figure
694
+ // there, above the box (`placeSpanFloatsAtCut`) — its only slot here
695
+ // would be the band's foot, under the box.
696
+ if (preferTop && f.span === 'page' && bandColumns(page, currentBand(page, cursor)).length > 1) {
697
+ i++;
698
+ continue;
699
+ }
459
700
  if (preferTop)
460
701
  slots = [...slots.filter((s) => s.position === 'top'), ...slots.filter((s) => s.position !== 'top')];
461
702
  for (const slot of slots) {
462
- r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict');
703
+ if (box && slotStarvesBox(page, f, slot, box, preferTop))
704
+ continue;
705
+ r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict', preferTop);
463
706
  if (r !== 'defer')
464
707
  break;
465
708
  }
466
- if (r === 'defer')
467
- i++;
468
- else
469
- pendingFloats.splice(i, 1);
709
+ i = settle(i, r);
470
710
  }
471
711
  };
472
712
  /** Reserve floats on each freshly opened content page. Passed only to the
@@ -481,7 +721,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
481
721
  return false;
482
722
  const plan = calloutPlan.get(idx);
483
723
  const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
484
- if (!style || style.span !== 'page' || style.placement === 'fixed')
724
+ if (!style)
725
+ return false;
726
+ const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
727
+ if (span !== 'page' || placement === 'fixed')
485
728
  return false;
486
729
  const page = doc.pages[cursor.pageIndex];
487
730
  return bandColumns(page, currentBand(page, cursor)).length > 1;
@@ -494,12 +737,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
494
737
  tryPlacePendingFloatsOnCurrentPage();
495
738
  let guard = 0;
496
739
  while (pendingFloats.length > 0 && guard++ < 1000) {
497
- const before = pendingFloats.length;
740
+ const before = floatsPlaced;
498
741
  const startPageIndex = cursor.pageIndex;
499
742
  do {
500
743
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
501
744
  } while (cursor.pageIndex === startPageIndex);
502
- if (pendingFloats.length === before)
745
+ if (floatsPlaced === before)
503
746
  break; // safety: no progress
504
747
  }
505
748
  };
@@ -637,11 +880,22 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
637
880
  const aroundZone = zone ? bandCapLinesAroundZone(cols, baselineGrid, zone, (c) => columnBottom(c, uncappedBottoms)) : null;
638
881
  if (aroundZone === null && Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5)
639
882
  return;
883
+ // Float bands reserved at the columns' feet are the band's content too:
884
+ // the level cut must leave them room under the text (a figure placed
885
+ // before a span box then anchors to the cut, see `anchorToCap`).
886
+ const footBands = cols.reduce((sum, c) => sum + reservedOf(c).bottom, 0);
887
+ // A figure heading an otherwise empty column keeps its slot: the cut
888
+ // goes no higher than the band it takes, or the capped pass evicts it
889
+ // to a page of its own.
890
+ const top = bandTop(cols);
891
+ const floatHeads = cols
892
+ .filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0)
893
+ .map((c) => Math.ceil((c.bbox.y - top - 0.01) / baselineGrid));
640
894
  bandCapProposals.set(boundaryIndex, {
641
895
  kind: 'trailing',
642
896
  startContentIndex: bandStart.contentIndex,
643
897
  startPart: bandStart.part,
644
- lines: aroundZone ?? bandCapLines(cols, baselineGrid),
898
+ lines: aroundZone ?? Math.max(bandCapLines(cols, baselineGrid, footBands), ...floatHeads),
645
899
  retries: 0,
646
900
  ...(aroundZone !== null ? { zone } : {}),
647
901
  });
@@ -734,6 +988,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
734
988
  doc.blocks.push(child);
735
989
  }
736
990
  };
991
+ const CUT_START = { child: 0, line: 0 };
737
992
  const makeCalloutLayouter = (startIdx, plan, style) => {
738
993
  const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
739
994
  const realAt = [];
@@ -743,12 +998,15 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
743
998
  });
744
999
  const layoutRange = (from, to, width, frameId, continuation) => {
745
1000
  let n = 0;
1001
+ // A cut inside child `to.child` includes that child (its first
1002
+ // `to.line` lines); a cut at a child's head excludes it.
1003
+ const toChild = to.line > 0 ? to.child + 1 : to.child;
746
1004
  return layoutCallout({
747
1005
  style,
748
1006
  attrs: plan.attrs,
749
1007
  continuation,
750
- children: children.slice(from, to),
751
- childStartIdx: startIdx + 1 + from,
1008
+ children: children.slice(from.child, toChild),
1009
+ childStartIdx: startIdx + 1 + from.child,
752
1010
  width,
753
1011
  ctx: measureCtx,
754
1012
  resolved,
@@ -756,49 +1014,64 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
756
1014
  frameId,
757
1015
  nextChildId: () => `${frameId}-c${n++}`,
758
1016
  paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
1017
+ ...(from.line > 0 ? { lineFrom: from.line } : {}),
1018
+ ...(to.line > 0 ? { lineTo: to.line } : {}),
759
1019
  });
760
1020
  };
761
- return { children, childBase: startIdx + 1, realAt, layoutRange };
1021
+ return { children, childBase: startIdx + 1, realAt, end: { child: children.length, line: 0 }, layoutRange };
762
1022
  };
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);
1023
+ /** The longest leading fragment of the box from cut `from` on whose box
1024
+ * is at most `roomPx` tall. Cuts fall between children or between the
1025
+ * lines of a text child, and every fragment keeps at least the style's
1026
+ * `splitMinLines` lines on its side of the cut (a figure or a display
1027
+ * formula counts as one line). The full layout's geometry ranks the
1028
+ * candidates (box bottom = content bottom at the cut + the box's tail
1029
+ * below its last child); the deepest candidate that fits is laid out
1030
+ * for real and taken when it truly fits. `null` when none does. */
1031
+ const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation, minLines) => {
1032
+ const full = L.layoutRange(from, L.end, width, frameId, continuation);
774
1033
  const lastChild = full.children[full.children.length - 1];
775
1034
  if (!lastChild)
776
1035
  return null;
777
1036
  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);
1037
+ const totalLines = full.children.reduce((n, c) => n + Math.max(1, c.lines.length), 0);
1038
+ /** Candidate cuts with the head's content bottom and line count. */
1039
+ const candidates = [];
1040
+ let linesBefore = 0;
1041
+ for (let i = 0; i < full.children.length; i++) {
1042
+ const c = full.children[i];
1043
+ const k = (c.contentIndex ?? L.childBase) - L.childBase;
1044
+ const cuttable = c.type !== 'resource' && c.type !== 'mathDisplay' && c.lines.length > 1;
1045
+ // The first laid-out child of a continuation opens after `from.line`
1046
+ // lines: cuts inside it are counted from the child's own head.
1047
+ const lineBase = k === from.child ? from.line : 0;
1048
+ if (cuttable) {
1049
+ for (let l = 1; l < c.lines.length; l++) {
1050
+ const line = c.lines[l - 1];
1051
+ candidates.push({ cut: { child: k, line: lineBase + l }, bottom: line.bbox.y + line.bbox.height, headLines: linesBefore + l });
784
1052
  }
785
1053
  }
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);
1054
+ linesBefore += Math.max(1, c.lines.length);
1055
+ if (i < full.children.length - 1) {
1056
+ candidates.push({ cut: { child: k + 1, line: 0 }, bottom: c.bbox.y + c.bbox.height, headLines: linesBefore });
1057
+ }
1058
+ }
1059
+ const min = Math.max(1, minLines);
1060
+ const viable = candidates
1061
+ .filter((c) => c.headLines >= min && totalLines - c.headLines >= min)
1062
+ .sort((a, b) => b.bottom - a.bottom);
1063
+ for (const c of viable) {
1064
+ if (c.bottom + tail > roomPx + 0.01)
1065
+ continue;
1066
+ const result = L.layoutRange(from, c.cut, width, frameId, continuation);
794
1067
  if (result.totalHeight <= roomPx + 0.01)
795
- return { to, result };
1068
+ return { to: c.cut, result };
796
1069
  }
797
1070
  return null;
798
1071
  };
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 });
1072
+ /** Absolute content indices of the children a fragment from cut `from`
1073
+ * to cut `to` of `L` touches, for the fragment's source range. */
1074
+ const fragmentRange = (L, from, to) => ({ firstChildIdx: L.childBase + from.child, lastChildIdx: L.childBase + (to.line > 0 ? to.child : to.child - 1) });
802
1075
  const markFragment = (result, part, continued) => {
803
1076
  if (result.frame.callout) {
804
1077
  result.frame.callout.part = part;
@@ -879,9 +1152,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
879
1152
  * referenced by the very block before the box, so a cut that spills
880
1153
  * that block into the figure's column leaves the figure no slot after
881
1154
  * 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. */
1155
+ * box then cuts under the text and the figure alike; a figure
1156
+ * referenced earlier keeps the cap route, which flows text under it. */
885
1157
  const lastBlockBefore = () => {
886
1158
  let j = startIdx - 1;
887
1159
  while (j >= 0 && isMarkerBlock(contentBlocks[j]))
@@ -897,13 +1169,69 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
897
1169
  return false;
898
1170
  if (!isBandLevel(textCols))
899
1171
  return false;
900
- const textBottom = bandUsedBottom(textCols);
901
1172
  const before = lastBlockBefore();
902
- return floatOnly.every((c) => c.bbox.y <= textBottom + baselineGrid + 0.5 && topFloatRefOf.get(c) === before);
1173
+ // However tall the figure is: the text before the box is all placed,
1174
+ // so no cut could level the columns any better — the box goes under
1175
+ // both, the slack under the text being the compositor's trade.
1176
+ return floatOnly.every((c) => topFloatRefOf.get(c) === before);
1177
+ };
1178
+ /**
1179
+ * A page-span figure referenced before the box takes the cut first: the
1180
+ * band is closed level under the text, the figure spans the page right
1181
+ * there (where the text ended, as the compositor reads it), and the box
1182
+ * goes on below — or to the next page when it no longer fits. Only when
1183
+ * the band can be cut for the box (level, or capped for it) and the
1184
+ * figure fits between the cut and the band bottom; otherwise the figure
1185
+ * stays pending for the ordinary slots. Returns whether any was set.
1186
+ */
1187
+ const placeSpanFloatsAtCut = (page, capActiveHere) => {
1188
+ let placedAny = false;
1189
+ for (let i = 0; i < pendingFloats.length;) {
1190
+ const f = pendingFloats[i];
1191
+ const cols = bandColumns(page, currentBand(page, cursor));
1192
+ if (f.span !== 'page' || cols.length < 2 || !((capActiveHere && !placedAny) || levelForBox(cols))) {
1193
+ i++;
1194
+ continue;
1195
+ }
1196
+ const width = page.contentArea.width;
1197
+ const slice = sliceOf(f);
1198
+ const measure = measureFloat(f.resourceId, width, slice);
1199
+ if (!measure) {
1200
+ i++;
1201
+ continue;
1202
+ }
1203
+ const cutY = gridUp(page, bandUsedBottom(cols));
1204
+ const spacing = cols.some((c) => c.blocks.length > 0) ? floatGapPx : 0;
1205
+ const need = needFor(spacing, measure.height, floatGapPx);
1206
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
1207
+ if (cutY + need > bandBottom + 0.01) {
1208
+ i++;
1209
+ continue;
1210
+ }
1211
+ const built = buildFloatBlock(f.resourceId, page.contentArea.x, width, slice);
1212
+ if (!built) {
1213
+ i++;
1214
+ continue;
1215
+ }
1216
+ if (capActiveHere && !placedAny) {
1217
+ uncapBand(cols, uncappedBottoms);
1218
+ spanPlacedInBand.add(startIdx);
1219
+ }
1220
+ closeBandAndInsertSpan(page, cols, cutY, built.block, need, cursor, spacing, built.height);
1221
+ // The float was built at (x, 0): move its inner geometry down to
1222
+ // where the span column put it.
1223
+ offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, built.block.bbox.y);
1224
+ built.block.contentIndex = f.firstBlockIdx;
1225
+ doc.blocks.push(built.block);
1226
+ floatsPlaced++;
1227
+ placedAny = true;
1228
+ pendingFloats.splice(i, 1);
1229
+ }
1230
+ return placedAny;
903
1231
  };
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;
1232
+ /** Cut the fragment to place starts at, and its 0-based index among
1233
+ * the fragments (0 = the box, or its head). */
1234
+ let from = CUT_START;
907
1235
  let part = 0;
908
1236
  let frameId = firstFrameId;
909
1237
  /** The box already moved to a fresh page (or sits on an empty one):
@@ -912,7 +1240,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
912
1240
  for (;;) {
913
1241
  let page = doc.pages[cursor.pageIndex];
914
1242
  const continuation = part > 0;
915
- const layoutAt = (width) => L.layoutRange(from, L.children.length, width, frameId, continuation);
1243
+ const layoutAt = (width) => L.layoutRange(from, L.end, width, frameId, continuation);
916
1244
  const result = layoutAt(page.contentArea.width);
917
1245
  /** Where the box would cut the current band, and whether it fits (room
918
1246
  * is measured against the columns' TRUE bottoms — a capped band keeps
@@ -936,11 +1264,70 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
936
1264
  // Is this band capped for this very box? Then it cuts at the band's
937
1265
  // used bottom (at most the cap) even when the last column is short.
938
1266
  const cap = part === 0 ? bandCaps?.get(startIdx) : undefined;
939
- const capActive = cap !== undefined
1267
+ let capActive = cap !== undefined
940
1268
  && activeCap !== null
941
1269
  && activeCap.spanIndex === startIdx
942
1270
  && activeCap.pageIndex === page.index
943
1271
  && activeCap.band === currentBand(page, cursor);
1272
+ // A page-span figure referenced before the box cuts the band first
1273
+ // and the box measures the fresh band under it. A barrier box then
1274
+ // takes every other pending float — the current page's free slots,
1275
+ // else pages opened ahead — before it lands.
1276
+ if (part === 0) {
1277
+ if (placeSpanFloatsAtCut(page, capActive))
1278
+ capActive = false;
1279
+ if (style.floatBarrier && pendingFloats.length > 0) {
1280
+ const pageBefore = cursor.pageIndex;
1281
+ const bandBefore = currentBand(page, cursor);
1282
+ // An uneven, uncapped band the figures are about to leave behind
1283
+ // gets the cap that levels it: a span cap when a page-span figure
1284
+ // would fit under the level cut — the capped pass sets it there
1285
+ // and the box follows — else a trailing cap, the figure and the
1286
+ // box moving on and the band ending level like a closing one.
1287
+ // Only a page-span figure is planned for here; column figures keep
1288
+ // the ordinary slots (their level is the band cap's own business).
1289
+ const first = pendingFloats.find((f) => f.span === 'page');
1290
+ if (first && !capActive && cap === undefined && bandStart && registeredBand
1291
+ && registeredBand.pageIndex === page.index && registeredBand.band === bandBefore) {
1292
+ const cols = bandColumns(page, bandBefore).filter((c) => c.bbox.height > 0.5);
1293
+ if (cols.length > 1 && !isBandLevel(cols) && cols.some((c) => c.blocks.length > 0)) {
1294
+ const lines = bandCapLines(cols, baselineGrid);
1295
+ const capBottom = bandTop(cols) + lines * baselineGrid;
1296
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
1297
+ const m = measureFloat(first.resourceId, page.contentArea.width, sliceOf(first));
1298
+ const figureFits = m !== null && capBottom + needFor(floatGapPx, m.height, floatGapPx) <= bandBottom + 0.01;
1299
+ if (figureFits) {
1300
+ bandCapProposals.set(startIdx, {
1301
+ kind: 'span',
1302
+ startContentIndex: bandStart.contentIndex,
1303
+ startPart: bandStart.part,
1304
+ lines,
1305
+ retries: 0,
1306
+ });
1307
+ }
1308
+ else {
1309
+ proposeTrailingCap(startIdx);
1310
+ }
1311
+ }
1312
+ }
1313
+ tryPlacePendingFloatsOnCurrentPage();
1314
+ drainPendingFloats();
1315
+ if (cursor.pageIndex !== pageBefore || currentBand(doc.pages[cursor.pageIndex], cursor) !== bandBefore) {
1316
+ // A figure took a page ahead and the box follows it there. A
1317
+ // band cut level for it (trailing cap) counts as delivered —
1318
+ // the columns stay cut — and balancing never stretches the
1319
+ // page's last column back to the bottom. A span cap that could
1320
+ // not seat the figure is left undelivered for the driver to
1321
+ // grow or drop.
1322
+ if (capActive && cap?.kind === 'trailing')
1323
+ spanPlacedInBand.add(startIdx);
1324
+ if (doc.pages[pageBefore].columns.some((c) => c.blocks.length > 0))
1325
+ forcedBreakPages.add(pageBefore);
1326
+ }
1327
+ capActive = false;
1328
+ }
1329
+ page = doc.pages[cursor.pageIndex];
1330
+ }
944
1331
  const fit = forceHere ? (measureBand(true) ?? measureBand(false)) : measureBand(!capActive);
945
1332
  let action = null;
946
1333
  if (fit) {
@@ -960,21 +1347,30 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
960
1347
  band = measureBand(false);
961
1348
  }
962
1349
  if (band) {
963
- const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation);
1350
+ const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation, style.splitMinLines);
964
1351
  if (fragment) {
965
1352
  action = { kind: 'split', fit: band, need: needFor(band.spacing, fragment.result.totalHeight, 0), fragment };
966
1353
  }
967
1354
  }
968
1355
  }
969
- if (!action && forceHere && fit)
1356
+ if (!action && forceHere && fit) {
970
1357
  action = { kind: 'whole', fit, need: fit.need, result };
1358
+ (doc.warnings ??= []).push({
1359
+ kind: 'calloutOverflow',
1360
+ pageIndex: cursor.pageIndex,
1361
+ columnIndex: cursor.columnIndex,
1362
+ sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
1363
+ sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
1364
+ overflowPx: Math.max(0, fit.spacing + result.totalHeight - fit.roomPx),
1365
+ });
1366
+ }
971
1367
  if (action) {
972
1368
  if (capActive) {
973
1369
  uncapBand(action.fit.cols, uncappedBottoms);
974
1370
  spanPlacedInBand.add(startIdx);
975
1371
  }
976
1372
  const placed = action.kind === 'whole' ? action.result : action.fragment.result;
977
- const to = action.kind === 'whole' ? L.children.length : action.fragment.to;
1373
+ const to = action.kind === 'whole' ? L.end : action.fragment.to;
978
1374
  if (part > 0 || action.kind === 'split')
979
1375
  markFragment(placed, part, action.kind === 'split');
980
1376
  const spanCol = closeBandAndInsertSpan(page, action.fit.cols, action.fit.cutY, placed.frame, action.need, cursor, action.fit.spacing, placed.totalHeight);
@@ -1011,13 +1407,23 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1011
1407
  // (or, for a splittable box, for the whole box flush with the
1012
1408
  // band bottom).
1013
1409
  const cols = bandColumns(page, currentBand(page, cursor));
1014
- const lines = bandCapLines(cols, baselineGrid);
1015
- const capBottom = bandTop(cols) + lines * baselineGrid;
1410
+ // A column holding only a figure at its head must keep that slot
1411
+ // in the capped pass: the cut can go no higher than the band the
1412
+ // figure takes, or the figure falls off the page and the box after it.
1413
+ const top = bandTop(cols);
1414
+ const floatHeads = cols
1415
+ .filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0)
1416
+ .map((c) => Math.ceil((c.bbox.y - top - 0.01) / baselineGrid));
1417
+ const lines = Math.max(bandCapLines(cols, baselineGrid), ...floatHeads);
1418
+ const capBottom = top + lines * baselineGrid;
1016
1419
  const spacing = Math.max(pendingSpacing, result.marginTopPx);
1017
1420
  const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
1018
1421
  const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
1422
+ // A splittable box is worth the cut when its head fits flush with
1423
+ // the band bottom, the rest going on to the next page.
1019
1424
  const fitsAfterCut = capBottom + need + minRoomPx <= bandBottom + 0.01
1020
- || (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01);
1425
+ || (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01)
1426
+ || (splittable && splitCalloutFragment(L, from, page.contentArea.width, bandBottom - capBottom - spacing, frameId, continuation, style.splitMinLines) !== null);
1021
1427
  if (fitsAfterCut) {
1022
1428
  bandCapProposals.set(startIdx, {
1023
1429
  kind: 'span',
@@ -1036,7 +1442,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1036
1442
  proposeTrailingCap(startIdx);
1037
1443
  markForcedBreak();
1038
1444
  }
1039
- else if (capActive && cap.kind === 'trailing') {
1445
+ else if (capActive && cap?.kind === 'trailing') {
1040
1446
  // Reached inside the band cut level for it: delivered even though
1041
1447
  // the box moves on — the columns stay cut.
1042
1448
  spanPlacedInBand.add(startIdx);
@@ -1261,7 +1667,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1261
1667
  const firstFrameId = `block-${blockIdCounter++}`;
1262
1668
  const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
1263
1669
  const L = makeCalloutLayouter(startIdx, plan, style);
1264
- const layoutAt = (width) => L.layoutRange(0, L.children.length, width, firstFrameId, false);
1670
+ const layoutAt = (width) => L.layoutRange(CUT_START, L.end, width, firstFrameId, false);
1265
1671
  // Fixed boxes leave the flow entirely.
1266
1672
  if (placement === 'fixed') {
1267
1673
  placeCalloutFixed(startIdx, plan, style, layoutAt);
@@ -1279,22 +1685,42 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1279
1685
  }
1280
1686
  }
1281
1687
  const splittable = !style.keepTogether;
1282
- let from = 0;
1688
+ let from = CUT_START;
1283
1689
  let part = 0;
1284
1690
  let frameId = firstFrameId;
1691
+ /** Times the box left an EMPTY short column (see below) — bounded. */
1692
+ let shortColumnMoves = 0;
1285
1693
  for (;;) {
1286
1694
  let curCol = currentColumn(doc, cursor);
1287
1695
  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);
1696
+ const result = L.layoutRange(from, L.end, curCol.bbox.width, frameId, continuation);
1697
+ // Column balancing: a box closing its column takes the column's gap
1698
+ // above it (the trailing-callout lever), so its foot lands on the
1699
+ // last grid slot — level with the column beside it. Any fragment
1700
+ // qualifies, as long as something sits above it to push down from:
1701
+ // text, or the float band at the head of an otherwise empty column.
1702
+ const balanceBefore = curCol.blocks.length > 0 || reservedOf(curCol).top > 0
1703
+ ? (balanceExtraPx?.get(balanceKey(startIdx, part)) ?? 0)
1704
+ : 0;
1705
+ const spacing = (curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx)) + balanceBefore;
1290
1706
  const roomPx = curCol.availableHeight - spacing;
1291
1707
  let fragment = null;
1292
1708
  if (result.totalHeight > roomPx + 0.01) {
1293
1709
  // The (rest of the) box does not fit the column: a splittable box
1294
1710
  // leaves the head that fits here…
1295
1711
  if (splittable)
1296
- fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation);
1297
- if (!fragment && curCol.blocks.length > 0) {
1712
+ fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation, style.splitMinLines);
1713
+ // …otherwise it moves whole to the next column — also out of an
1714
+ // EMPTY column that float bands or a band cap have cut short, when
1715
+ // a full column would hold it (bounded, so a run of short columns
1716
+ // cannot make it wander forever). Only a box taller than a full
1717
+ // column is placed anyway, overflowing (a layout warning says so).
1718
+ const shortColumn = shortColumnMoves < 4
1719
+ && curCol.bbox.height < contentArea.height - baselineGrid
1720
+ && result.totalHeight <= contentArea.height + 0.01;
1721
+ if (!fragment && (curCol.blocks.length > 0 || shortColumn)) {
1722
+ if (curCol.blocks.length === 0)
1723
+ shortColumnMoves++;
1298
1724
  // …otherwise it moves whole to the next column. Keep-with-next: a
1299
1725
  // run of headings at the column's tail travels with the box.
1300
1726
  // Skipped when the column holds nothing else (rolling back again
@@ -1315,13 +1741,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1315
1741
  return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
1316
1742
  }
1317
1743
  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
- }
1744
+ // Try the next column afresh: it may be short too (a float band
1745
+ // reserved on the page it opened), or of another width (oneAndHalf).
1746
+ continue;
1323
1747
  }
1324
1748
  // An empty column that is still too short: placed anyway, overflowing.
1749
+ if (!fragment && curCol.blocks.length === 0 && result.totalHeight > curCol.availableHeight - spacing + 0.01) {
1750
+ (doc.warnings ??= []).push({
1751
+ kind: 'calloutOverflow',
1752
+ pageIndex: cursor.pageIndex,
1753
+ columnIndex: cursor.columnIndex,
1754
+ sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
1755
+ sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
1756
+ overflowPx: result.totalHeight + spacing - curCol.availableHeight,
1757
+ });
1758
+ }
1325
1759
  }
1326
1760
  // Floats first-referenced inside the box still enqueue in reading order
1327
1761
  // (only once the box is committed, so a keep-with-next replay does not
@@ -1330,10 +1764,17 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1330
1764
  for (let i = startIdx + 1; i <= plan.endIdx; i++)
1331
1765
  enqueueFloatsFor(i);
1332
1766
  const placed = fragment ? fragment.result : result;
1333
- const to = fragment ? fragment.to : L.children.length;
1767
+ const to = fragment ? fragment.to : L.end;
1334
1768
  if (part > 0 || fragment)
1335
1769
  markFragment(placed, part, fragment !== null);
1336
- const spacingBefore = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx);
1770
+ const spacingBefore = (curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx)) + balanceBefore;
1771
+ if (balanceBefore > 0)
1772
+ addBalanceExtra(curCol, balanceBefore);
1773
+ // Spacing collapses at a column top, so the lever's push under a
1774
+ // float band is consumed here: the box then opens that far down.
1775
+ if (balanceBefore > 0 && curCol.blocks.length === 0) {
1776
+ curCol.availableHeight = Math.max(0, curCol.availableHeight - balanceBefore);
1777
+ }
1337
1778
  enterBand(startIdx, 0);
1338
1779
  placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
1339
1780
  enterBand(startIdx, 0);
@@ -1367,7 +1808,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1367
1808
  // order. Then enqueue the floats first-referenced in this block, so the
1368
1809
  // next page opened while placing it (or any later block) reserves their
1369
1810
  // band and the next iteration offers them the slots that follow.
1370
- tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
1811
+ tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx), blockIdx);
1371
1812
  enqueueFloatsFor(blockIdx);
1372
1813
  // --- Directives ----------------------------------------------------
1373
1814
  if (rawBlock.type === 'directive') {
@@ -1486,8 +1927,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1486
1927
  // takes every pending float first — in the current page's free
1487
1928
  // slots, else on pages opened ahead of it — so no float escapes
1488
1929
  // past it. The page stays balanceable (no forced break).
1489
- if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier) {
1490
- tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
1930
+ // A page-span barrier box drains inside `placeCalloutSpan`, once
1931
+ // the page-span figures before it have taken the band cut.
1932
+ if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier && !spanBoxAt(blockIdx)) {
1933
+ tryPlacePendingFloatsOnCurrentPage();
1491
1934
  drainPendingFloats();
1492
1935
  }
1493
1936
  // `span: 'page'` boxes in multi-column layouts branch to the
@@ -2242,12 +2685,12 @@ export function buildDocument(content, config, cache, options) {
2242
2685
  };
2243
2686
  const inColumn = gaps
2244
2687
  .filter((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)
2245
- .flatMap((g) => g.candidates.map((c) => c.contentIndex));
2688
+ .flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
2246
2689
  if (blacklist(new Set(inColumn)))
2247
2690
  return true;
2248
2691
  const onPage = gaps
2249
2692
  .filter((g) => g.pageIndex === div.pageIndex)
2250
- .flatMap((g) => g.candidates.map((c) => c.contentIndex));
2693
+ .flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
2251
2694
  if (blacklist(new Set(onPage)))
2252
2695
  return true;
2253
2696
  return blacklist(null);