postext 0.3.28 → 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 (119) hide show
  1. package/dist/__tests__/columnBalancing.test.js +78 -1
  2. package/dist/__tests__/columnBalancing.test.js.map +1 -1
  3. package/dist/__tests__/exports.test.js +2 -0
  4. package/dist/__tests__/exports.test.js.map +1 -1
  5. package/dist/__tests__/pipeline/calloutOverflow.test.d.ts +2 -0
  6. package/dist/__tests__/pipeline/calloutOverflow.test.d.ts.map +1 -0
  7. package/dist/__tests__/pipeline/calloutOverflow.test.js +189 -0
  8. package/dist/__tests__/pipeline/calloutOverflow.test.js.map +1 -0
  9. package/dist/__tests__/pipeline/colonBalancing.test.d.ts +2 -0
  10. package/dist/__tests__/pipeline/colonBalancing.test.d.ts.map +1 -0
  11. package/dist/__tests__/pipeline/colonBalancing.test.js +124 -0
  12. package/dist/__tests__/pipeline/colonBalancing.test.js.map +1 -0
  13. package/dist/__tests__/pipeline/floatFirstSlot.test.js +33 -0
  14. package/dist/__tests__/pipeline/floatFirstSlot.test.js.map +1 -1
  15. package/dist/__tests__/pipeline/headingShortBand.test.d.ts +2 -0
  16. package/dist/__tests__/pipeline/headingShortBand.test.d.ts.map +1 -0
  17. package/dist/__tests__/pipeline/headingShortBand.test.js +89 -0
  18. package/dist/__tests__/pipeline/headingShortBand.test.js.map +1 -0
  19. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.d.ts +2 -0
  20. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.d.ts.map +1 -0
  21. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.js +96 -0
  22. package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.js.map +1 -0
  23. package/dist/__tests__/pipeline/spanFloatAtCut.test.d.ts +2 -0
  24. package/dist/__tests__/pipeline/spanFloatAtCut.test.d.ts.map +1 -0
  25. package/dist/__tests__/pipeline/spanFloatAtCut.test.js +155 -0
  26. package/dist/__tests__/pipeline/spanFloatAtCut.test.js.map +1 -0
  27. package/dist/__tests__/pipeline/tableCellImages.test.d.ts +2 -0
  28. package/dist/__tests__/pipeline/tableCellImages.test.d.ts.map +1 -0
  29. package/dist/__tests__/pipeline/tableCellImages.test.js +166 -0
  30. package/dist/__tests__/pipeline/tableCellImages.test.js.map +1 -0
  31. package/dist/__tests__/pipeline/tableCellLists.test.d.ts +2 -0
  32. package/dist/__tests__/pipeline/tableCellLists.test.d.ts.map +1 -0
  33. package/dist/__tests__/pipeline/tableCellLists.test.js +91 -0
  34. package/dist/__tests__/pipeline/tableCellLists.test.js.map +1 -0
  35. package/dist/__tests__/pipeline/tableSlice.test.d.ts +2 -0
  36. package/dist/__tests__/pipeline/tableSlice.test.d.ts.map +1 -0
  37. package/dist/__tests__/pipeline/tableSlice.test.js +226 -0
  38. package/dist/__tests__/pipeline/tableSlice.test.js.map +1 -0
  39. package/dist/__tests__/pipeline/tableSplit.test.d.ts +2 -0
  40. package/dist/__tests__/pipeline/tableSplit.test.d.ts.map +1 -0
  41. package/dist/__tests__/pipeline/tableSplit.test.js +154 -0
  42. package/dist/__tests__/pipeline/tableSplit.test.js.map +1 -0
  43. package/dist/__tests__/pipeline/trailingCalloutBalance.test.d.ts +2 -0
  44. package/dist/__tests__/pipeline/trailingCalloutBalance.test.d.ts.map +1 -0
  45. package/dist/__tests__/pipeline/trailingCalloutBalance.test.js +60 -0
  46. package/dist/__tests__/pipeline/trailingCalloutBalance.test.js.map +1 -0
  47. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  48. package/dist/canvas-backend/headerFooter.js +2 -6
  49. package/dist/canvas-backend/headerFooter.js.map +1 -1
  50. package/dist/canvas-backend/index.d.ts +1 -1
  51. package/dist/canvas-backend/index.d.ts.map +1 -1
  52. package/dist/canvas-backend/renderResourceBlock.d.ts +15 -1
  53. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
  54. package/dist/canvas-backend/renderResourceBlock.js +117 -9
  55. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  56. package/dist/defaults/calloutStyles.d.ts +1 -0
  57. package/dist/defaults/calloutStyles.d.ts.map +1 -1
  58. package/dist/defaults/calloutStyles.js +6 -0
  59. package/dist/defaults/calloutStyles.js.map +1 -1
  60. package/dist/defaults/headings.d.ts +2 -0
  61. package/dist/defaults/headings.d.ts.map +1 -1
  62. package/dist/defaults/headings.js +14 -0
  63. package/dist/defaults/headings.js.map +1 -1
  64. package/dist/defaults/index.d.ts +1 -1
  65. package/dist/defaults/index.d.ts.map +1 -1
  66. package/dist/defaults/index.js +1 -1
  67. package/dist/defaults/index.js.map +1 -1
  68. package/dist/defaults/tableStyle.d.ts +7 -1
  69. package/dist/defaults/tableStyle.d.ts.map +1 -1
  70. package/dist/defaults/tableStyle.js +35 -1
  71. package/dist/defaults/tableStyle.js.map +1 -1
  72. package/dist/html-backend.d.ts.map +1 -1
  73. package/dist/html-backend.js +32 -19
  74. package/dist/html-backend.js.map +1 -1
  75. package/dist/index.d.ts +5 -5
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +2 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/knuthPlass/breakpoints.d.ts.map +1 -1
  80. package/dist/knuthPlass/breakpoints.js +6 -2
  81. package/dist/knuthPlass/breakpoints.js.map +1 -1
  82. package/dist/knuthPlass/constants.d.ts +3 -1
  83. package/dist/knuthPlass/constants.d.ts.map +1 -1
  84. package/dist/knuthPlass/constants.js +3 -1
  85. package/dist/knuthPlass/constants.js.map +1 -1
  86. package/dist/pipeline/bandCaps.d.ts +4 -2
  87. package/dist/pipeline/bandCaps.d.ts.map +1 -1
  88. package/dist/pipeline/bandCaps.js +5 -3
  89. package/dist/pipeline/bandCaps.js.map +1 -1
  90. package/dist/pipeline/build.d.ts.map +1 -1
  91. package/dist/pipeline/build.js +710 -107
  92. package/dist/pipeline/build.js.map +1 -1
  93. package/dist/pipeline/calloutLayout.d.ts +7 -0
  94. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  95. package/dist/pipeline/calloutLayout.js +36 -6
  96. package/dist/pipeline/calloutLayout.js.map +1 -1
  97. package/dist/pipeline/columnBalancing.d.ts +46 -5
  98. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  99. package/dist/pipeline/columnBalancing.js +142 -6
  100. package/dist/pipeline/columnBalancing.js.map +1 -1
  101. package/dist/pipeline/config.js +1 -1
  102. package/dist/pipeline/config.js.map +1 -1
  103. package/dist/pipeline/floatPlacement.d.ts +4 -0
  104. package/dist/pipeline/floatPlacement.d.ts.map +1 -1
  105. package/dist/pipeline/floatPlacement.js.map +1 -1
  106. package/dist/pipeline/resourceLayout.d.ts +55 -1
  107. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  108. package/dist/pipeline/resourceLayout.js +394 -30
  109. package/dist/pipeline/resourceLayout.js.map +1 -1
  110. package/dist/table/model.d.ts +7 -1
  111. package/dist/table/model.d.ts.map +1 -1
  112. package/dist/table/model.js +22 -0
  113. package/dist/table/model.js.map +1 -1
  114. package/dist/types.d.ts +56 -5
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/vdt.d.ts +55 -0
  117. package/dist/vdt.d.ts.map +1 -1
  118. package/dist/vdt.js.map +1 -1
  119. 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, 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;
@@ -303,6 +336,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
303
336
  * sends single-column floats to the least reserved column). */
304
337
  const floatReserved = new Map();
305
338
  const reservedOf = (col) => floatReserved.get(col) ?? { top: 0, bottom: 0 };
339
+ /** Content index of the block that first referenced the latest float
340
+ * reserved at the head of each column. */
341
+ const topFloatRefOf = new Map();
306
342
  /** Kind of cap the column is under (`undefined` when uncapped). A cap that
307
343
  * cannot be attributed to the active band is treated as a span cap — the
308
344
  * conservative reading, which keeps the column's bottom off the slot list. */
@@ -314,24 +350,136 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
314
350
  }
315
351
  return 'span';
316
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
+ };
317
432
  /**
318
433
  * Reserve a float band on `targetCols` (one column, or every text column
319
434
  * of the band for a page-span float) and position the float there.
320
435
  * `'fresh'` is the freshly-opened-page rule: keep three lines of text room
321
436
  * once a band already holds a float, but force-place a dominating float
322
- * on an all-text band so the queue always progresses. `'strict'` is the
323
- * current-page rule: the band must fit in each column's remaining height
324
- * (below its content), keeping the text room only next to another band.
325
- * 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.
326
443
  */
327
- 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) => {
328
448
  const first = targetCols[0];
329
449
  const width = pageSpan ? page.contentArea.width : first.bbox.width;
330
450
  const xLeft = pageSpan ? page.contentArea.x : first.bbox.x;
331
- const measure = measureFloat(f.resourceId, width);
451
+ const slice = sliceOf(f);
452
+ const measure = measureFloat(f.resourceId, width, slice);
332
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)
333
479
  return 'skip';
334
- 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;
335
483
  if (mode === 'fresh') {
336
484
  let minAvail = Infinity;
337
485
  let anyReserved = false;
@@ -343,10 +491,25 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
343
491
  }
344
492
  if (need > minAvail - minTextPx && anyReserved)
345
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
+ }
346
509
  }
347
510
  else {
348
511
  for (const c of targetCols) {
349
- if (position === 'bottom' && uncappedBottoms.has(c)) {
512
+ if (position === 'bottom' && uncappedBottoms.has(c) && !anchorToCap) {
350
513
  // Trailing cap: the band must lie entirely below the level cut.
351
514
  if (y - floatGapPx < c.bbox.y + c.bbox.height - 0.01)
352
515
  return 'defer';
@@ -356,7 +519,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
356
519
  return 'defer';
357
520
  }
358
521
  }
359
- const built = buildFloatBlock(f.resourceId, xLeft, width);
522
+ const built = buildFloatBlock(f.resourceId, xLeft, width, slice);
360
523
  if (!built)
361
524
  return 'skip';
362
525
  for (const col of targetCols) {
@@ -366,10 +529,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
366
529
  col.bbox.height = Math.max(0, col.bbox.height - need);
367
530
  col.availableHeight = Math.max(0, col.availableHeight - need);
368
531
  r.top += need;
532
+ topFloatRefOf.set(col, Math.max(topFloatRefOf.get(col) ?? -1, f.firstBlockIdx));
369
533
  }
370
534
  else {
371
535
  const capped = uncappedBottoms.get(col);
372
- if (capped !== undefined) {
536
+ if (capped !== undefined && !anchorToCap) {
373
537
  uncappedBottoms.set(col, capped - need);
374
538
  }
375
539
  else {
@@ -377,6 +541,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
377
541
  const reserved = col.bbox.height - newHeight;
378
542
  col.bbox.height = newHeight;
379
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);
380
549
  }
381
550
  r.bottom += need;
382
551
  }
@@ -387,7 +556,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
387
556
  built.block.pageIndex = page.index;
388
557
  built.block.columnIndex = first.index;
389
558
  (page.floats ??= []).push(built.block);
390
- 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;
391
574
  };
392
575
  const positionsFor = (f) => f.position === 'auto' ? ['top', 'bottom'] : [f.position];
393
576
  /** Reserve top/bottom bands on a freshly opened page and position as many
@@ -429,38 +612,123 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
429
612
  if (r !== 'defer')
430
613
  break;
431
614
  }
432
- if (r === 'defer')
433
- i++;
434
- else
435
- pendingFloats.splice(i, 1);
615
+ i = settle(i, r);
436
616
  }
437
617
  }
438
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
+ };
439
674
  /** Offer every pending float the free slots of the current page after the
440
675
  * cursor (bottom of the referencing column, top / bottom of the next
441
676
  * empty columns; the band bottom for page-span floats). Runs before each
442
- * block is placed, so a float lands in the first gap after its reference. */
443
- const tryPlacePendingFloatsOnCurrentPage = () => {
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) => {
444
681
  if (pendingFloats.length === 0)
445
682
  return;
446
683
  const page = doc.pages[cursor.pageIndex];
684
+ const box = nextBlockIdx !== undefined ? keepTogetherBoxAt(nextBlockIdx) : null;
447
685
  for (let i = 0; i < pendingFloats.length;) {
448
686
  const f = pendingFloats[i];
449
687
  let r = 'defer';
450
- for (const slot of enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf)) {
451
- r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict');
688
+ let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf);
689
+ // A page-span box comes next: the head of an empty column keeps the
690
+ // band cuttable under the float (the box then sits below both the
691
+ // text and the figure), where the referencing column's foot would
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
+ }
700
+ if (preferTop)
701
+ slots = [...slots.filter((s) => s.position === 'top'), ...slots.filter((s) => s.position !== 'top')];
702
+ for (const slot of slots) {
703
+ if (box && slotStarvesBox(page, f, slot, box, preferTop))
704
+ continue;
705
+ r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict', preferTop);
452
706
  if (r !== 'defer')
453
707
  break;
454
708
  }
455
- if (r === 'defer')
456
- i++;
457
- else
458
- pendingFloats.splice(i, 1);
709
+ i = settle(i, r);
459
710
  }
460
711
  };
461
712
  /** Reserve floats on each freshly opened content page. Passed only to the
462
713
  * content-flow column advances — parity / force-blank pages never get it. */
463
714
  const onNewPage = (page) => flushFloatsIntoPage(page);
715
+ /** Whether content block `idx` opens a callout that will span the page
716
+ * in the current (multi-column) band — the floats placed right before
717
+ * it prefer the head of an empty column. */
718
+ const spanBoxAt = (idx) => {
719
+ const b = contentBlocks[idx];
720
+ if (!b || b.type !== 'containerStart' || b.containerName !== 'callout')
721
+ return false;
722
+ const plan = calloutPlan.get(idx);
723
+ const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
724
+ if (!style)
725
+ return false;
726
+ const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
727
+ if (span !== 'page' || placement === 'fixed')
728
+ return false;
729
+ const page = doc.pages[cursor.pageIndex];
730
+ return bandColumns(page, currentBand(page, cursor)).length > 1;
731
+ };
464
732
  /** Chapter barrier: place every pending float before the boundary — in
465
733
  * the current page's free slots, then on fresh pages opened ahead of it
466
734
  * (each force-places at least one float). The cursor is left on the last
@@ -469,12 +737,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
469
737
  tryPlacePendingFloatsOnCurrentPage();
470
738
  let guard = 0;
471
739
  while (pendingFloats.length > 0 && guard++ < 1000) {
472
- const before = pendingFloats.length;
740
+ const before = floatsPlaced;
473
741
  const startPageIndex = cursor.pageIndex;
474
742
  do {
475
743
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
476
744
  } while (cursor.pageIndex === startPageIndex);
477
- if (pendingFloats.length === before)
745
+ if (floatsPlaced === before)
478
746
  break; // safety: no progress
479
747
  }
480
748
  };
@@ -531,6 +799,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
531
799
  let activeCap = null;
532
800
  /** True bottoms of capped columns (restored when the span block cuts). */
533
801
  const uncappedBottoms = new Map();
802
+ /** Column balancing: height (px) the levers added inside each column so
803
+ * far — extra grid lines above headings / list ends and lines gained by
804
+ * loose paragraphs. Balancing only ever fills a column's bottom gap, so
805
+ * a placement rule that needs slack *after* a block (keep-colon-with-
806
+ * list) must see the column as it was before the levers filled it:
807
+ * otherwise the block that closed the column in the plain pass moves to
808
+ * the next column, the flow shifts on every later page, and the pass is
809
+ * discarded as a regression. */
810
+ const balanceExtraInColumn = new Map();
811
+ const addBalanceExtra = (col, px) => {
812
+ if (px > 0)
813
+ balanceExtraInColumn.set(col, (balanceExtraInColumn.get(col) ?? 0) + px);
814
+ };
534
815
  const bandCapProposals = new Map();
535
816
  const spanPlacedInBand = new Set();
536
817
  const bandCapsApplied = new Set();
@@ -599,11 +880,22 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
599
880
  const aroundZone = zone ? bandCapLinesAroundZone(cols, baselineGrid, zone, (c) => columnBottom(c, uncappedBottoms)) : null;
600
881
  if (aroundZone === null && Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5)
601
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));
602
894
  bandCapProposals.set(boundaryIndex, {
603
895
  kind: 'trailing',
604
896
  startContentIndex: bandStart.contentIndex,
605
897
  startPart: bandStart.part,
606
- lines: aroundZone ?? bandCapLines(cols, baselineGrid),
898
+ lines: aroundZone ?? Math.max(bandCapLines(cols, baselineGrid, footBands), ...floatHeads),
607
899
  retries: 0,
608
900
  ...(aroundZone !== null ? { zone } : {}),
609
901
  });
@@ -696,6 +988,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
696
988
  doc.blocks.push(child);
697
989
  }
698
990
  };
991
+ const CUT_START = { child: 0, line: 0 };
699
992
  const makeCalloutLayouter = (startIdx, plan, style) => {
700
993
  const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
701
994
  const realAt = [];
@@ -705,12 +998,15 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
705
998
  });
706
999
  const layoutRange = (from, to, width, frameId, continuation) => {
707
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;
708
1004
  return layoutCallout({
709
1005
  style,
710
1006
  attrs: plan.attrs,
711
1007
  continuation,
712
- children: children.slice(from, to),
713
- childStartIdx: startIdx + 1 + from,
1008
+ children: children.slice(from.child, toChild),
1009
+ childStartIdx: startIdx + 1 + from.child,
714
1010
  width,
715
1011
  ctx: measureCtx,
716
1012
  resolved,
@@ -718,49 +1014,64 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
718
1014
  frameId,
719
1015
  nextChildId: () => `${frameId}-c${n++}`,
720
1016
  paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
1017
+ ...(from.line > 0 ? { lineFrom: from.line } : {}),
1018
+ ...(to.line > 0 ? { lineTo: to.line } : {}),
721
1019
  });
722
1020
  };
723
- return { children, childBase: startIdx + 1, realAt, layoutRange };
1021
+ return { children, childBase: startIdx + 1, realAt, end: { child: children.length, line: 0 }, layoutRange };
724
1022
  };
725
- /** The longest leading fragment of the children from `from` on whose box
726
- * is at most `roomPx` tall — at least one child, and at least one left
727
- * for the rest. The full layout's child geometry picks the candidate
728
- * (box bottom = child bottom + the box's tail below its last child);
729
- * the candidate is then laid out for real and shortened while it does
730
- * not fit. `null` when not even the first child fits. */
731
- const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation) => {
732
- const starts = L.realAt.filter((k) => k >= from);
733
- if (starts.length < 2)
734
- return null;
735
- 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);
736
1033
  const lastChild = full.children[full.children.length - 1];
737
1034
  if (!lastChild)
738
1035
  return null;
739
1036
  const tail = full.totalHeight - (lastChild.bbox.y + lastChild.bbox.height);
740
- /** Frame-relative bottom of the last laid-out child before position `to`. */
741
- const bottomBefore = (to) => {
742
- let bottom = 0;
743
- for (const c of full.children) {
744
- if (c.contentIndex !== undefined && c.contentIndex < L.childBase + to) {
745
- 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 });
746
1052
  }
747
1053
  }
748
- return bottom;
749
- };
750
- let j = starts.length - 1;
751
- while (j >= 1 && bottomBefore(starts[j]) + tail > roomPx + 0.01)
752
- j--;
753
- for (; j >= 1; j--) {
754
- const to = starts[j];
755
- 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);
756
1067
  if (result.totalHeight <= roomPx + 0.01)
757
- return { to, result };
1068
+ return { to: c.cut, result };
758
1069
  }
759
1070
  return null;
760
1071
  };
761
- /** Absolute content indices of the children in `[from, to)` of `L`, for
762
- * the fragment's source range. */
763
- 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) });
764
1075
  const markFragment = (result, part, continued) => {
765
1076
  if (result.frame.callout) {
766
1077
  result.frame.callout.part = part;
@@ -835,9 +1146,92 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
835
1146
  const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
836
1147
  return Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5;
837
1148
  };
838
- /** Position in the children the fragment to place starts at, and its
839
- * 0-based index among the fragments (0 = the box, or its head). */
840
- let from = 0;
1149
+ /** Level for the box: the text columns end level, and a column holding
1150
+ * only a float band (a figure at its head, no text yet) counts as level
1151
+ * when a band cap could not level it either — the figure was first
1152
+ * referenced by the very block before the box, so a cut that spills
1153
+ * that block into the figure's column leaves the figure no slot after
1154
+ * its reference (it would fall off the page, and the box with it). The
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. */
1157
+ const lastBlockBefore = () => {
1158
+ let j = startIdx - 1;
1159
+ while (j >= 0 && isMarkerBlock(contentBlocks[j]))
1160
+ j--;
1161
+ return j;
1162
+ };
1163
+ const levelForBox = (cols) => {
1164
+ if (isBandLevel(cols))
1165
+ return true;
1166
+ const textCols = cols.filter((c) => c.blocks.length > 0);
1167
+ const floatOnly = cols.filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0);
1168
+ if (textCols.length === 0 || textCols.length + floatOnly.length !== cols.length)
1169
+ return false;
1170
+ if (!isBandLevel(textCols))
1171
+ return false;
1172
+ const before = lastBlockBefore();
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;
1231
+ };
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;
841
1235
  let part = 0;
842
1236
  let frameId = firstFrameId;
843
1237
  /** The box already moved to a fresh page (or sits on an empty one):
@@ -846,7 +1240,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
846
1240
  for (;;) {
847
1241
  let page = doc.pages[cursor.pageIndex];
848
1242
  const continuation = part > 0;
849
- const layoutAt = (width) => L.layoutRange(from, L.children.length, width, frameId, continuation);
1243
+ const layoutAt = (width) => L.layoutRange(from, L.end, width, frameId, continuation);
850
1244
  const result = layoutAt(page.contentArea.width);
851
1245
  /** Where the box would cut the current band, and whether it fits (room
852
1246
  * is measured against the columns' TRUE bottoms — a capped band keeps
@@ -855,7 +1249,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
855
1249
  * `requireLevel` is set. */
856
1250
  const measureBand = (requireLevel) => {
857
1251
  const cols = bandColumns(page, currentBand(page, cursor));
858
- if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
1252
+ if (cols.length === 0 || (requireLevel && !levelForBox(cols)))
859
1253
  return null;
860
1254
  const cutY = gridUp(page, bandUsedBottom(cols));
861
1255
  const bandHasContent = cols.some((c) => c.blocks.length > 0);
@@ -870,11 +1264,70 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
870
1264
  // Is this band capped for this very box? Then it cuts at the band's
871
1265
  // used bottom (at most the cap) even when the last column is short.
872
1266
  const cap = part === 0 ? bandCaps?.get(startIdx) : undefined;
873
- const capActive = cap !== undefined
1267
+ let capActive = cap !== undefined
874
1268
  && activeCap !== null
875
1269
  && activeCap.spanIndex === startIdx
876
1270
  && activeCap.pageIndex === page.index
877
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
+ }
878
1331
  const fit = forceHere ? (measureBand(true) ?? measureBand(false)) : measureBand(!capActive);
879
1332
  let action = null;
880
1333
  if (fit) {
@@ -894,21 +1347,30 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
894
1347
  band = measureBand(false);
895
1348
  }
896
1349
  if (band) {
897
- 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);
898
1351
  if (fragment) {
899
1352
  action = { kind: 'split', fit: band, need: needFor(band.spacing, fragment.result.totalHeight, 0), fragment };
900
1353
  }
901
1354
  }
902
1355
  }
903
- if (!action && forceHere && fit)
1356
+ if (!action && forceHere && fit) {
904
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
+ }
905
1367
  if (action) {
906
1368
  if (capActive) {
907
1369
  uncapBand(action.fit.cols, uncappedBottoms);
908
1370
  spanPlacedInBand.add(startIdx);
909
1371
  }
910
1372
  const placed = action.kind === 'whole' ? action.result : action.fragment.result;
911
- const to = action.kind === 'whole' ? L.children.length : action.fragment.to;
1373
+ const to = action.kind === 'whole' ? L.end : action.fragment.to;
912
1374
  if (part > 0 || action.kind === 'split')
913
1375
  markFragment(placed, part, action.kind === 'split');
914
1376
  const spanCol = closeBandAndInsertSpan(page, action.fit.cols, action.fit.cutY, placed.frame, action.need, cursor, action.fit.spacing, placed.totalHeight);
@@ -945,13 +1407,23 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
945
1407
  // (or, for a splittable box, for the whole box flush with the
946
1408
  // band bottom).
947
1409
  const cols = bandColumns(page, currentBand(page, cursor));
948
- const lines = bandCapLines(cols, baselineGrid);
949
- 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;
950
1419
  const spacing = Math.max(pendingSpacing, result.marginTopPx);
951
1420
  const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
952
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.
953
1424
  const fitsAfterCut = capBottom + need + minRoomPx <= bandBottom + 0.01
954
- || (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);
955
1427
  if (fitsAfterCut) {
956
1428
  bandCapProposals.set(startIdx, {
957
1429
  kind: 'span',
@@ -970,7 +1442,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
970
1442
  proposeTrailingCap(startIdx);
971
1443
  markForcedBreak();
972
1444
  }
973
- else if (capActive && cap.kind === 'trailing') {
1445
+ else if (capActive && cap?.kind === 'trailing') {
974
1446
  // Reached inside the band cut level for it: delivered even though
975
1447
  // the box moves on — the columns stay cut.
976
1448
  spanPlacedInBand.add(startIdx);
@@ -1195,7 +1667,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1195
1667
  const firstFrameId = `block-${blockIdCounter++}`;
1196
1668
  const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
1197
1669
  const L = makeCalloutLayouter(startIdx, plan, style);
1198
- 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);
1199
1671
  // Fixed boxes leave the flow entirely.
1200
1672
  if (placement === 'fixed') {
1201
1673
  placeCalloutFixed(startIdx, plan, style, layoutAt);
@@ -1213,22 +1685,42 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1213
1685
  }
1214
1686
  }
1215
1687
  const splittable = !style.keepTogether;
1216
- let from = 0;
1688
+ let from = CUT_START;
1217
1689
  let part = 0;
1218
1690
  let frameId = firstFrameId;
1691
+ /** Times the box left an EMPTY short column (see below) — bounded. */
1692
+ let shortColumnMoves = 0;
1219
1693
  for (;;) {
1220
1694
  let curCol = currentColumn(doc, cursor);
1221
1695
  const continuation = part > 0;
1222
- const result = L.layoutRange(from, L.children.length, curCol.bbox.width, frameId, continuation);
1223
- 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;
1224
1706
  const roomPx = curCol.availableHeight - spacing;
1225
1707
  let fragment = null;
1226
1708
  if (result.totalHeight > roomPx + 0.01) {
1227
1709
  // The (rest of the) box does not fit the column: a splittable box
1228
1710
  // leaves the head that fits here…
1229
1711
  if (splittable)
1230
- fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation);
1231
- 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++;
1232
1724
  // …otherwise it moves whole to the next column. Keep-with-next: a
1233
1725
  // run of headings at the column's tail travels with the box.
1234
1726
  // Skipped when the column holds nothing else (rolling back again
@@ -1249,13 +1741,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1249
1741
  return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
1250
1742
  }
1251
1743
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1252
- curCol = currentColumn(doc, cursor);
1253
- // Columns of different widths (oneAndHalf): re-lay out for the new one.
1254
- if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
1255
- continue;
1256
- }
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;
1257
1747
  }
1258
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
+ }
1259
1759
  }
1260
1760
  // Floats first-referenced inside the box still enqueue in reading order
1261
1761
  // (only once the box is committed, so a keep-with-next replay does not
@@ -1264,10 +1764,17 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1264
1764
  for (let i = startIdx + 1; i <= plan.endIdx; i++)
1265
1765
  enqueueFloatsFor(i);
1266
1766
  const placed = fragment ? fragment.result : result;
1267
- const to = fragment ? fragment.to : L.children.length;
1767
+ const to = fragment ? fragment.to : L.end;
1268
1768
  if (part > 0 || fragment)
1269
1769
  markFragment(placed, part, fragment !== null);
1270
- 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
+ }
1271
1778
  enterBand(startIdx, 0);
1272
1779
  placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
1273
1780
  enterBand(startIdx, 0);
@@ -1301,7 +1808,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1301
1808
  // order. Then enqueue the floats first-referenced in this block, so the
1302
1809
  // next page opened while placing it (or any later block) reserves their
1303
1810
  // band and the next iteration offers them the slots that follow.
1304
- tryPlacePendingFloatsOnCurrentPage();
1811
+ tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx), blockIdx);
1305
1812
  enqueueFloatsFor(blockIdx);
1306
1813
  // --- Directives ----------------------------------------------------
1307
1814
  if (rawBlock.type === 'directive') {
@@ -1420,7 +1927,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1420
1927
  // takes every pending float first — in the current page's free
1421
1928
  // slots, else on pages opened ahead of it — so no float escapes
1422
1929
  // past it. The page stays balanceable (no forced break).
1423
- if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier) {
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)) {
1424
1933
  tryPlacePendingFloatsOnCurrentPage();
1425
1934
  drainPendingFloats();
1426
1935
  }
@@ -1614,6 +2123,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1614
2123
  // A justified line a link leaves with too few spaces is set ragged.
1615
2124
  let remainingLines = [...raggedUrlLines(measured.lines, style.textAlign, rawBlock.text)];
1616
2125
  let partIndex = 0;
2126
+ /** Times this block left an EMPTY short column (see `shortColumn`) —
2127
+ * bounded so a page whose columns are all short (footnotes, design
2128
+ * bands) cannot make it wander forever. */
2129
+ let shortColumnMoves = 0;
1617
2130
  // "Keep with next" for colon-introduced lists: a paragraph ending in `:`
1618
2131
  // followed directly by a list acts as a lead-in title — the colon-bearing
1619
2132
  // line must share a column with the first list item. Only checked for the
@@ -1642,8 +2155,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1642
2155
  // page top.
1643
2156
  if (vdtType === 'heading') {
1644
2157
  const extraPx = balanceExtraPx?.get(blockIdx);
1645
- if (extraPx)
2158
+ if (extraPx) {
1646
2159
  spacingBefore += extraPx;
2160
+ addBalanceExtra(curCol, extraPx);
2161
+ }
1647
2162
  }
1648
2163
  }
1649
2164
  else if (vdtType === 'listItem') {
@@ -1657,10 +2172,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1657
2172
  // handled inside the heading branch above (after margin collapsing).
1658
2173
  if (vdtType !== 'heading' && vdtType !== 'mathDisplay' && partIndex === 0) {
1659
2174
  const extraPx = balanceExtraPx?.get(blockIdx);
1660
- if (extraPx)
2175
+ if (extraPx) {
1661
2176
  spacingBefore += extraPx;
2177
+ addBalanceExtra(curCol, extraPx);
2178
+ }
2179
+ }
2180
+ }
2181
+ else if (partIndex === 0 && reservedOf(curCol).top > 0) {
2182
+ // Column balancing: extra grid lines between the float band at the
2183
+ // head of this column and its first block (the after-float lever).
2184
+ const extraPx = balanceExtraPx?.get(blockIdx);
2185
+ if (extraPx) {
2186
+ spacingBefore += extraPx;
2187
+ addBalanceExtra(curCol, extraPx);
1662
2188
  }
1663
2189
  }
2190
+ // A loose paragraph's extra line is balancing height too (it lands
2191
+ // whole in this column — loose candidates are never split parts).
2192
+ if (partIndex === 0 && tryLoose && looseLines !== undefined && typeof looseOutcome.get(blockIdx) === 'number') {
2193
+ addBalanceExtra(curCol, looseLines * style.lineHeightPx);
2194
+ }
1664
2195
  const effectiveAvailable = curCol.availableHeight - spacingBefore;
1665
2196
  const linesPerAvailable = Math.floor(effectiveAvailable / style.lineHeightPx);
1666
2197
  // Math display blocks carry their natural pixel height on the single
@@ -1704,7 +2235,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1704
2235
  && curCol.blocks.length > 0) {
1705
2236
  const usedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
1706
2237
  const paragraphBottom = usedHeight + totalRemainHeight;
1707
- const availableAfter = curCol.bbox.height - paragraphBottom;
2238
+ // Slack after the paragraph as the plain pass saw it: the height
2239
+ // balancing added above in this column is not room the list lost.
2240
+ const availableAfter = curCol.bbox.height - paragraphBottom + (balanceExtraInColumn.get(curCol) ?? 0);
1708
2241
  const nextListKind = nextBlock?.listKind ?? 'unordered';
1709
2242
  const nextListMarginDim = nextListKind === 'ordered'
1710
2243
  ? resolved.orderedLists.marginTop
@@ -1784,6 +2317,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1784
2317
  // headingRunCount === curCol.blocks.length: fall through to place.
1785
2318
  }
1786
2319
  }
2320
+ // A block opening a column normally stays (no column can take it any
2321
+ // better), except in a column at least a line shorter than the content
2322
+ // area — the band under a page-span box, or a column cut by a float:
2323
+ // a heading that fits there alone, or a block that does not fit there
2324
+ // but would fit a full column, opens the next column instead (the move
2325
+ // count is bounded besides). A column cut by a band cap is short on
2326
+ // purpose — its content is meant to end at the cut.
2327
+ const shortColumn = shortColumnMoves < 4
2328
+ && !uncappedBottoms.has(curCol)
2329
+ && curCol.bbox.height < contentArea.height - baselineGrid;
1787
2330
  // Block fits in current column
1788
2331
  if (effectiveRemainHeight <= effectiveAvailable) {
1789
2332
  // Heading keep-with-next: never leave a heading as the last block of a
@@ -1800,7 +2343,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1800
2343
  && resolved.headings.keepWithNext
1801
2344
  && !nextIsHeading
1802
2345
  && nextBlock !== null
1803
- && curCol.blocks.length > 0) {
2346
+ && (curCol.blocks.length > 0 || shortColumn)) {
1804
2347
  const wouldUsedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
1805
2348
  const naturalBottom = wouldUsedHeight + effectiveRemainHeight + style.marginBottomPx;
1806
2349
  const snappedBottom = shouldSnapToGrid
@@ -1812,6 +2355,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1812
2355
  : 1;
1813
2356
  const minSpaceAfter = minLinesNeeded * bodyStyle.lineHeightPx;
1814
2357
  if (remainAfterHeading < minSpaceAfter) {
2358
+ if (curCol.blocks.length === 0)
2359
+ shortColumnMoves++;
1815
2360
  // Roll back any immediately-preceding heading blocks in this
1816
2361
  // column so they travel with this one.
1817
2362
  const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
@@ -1975,8 +2520,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1975
2520
  }
1976
2521
  // choice.splitAt === 0: fall through to push whole paragraph to next column
1977
2522
  }
1978
- // Cannot split — advance to next column if current has content
1979
- if (curCol.blocks.length > 0) {
2523
+ // Cannot split — advance to next column if current has content (or
2524
+ // the column is a short band that cannot hold the block at all).
2525
+ if (curCol.blocks.length > 0 || (shortColumn && effectiveRemainHeight <= contentArea.height)) {
2526
+ if (curCol.blocks.length === 0)
2527
+ shortColumnMoves++;
1980
2528
  // Heading keep-with-next (no-fit variant): when a heading can't fit
1981
2529
  // in the current column and the column's tail is a run of headings,
1982
2530
  // pull those headings along so they don't remain stranded as orphans
@@ -2099,17 +2647,67 @@ export function buildDocument(content, config, cache, options) {
2099
2647
  let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
2100
2648
  let applied = { lines: new Map(), loose: new Map() };
2101
2649
  const failedLoose = new Set();
2650
+ const failedLines = new Set();
2102
2651
  let converged = bestScore === 0;
2652
+ /**
2653
+ * A rejected pass moved content across a column break somewhere (a
2654
+ * split paragraph whose head no longer fits, a float that lost its slot,
2655
+ * a lead-in that left with its list…): every page after that point is
2656
+ * re-flowed, gaps open elsewhere and a span cap may miss its band. The
2657
+ * levers are meant to be local, so contain the damage: find the first
2658
+ * column whose content changed and blacklist the levers this pass newly
2659
+ * applied there (failing that, on its page; failing that, everywhere), so
2660
+ * the next proposal keeps the working levers before it and tries again
2661
+ * without the one that cascaded. Returns whether anything was blacklisted.
2662
+ */
2663
+ const containCascade = (next, proposal) => {
2664
+ const div = firstDivergentColumn(best.doc, next.doc);
2665
+ if (!div)
2666
+ return false;
2667
+ const newLines = [...proposal.lines].filter(([k, n]) => n > (applied.lines.get(k) ?? 0)).map(([k]) => k);
2668
+ const newLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
2669
+ if (newLines.length === 0 && newLoose.length === 0)
2670
+ return false;
2671
+ const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
2672
+ const blacklist = (cands) => {
2673
+ let hit = false;
2674
+ for (const k of newLines)
2675
+ if (!cands || cands.has(k)) {
2676
+ failedLines.add(k);
2677
+ hit = true;
2678
+ }
2679
+ for (const k of newLoose)
2680
+ if (!cands || cands.has(k)) {
2681
+ failedLoose.add(k);
2682
+ hit = true;
2683
+ }
2684
+ return hit;
2685
+ };
2686
+ const inColumn = gaps
2687
+ .filter((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)
2688
+ .flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
2689
+ if (blacklist(new Set(inColumn)))
2690
+ return true;
2691
+ const onPage = gaps
2692
+ .filter((g) => g.pageIndex === div.pageIndex)
2693
+ .flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
2694
+ if (blacklist(new Set(onPage)))
2695
+ return true;
2696
+ return blacklist(null);
2697
+ };
2103
2698
  const balance = () => {
2104
2699
  while (!converged && passCount < MAX_BALANCING_PASSES) {
2105
2700
  const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
2106
2701
  maxLinesPerHeading: balancing.maxLinesPerHeading,
2107
2702
  stretchAfterLists: balancing.stretchAfterLists,
2108
2703
  maxLinesAfterList: balancing.maxLinesAfterList,
2704
+ stretchAfterFloats: balancing.stretchAfterFloats,
2705
+ maxLinesAfterFloat: balancing.maxLinesAfterFloat,
2109
2706
  looseParagraphs: balancing.looseParagraphs,
2110
2707
  maxLooseParagraphs: balancing.maxLooseParagraphs,
2111
2708
  optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
2112
2709
  failedLoose,
2710
+ failedLines,
2113
2711
  });
2114
2712
  if (!proposal.changed) {
2115
2713
  // No stretch point can absorb the remaining gaps — stable.
@@ -2153,11 +2751,16 @@ export function buildDocument(content, config, cache, options) {
2153
2751
  converged = score === 0;
2154
2752
  }
2155
2753
  else {
2156
- // Plateau or regression. Retry when a loose candidate was just
2157
- // blacklisted (the proposer falls through to the next one), or when
2158
- // the new loose paragraphs gained their lines yet the layout did not
2159
- // improve (the gain landed elsewhere — drop them too). A pure spacing
2160
- // plateau means we're done: keep the best layout found so far.
2754
+ // Plateau or regression. First contain a cascade: a lever that
2755
+ // moved content across a column break is blacklisted and the loop
2756
+ // retries without it. Otherwise retry when a loose candidate was
2757
+ // just blacklisted (the proposer falls through to the next one), or
2758
+ // when the new loose paragraphs gained their lines yet the layout
2759
+ // did not improve (the gain landed elsewhere — drop them too). A
2760
+ // pure spacing plateau means we're done: keep the best layout found
2761
+ // so far.
2762
+ if (containCascade(next, proposal))
2763
+ continue;
2161
2764
  if (looseFailed.length > 0 || looseWon.length > 0) {
2162
2765
  for (const k of looseWon)
2163
2766
  failedLoose.add(k);