postext 0.3.27 → 0.3.29

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 (90) 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__/defaults/calloutStyles.test.js +4 -2
  4. package/dist/__tests__/defaults/calloutStyles.test.js.map +1 -1
  5. package/dist/__tests__/defaults/headingsBalancing.test.d.ts +2 -0
  6. package/dist/__tests__/defaults/headingsBalancing.test.d.ts.map +1 -0
  7. package/dist/__tests__/defaults/headingsBalancing.test.js +17 -0
  8. package/dist/__tests__/defaults/headingsBalancing.test.js.map +1 -0
  9. package/dist/__tests__/parts.test.js +82 -1
  10. package/dist/__tests__/parts.test.js.map +1 -1
  11. package/dist/__tests__/pipeline/calloutSplit.test.d.ts +2 -0
  12. package/dist/__tests__/pipeline/calloutSplit.test.d.ts.map +1 -0
  13. package/dist/__tests__/pipeline/calloutSplit.test.js +156 -0
  14. package/dist/__tests__/pipeline/calloutSplit.test.js.map +1 -0
  15. package/dist/__tests__/pipeline/colonBalancing.test.d.ts +2 -0
  16. package/dist/__tests__/pipeline/colonBalancing.test.d.ts.map +1 -0
  17. package/dist/__tests__/pipeline/colonBalancing.test.js +124 -0
  18. package/dist/__tests__/pipeline/colonBalancing.test.js.map +1 -0
  19. package/dist/__tests__/pipeline/floatFirstSlot.test.js +33 -0
  20. package/dist/__tests__/pipeline/floatFirstSlot.test.js.map +1 -1
  21. package/dist/__tests__/pipeline/headingShortBand.test.d.ts +2 -0
  22. package/dist/__tests__/pipeline/headingShortBand.test.d.ts.map +1 -0
  23. package/dist/__tests__/pipeline/headingShortBand.test.js +89 -0
  24. package/dist/__tests__/pipeline/headingShortBand.test.js.map +1 -0
  25. package/dist/__tests__/pipeline/spanBlocks.test.js +4 -2
  26. package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
  27. package/dist/__tests__/pipeline/spanLeaveLevel.test.d.ts +2 -0
  28. package/dist/__tests__/pipeline/spanLeaveLevel.test.d.ts.map +1 -0
  29. package/dist/__tests__/pipeline/spanLeaveLevel.test.js +103 -0
  30. package/dist/__tests__/pipeline/spanLeaveLevel.test.js.map +1 -0
  31. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  32. package/dist/canvas-backend/headerFooter.js +2 -6
  33. package/dist/canvas-backend/headerFooter.js.map +1 -1
  34. package/dist/canvas-backend/index.d.ts +1 -1
  35. package/dist/canvas-backend/index.d.ts.map +1 -1
  36. package/dist/canvas-backend/renderResourceBlock.d.ts +15 -1
  37. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
  38. package/dist/canvas-backend/renderResourceBlock.js +104 -8
  39. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  40. package/dist/defaults/calloutStyles.d.ts.map +1 -1
  41. package/dist/defaults/calloutStyles.js +3 -3
  42. package/dist/defaults/calloutStyles.js.map +1 -1
  43. package/dist/defaults/headings.d.ts +3 -0
  44. package/dist/defaults/headings.d.ts.map +1 -1
  45. package/dist/defaults/headings.js +21 -0
  46. package/dist/defaults/headings.js.map +1 -1
  47. package/dist/design/layout.d.ts +6 -1
  48. package/dist/design/layout.d.ts.map +1 -1
  49. package/dist/design/layout.js +38 -2
  50. package/dist/design/layout.js.map +1 -1
  51. package/dist/design/placeholders.d.ts +4 -0
  52. package/dist/design/placeholders.d.ts.map +1 -1
  53. package/dist/design/placeholders.js.map +1 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/pipeline/build.d.ts.map +1 -1
  57. package/dist/pipeline/build.js +531 -168
  58. package/dist/pipeline/build.js.map +1 -1
  59. package/dist/pipeline/calloutLayout.d.ts +9 -3
  60. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  61. package/dist/pipeline/calloutLayout.js +7 -5
  62. package/dist/pipeline/calloutLayout.js.map +1 -1
  63. package/dist/pipeline/columnBalancing.d.ts +30 -5
  64. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  65. package/dist/pipeline/columnBalancing.js +70 -4
  66. package/dist/pipeline/columnBalancing.js.map +1 -1
  67. package/dist/pipeline/continuation.d.ts.map +1 -1
  68. package/dist/pipeline/continuation.js +14 -2
  69. package/dist/pipeline/continuation.js.map +1 -1
  70. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  71. package/dist/pipeline/headerFooter.js +7 -1
  72. package/dist/pipeline/headerFooter.js.map +1 -1
  73. package/dist/pipeline/parts.d.ts +9 -0
  74. package/dist/pipeline/parts.d.ts.map +1 -1
  75. package/dist/pipeline/parts.js +15 -0
  76. package/dist/pipeline/parts.js.map +1 -1
  77. package/dist/pipeline/placeholders.d.ts +12 -9
  78. package/dist/pipeline/placeholders.d.ts.map +1 -1
  79. package/dist/pipeline/placeholders.js +13 -12
  80. package/dist/pipeline/placeholders.js.map +1 -1
  81. package/dist/pipeline/placement.d.ts +1 -0
  82. package/dist/pipeline/placement.d.ts.map +1 -1
  83. package/dist/pipeline/placement.js +1 -0
  84. package/dist/pipeline/placement.js.map +1 -1
  85. package/dist/types.d.ts +38 -1
  86. package/dist/types.d.ts.map +1 -1
  87. package/dist/vdt.d.ts +14 -1
  88. package/dist/vdt.d.ts.map +1 -1
  89. package/dist/vdt.js.map +1 -1
  90. package/package.json +1 -1
@@ -11,7 +11,7 @@ import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
11
11
  import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
12
12
  import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, pageIsOccupied, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
13
13
  import { chooseParagraphSplit } from './orphanWidow';
14
- import { applyStyleAttrs, computePageMetrics, nextNonMarkerBlock, prevNonMarkerBlock, rollbackTrailingBlocks, } from './buildHelpers';
14
+ import { applyStyleAttrs, computePageMetrics, isMarkerBlock, nextNonMarkerBlock, prevNonMarkerBlock, rollbackTrailingBlocks, } from './buildHelpers';
15
15
  import { measureContentBlock } from './measureContentBlock';
16
16
  import { planParagraphContainers } from './paragraphContainers';
17
17
  import { planParts, derivePartMeasureContext } from './parts';
@@ -22,7 +22,7 @@ import { enumerateCurrentPageSlots, measureFloatBand, columnHasFloatBand, fitsSt
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 } 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 {
@@ -115,6 +115,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
115
115
  doc.pageIndexOffset = pageIndexOffset;
116
116
  if (continuation?.headings && continuation.headings.h1 > 0)
117
117
  doc.chapterOrdinalOffset = continuation.headings.h1;
118
+ // The part the preceding chapters left open: running heads and palette
119
+ // overrides apply from the first page until this document opens its own.
120
+ if (continuation?.part)
121
+ doc.partStart = continuation.part;
118
122
  const pageMetrics = computePageMetrics(resolved);
119
123
  const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = pageMetrics;
120
124
  // Page/bleed frames for design elements anchored to `'page'` / `'bleed'`.
@@ -299,6 +303,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
299
303
  * sends single-column floats to the least reserved column). */
300
304
  const floatReserved = new Map();
301
305
  const reservedOf = (col) => floatReserved.get(col) ?? { top: 0, bottom: 0 };
306
+ /** Content index of the block that first referenced the latest float
307
+ * reserved at the head of each column. */
308
+ const topFloatRefOf = new Map();
302
309
  /** Kind of cap the column is under (`undefined` when uncapped). A cap that
303
310
  * cannot be attributed to the active band is treated as a span cap — the
304
311
  * conservative reading, which keeps the column's bottom off the slot list. */
@@ -362,6 +369,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
362
369
  col.bbox.height = Math.max(0, col.bbox.height - need);
363
370
  col.availableHeight = Math.max(0, col.availableHeight - need);
364
371
  r.top += need;
372
+ topFloatRefOf.set(col, Math.max(topFloatRefOf.get(col) ?? -1, f.firstBlockIdx));
365
373
  }
366
374
  else {
367
375
  const capped = uncappedBottoms.get(col);
@@ -436,14 +444,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
436
444
  * cursor (bottom of the referencing column, top / bottom of the next
437
445
  * empty columns; the band bottom for page-span floats). Runs before each
438
446
  * block is placed, so a float lands in the first gap after its reference. */
439
- const tryPlacePendingFloatsOnCurrentPage = () => {
447
+ const tryPlacePendingFloatsOnCurrentPage = (preferTop = false) => {
440
448
  if (pendingFloats.length === 0)
441
449
  return;
442
450
  const page = doc.pages[cursor.pageIndex];
443
451
  for (let i = 0; i < pendingFloats.length;) {
444
452
  const f = pendingFloats[i];
445
453
  let r = 'defer';
446
- for (const slot of enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf)) {
454
+ let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf);
455
+ // A page-span box comes next: the head of an empty column keeps the
456
+ // band cuttable under the float (the box then sits below both the
457
+ // text and the figure), where the referencing column's foot would
458
+ // wall the box off the page.
459
+ if (preferTop)
460
+ slots = [...slots.filter((s) => s.position === 'top'), ...slots.filter((s) => s.position !== 'top')];
461
+ for (const slot of slots) {
447
462
  r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict');
448
463
  if (r !== 'defer')
449
464
  break;
@@ -457,6 +472,20 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
457
472
  /** Reserve floats on each freshly opened content page. Passed only to the
458
473
  * content-flow column advances — parity / force-blank pages never get it. */
459
474
  const onNewPage = (page) => flushFloatsIntoPage(page);
475
+ /** Whether content block `idx` opens a callout that will span the page
476
+ * in the current (multi-column) band — the floats placed right before
477
+ * it prefer the head of an empty column. */
478
+ const spanBoxAt = (idx) => {
479
+ const b = contentBlocks[idx];
480
+ if (!b || b.type !== 'containerStart' || b.containerName !== 'callout')
481
+ return false;
482
+ const plan = calloutPlan.get(idx);
483
+ const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
484
+ if (!style || style.span !== 'page' || style.placement === 'fixed')
485
+ return false;
486
+ const page = doc.pages[cursor.pageIndex];
487
+ return bandColumns(page, currentBand(page, cursor)).length > 1;
488
+ };
460
489
  /** Chapter barrier: place every pending float before the boundary — in
461
490
  * the current page's free slots, then on fresh pages opened ahead of it
462
491
  * (each force-places at least one float). The cursor is left on the last
@@ -527,6 +556,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
527
556
  let activeCap = null;
528
557
  /** True bottoms of capped columns (restored when the span block cuts). */
529
558
  const uncappedBottoms = new Map();
559
+ /** Column balancing: height (px) the levers added inside each column so
560
+ * far — extra grid lines above headings / list ends and lines gained by
561
+ * loose paragraphs. Balancing only ever fills a column's bottom gap, so
562
+ * a placement rule that needs slack *after* a block (keep-colon-with-
563
+ * list) must see the column as it was before the levers filled it:
564
+ * otherwise the block that closed the column in the plain pass moves to
565
+ * the next column, the flow shifts on every later page, and the pass is
566
+ * discarded as a regression. */
567
+ const balanceExtraInColumn = new Map();
568
+ const addBalanceExtra = (col, px) => {
569
+ if (px > 0)
570
+ balanceExtraInColumn.set(col, (balanceExtraInColumn.get(col) ?? 0) + px);
571
+ };
530
572
  const bandCapProposals = new Map();
531
573
  const spanPlacedInBand = new Set();
532
574
  const bandCapsApplied = new Set();
@@ -663,22 +705,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
663
705
  * keep-with-next rollbacks may pull along (a callout is one unbreakable
664
706
  * unit; its children never leave it). */
665
707
  const isFreeHeading = (b) => b.type === 'heading' && b.containerId === undefined;
666
- /** Shared tail of callout placement: stamp the frame's source range,
667
- * convert the laid-out box to absolute coordinates at the frame's placed
668
- * origin, and push frame + children — in that order — to `doc.blocks`
669
- * and to the column the frame landed in. */
670
- /** Stamp a callout frame's content index and source range (the whole
671
- * fence, opening to closing marker). */
672
- const stampCalloutSource = (frame, startIdx, plan) => {
708
+ /** Stamp a callout frame's content index and source range: the whole
709
+ * fence (opening to closing marker) for an unsplit box; for a fragment
710
+ * of a split box, from the fence start (first fragment) or the first
711
+ * child it holds, to the fence end (last fragment) or the last child. */
712
+ const stampCalloutSource = (frame, startIdx, plan, range) => {
673
713
  const startBlock = contentBlocks[startIdx];
674
714
  const endBlock = contentBlocks[plan.endIdx];
675
715
  frame.contentIndex = startIdx;
676
- frame.sourceStart = startBlock.sourceStart + bodyOffset;
677
- frame.sourceEnd = endBlock.sourceEnd + bodyOffset;
716
+ const first = range && range.firstChildIdx > startIdx + 1 ? contentBlocks[range.firstChildIdx] : startBlock;
717
+ const last = range && range.lastChildIdx < plan.endIdx - 1 ? contentBlocks[range.lastChildIdx] : endBlock;
718
+ frame.sourceStart = first.sourceStart + bodyOffset;
719
+ frame.sourceEnd = last.sourceEnd + bodyOffset;
678
720
  };
679
- const commitCallout = (result, startIdx, plan, col) => {
721
+ /** Shared tail of callout placement: stamp the frame's source range,
722
+ * convert the laid-out box to absolute coordinates at the frame's placed
723
+ * origin, and push frame + children — in that order — to `doc.blocks`
724
+ * and to the column the frame landed in. */
725
+ const commitCallout = (result, startIdx, plan, col, range) => {
680
726
  const frame = result.frame;
681
- stampCalloutSource(frame, startIdx, plan);
727
+ stampCalloutSource(frame, startIdx, plan, range);
682
728
  offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
683
729
  doc.blocks.push(frame);
684
730
  for (const child of result.children) {
@@ -688,6 +734,77 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
688
734
  doc.blocks.push(child);
689
735
  }
690
736
  };
737
+ const makeCalloutLayouter = (startIdx, plan, style) => {
738
+ const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
739
+ const realAt = [];
740
+ children.forEach((c, k) => {
741
+ if (c.type !== 'directive' && !isMarkerBlock(c))
742
+ realAt.push(k);
743
+ });
744
+ const layoutRange = (from, to, width, frameId, continuation) => {
745
+ let n = 0;
746
+ return layoutCallout({
747
+ style,
748
+ attrs: plan.attrs,
749
+ continuation,
750
+ children: children.slice(from, to),
751
+ childStartIdx: startIdx + 1 + from,
752
+ width,
753
+ ctx: measureCtx,
754
+ resolved,
755
+ containerId: plan.containerId,
756
+ frameId,
757
+ nextChildId: () => `${frameId}-c${n++}`,
758
+ paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
759
+ });
760
+ };
761
+ return { children, childBase: startIdx + 1, realAt, layoutRange };
762
+ };
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);
774
+ const lastChild = full.children[full.children.length - 1];
775
+ if (!lastChild)
776
+ return null;
777
+ 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);
784
+ }
785
+ }
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);
794
+ if (result.totalHeight <= roomPx + 0.01)
795
+ return { to, result };
796
+ }
797
+ return null;
798
+ };
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 });
802
+ const markFragment = (result, part, continued) => {
803
+ if (result.frame.callout) {
804
+ result.frame.callout.part = part;
805
+ result.frame.callout.continued = continued;
806
+ }
807
+ };
691
808
  /**
692
809
  * Place a `span: 'page'` `:::callout` in a multi-column layout as a span
693
810
  * block (stage 1): the box is laid out at the page's content width and
@@ -716,6 +833,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
716
833
  * overflowed instead (the box arrives elsewhere), the driver grows or
717
834
  * drops the cap.
718
835
  *
836
+ * Leaving level (`headings.balancing.beforeSpan`): a box that does not
837
+ * fit even after a level cut proposes a TRAILING cap instead — the band
838
+ * it leaves is cut level, the way a chapter's closing band is, and the
839
+ * page is marked as a forced break in that pass so balancing does not
840
+ * stretch its last column back to the page bottom. The cap is resolved
841
+ * after balancing (`resolveTrailingCaps`); when the box reaches the
842
+ * capped band it counts as delivered whether it fits there or moves on,
843
+ * and the cut columns stay cut (the polish round fills a column ending a
844
+ * line under the cap).
845
+ *
846
+ * Splitting (`keepTogether: false`): a box that does not fit a level (or
847
+ * capped, or nearly level — within one grid line) band breaks between
848
+ * its children: the longest fragment that fits closes the page flush
849
+ * with the band bottom, the rest opens the next page in a box without
850
+ * the title or icon, and splits again if it is still too tall. Such a box that
851
+ * fits whole only flush with the page bottom (no text below) is placed
852
+ * whole. When the band is uneven, the cap that levels it (span cap when
853
+ * the whole box fits after the cut, trailing cap otherwise) comes first;
854
+ * the fragment is cut in the capped pass.
855
+ *
719
856
  * Geometry stays on the baseline grid: the cut line is the band's used
720
857
  * bottom snapped UP to the next grid line (anchored at the content-area
721
858
  * top, like every column start), and the span column's height is the
@@ -724,69 +861,197 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
724
861
  * stay in the outer page bands: the band inherits the float-reduced top
725
862
  * / bottom of the page's columns, so a span column never overlaps a float.
726
863
  */
727
- const placeCalloutSpan = (startIdx, plan, layoutAt) => {
728
- let page = doc.pages[cursor.pageIndex];
729
- let result = layoutAt(page.contentArea.width);
864
+ const placeCalloutSpan = (startIdx, plan, style, L, firstFrameId) => {
865
+ const splittable = !style.keepTogether;
866
+ const balancingCfg = resolved.headings.balancing;
867
+ const levelBefore = balancingCfg.enabled && balancingCfg.beforeSpan;
730
868
  const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
731
869
  const minRoomPx = minLines * bodyStyle.lineHeightPx;
732
- /** Where the box would cut the current band, and whether it fits (room
733
- * is measured against the columns' TRUE bottoms — a capped band keeps
734
- * its slack below the cap). Null when the band is not level (or the
735
- * cursor sits on a span column with no band below it) and
736
- * `requireLevel` is set. */
737
- const measureBand = (requireLevel) => {
738
- const cols = bandColumns(page, currentBand(page, cursor));
739
- if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
740
- return null;
741
- const usedBottom = bandUsedBottom(cols);
742
- const cutY = page.contentArea.y
743
- + Math.ceil((usedBottom - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
744
- const bandHasContent = cols.some((c) => c.blocks.length > 0);
745
- const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
746
- const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
747
- const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
748
- const room = cutY + need + minRoomPx <= bandBottom + 0.01;
749
- return { cols, cutY, need, spacing, room };
870
+ const gridUp = (page, v) => page.contentArea.y + Math.ceil((v - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
871
+ const needFor = (spacing, height, margin) => Math.ceil((spacing + height + margin - 0.01) / baselineGrid) * baselineGrid;
872
+ const nearlyLevel = (cols) => {
873
+ const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
874
+ return Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5;
750
875
  };
751
- // Is this band capped for this very box? Then it cuts at the band's
752
- // used bottom (at most the cap) even when the last column is short.
753
- const cap = bandCaps?.get(startIdx);
754
- const capActive = cap !== undefined
755
- && activeCap !== null
756
- && activeCap.spanIndex === startIdx
757
- && activeCap.pageIndex === page.index
758
- && activeCap.band === currentBand(page, cursor);
759
- let fit = measureBand(!capActive);
760
- if (fit?.room && capActive) {
761
- uncapBand(fit.cols, uncappedBottoms);
762
- spanPlacedInBand.add(startIdx);
763
- }
764
- if (!fit && cap === undefined && bandStart && registeredBand
765
- && registeredBand.pageIndex === page.index
766
- && registeredBand.band === currentBand(page, cursor)) {
767
- // Uneven band, no cap yet: propose one when a level cut would leave
768
- // room for the box plus the widow minimum of body lines below it.
769
- const cols = bandColumns(page, currentBand(page, cursor));
770
- const lines = bandCapLines(cols, baselineGrid);
771
- const capBottom = bandTop(cols) + lines * baselineGrid;
772
- const spacing = Math.max(pendingSpacing, result.marginTopPx);
773
- const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
774
- const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
775
- if (capBottom + need + minRoomPx <= bandBottom + 0.01) {
776
- bandCapProposals.set(startIdx, {
777
- kind: 'span',
778
- startContentIndex: bandStart.contentIndex,
779
- startPart: bandStart.part,
780
- lines,
781
- retries: 0,
782
- });
876
+ /** Level for the box: the text columns end level, and a column holding
877
+ * only a float band (a figure at its head, no text yet) counts as level
878
+ * when a band cap could not level it either — the figure was first
879
+ * referenced by the very block before the box, so a cut that spills
880
+ * that block into the figure's column leaves the figure no slot after
881
+ * 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. */
885
+ const lastBlockBefore = () => {
886
+ let j = startIdx - 1;
887
+ while (j >= 0 && isMarkerBlock(contentBlocks[j]))
888
+ j--;
889
+ return j;
890
+ };
891
+ const levelForBox = (cols) => {
892
+ if (isBandLevel(cols))
893
+ return true;
894
+ const textCols = cols.filter((c) => c.blocks.length > 0);
895
+ const floatOnly = cols.filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0);
896
+ if (textCols.length === 0 || textCols.length + floatOnly.length !== cols.length)
897
+ return false;
898
+ if (!isBandLevel(textCols))
899
+ return false;
900
+ const textBottom = bandUsedBottom(textCols);
901
+ const before = lastBlockBefore();
902
+ return floatOnly.every((c) => c.bbox.y <= textBottom + baselineGrid + 0.5 && topFloatRefOf.get(c) === before);
903
+ };
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;
907
+ let part = 0;
908
+ let frameId = firstFrameId;
909
+ /** The box already moved to a fresh page (or sits on an empty one):
910
+ * whatever does not fit there is force-placed and overflows. */
911
+ let forceHere = false;
912
+ for (;;) {
913
+ let page = doc.pages[cursor.pageIndex];
914
+ const continuation = part > 0;
915
+ const layoutAt = (width) => L.layoutRange(from, L.children.length, width, frameId, continuation);
916
+ const result = layoutAt(page.contentArea.width);
917
+ /** Where the box would cut the current band, and whether it fits (room
918
+ * is measured against the columns' TRUE bottoms — a capped band keeps
919
+ * its slack below the cap). Null when the band is not level (or the
920
+ * cursor sits on a span column with no band below it) and
921
+ * `requireLevel` is set. */
922
+ const measureBand = (requireLevel) => {
923
+ const cols = bandColumns(page, currentBand(page, cursor));
924
+ if (cols.length === 0 || (requireLevel && !levelForBox(cols)))
925
+ return null;
926
+ const cutY = gridUp(page, bandUsedBottom(cols));
927
+ const bandHasContent = cols.some((c) => c.blocks.length > 0);
928
+ const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
929
+ const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
930
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
931
+ const room = cutY + need + minRoomPx <= bandBottom + 0.01;
932
+ const needFlush = needFor(spacing, result.totalHeight, 0);
933
+ const roomFlush = cutY + needFlush <= bandBottom + 0.01;
934
+ return { cols, cutY, spacing, need, room, needFlush, roomFlush, roomPx: bandBottom - cutY };
935
+ };
936
+ // Is this band capped for this very box? Then it cuts at the band's
937
+ // used bottom (at most the cap) even when the last column is short.
938
+ const cap = part === 0 ? bandCaps?.get(startIdx) : undefined;
939
+ const capActive = cap !== undefined
940
+ && activeCap !== null
941
+ && activeCap.spanIndex === startIdx
942
+ && activeCap.pageIndex === page.index
943
+ && activeCap.band === currentBand(page, cursor);
944
+ const fit = forceHere ? (measureBand(true) ?? measureBand(false)) : measureBand(!capActive);
945
+ let action = null;
946
+ if (fit) {
947
+ if (fit.room)
948
+ action = { kind: 'whole', fit, need: fit.need, result };
949
+ else if (splittable && fit.roomFlush)
950
+ action = { kind: 'whole', fit, need: fit.needFlush, result };
951
+ }
952
+ if (!action && splittable) {
953
+ // A band uneven by no more than a grid line still takes a fragment
954
+ // cut at its used bottom: balancing fills the line the short column
955
+ // is left under the cut.
956
+ let band = fit;
957
+ if (!band) {
958
+ const cols = bandColumns(page, currentBand(page, cursor));
959
+ if (cols.length > 0 && nearlyLevel(cols))
960
+ band = measureBand(false);
961
+ }
962
+ if (band) {
963
+ const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation);
964
+ if (fragment) {
965
+ action = { kind: 'split', fit: band, need: needFor(band.spacing, fragment.result.totalHeight, 0), fragment };
966
+ }
967
+ }
968
+ }
969
+ if (!action && forceHere && fit)
970
+ action = { kind: 'whole', fit, need: fit.need, result };
971
+ if (action) {
972
+ if (capActive) {
973
+ uncapBand(action.fit.cols, uncappedBottoms);
974
+ spanPlacedInBand.add(startIdx);
975
+ }
976
+ const placed = action.kind === 'whole' ? action.result : action.fragment.result;
977
+ const to = action.kind === 'whole' ? L.children.length : action.fragment.to;
978
+ if (part > 0 || action.kind === 'split')
979
+ markFragment(placed, part, action.kind === 'split');
980
+ const spanCol = closeBandAndInsertSpan(page, action.fit.cols, action.fit.cutY, placed.frame, action.need, cursor, action.fit.spacing, placed.totalHeight);
981
+ commitCallout(placed, startIdx, plan, spanCol, part > 0 || action.kind === 'split' ? fragmentRange(L, from, to) : undefined);
982
+ // Floats first-referenced inside the box enqueue once its head is
983
+ // committed, in reading order (same as the inline path).
984
+ if (part === 0)
985
+ for (let i = startIdx + 1; i <= plan.endIdx; i++)
986
+ enqueueFloatsFor(i);
987
+ // The new band starts on the grid right below the span column; nothing
988
+ // to snap — `need` already bakes in `marginBottom`.
989
+ pendingSpacing = 0;
990
+ if (action.kind === 'whole')
991
+ return true;
992
+ // The rest opens the next page.
993
+ from = to;
994
+ part++;
995
+ frameId = `block-${blockIdCounter++}`;
996
+ const startPageIndex = cursor.pageIndex;
997
+ do {
998
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
999
+ } while (cursor.pageIndex === startPageIndex);
1000
+ forceHere = true;
1001
+ continue;
1002
+ }
1003
+ // Nothing of the box lands in this band.
1004
+ if (part === 0) {
1005
+ let proposedSpan = false;
1006
+ if (!fit && cap === undefined && bandStart && registeredBand
1007
+ && registeredBand.pageIndex === page.index
1008
+ && registeredBand.band === currentBand(page, cursor)) {
1009
+ // Uneven band, no cap yet: propose one when a level cut would leave
1010
+ // room for the box plus the widow minimum of body lines below it
1011
+ // (or, for a splittable box, for the whole box flush with the
1012
+ // band bottom).
1013
+ const cols = bandColumns(page, currentBand(page, cursor));
1014
+ const lines = bandCapLines(cols, baselineGrid);
1015
+ const capBottom = bandTop(cols) + lines * baselineGrid;
1016
+ const spacing = Math.max(pendingSpacing, result.marginTopPx);
1017
+ const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
1018
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
1019
+ const fitsAfterCut = capBottom + need + minRoomPx <= bandBottom + 0.01
1020
+ || (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01);
1021
+ if (fitsAfterCut) {
1022
+ bandCapProposals.set(startIdx, {
1023
+ kind: 'span',
1024
+ startContentIndex: bandStart.contentIndex,
1025
+ startPart: bandStart.part,
1026
+ lines,
1027
+ retries: 0,
1028
+ });
1029
+ proposedSpan = true;
1030
+ }
1031
+ }
1032
+ if (levelBefore && !capActive && !proposedSpan) {
1033
+ // The box leaves an uncapped band: level it behind the box (a
1034
+ // trailing cap, resolved after balancing) and keep balancing from
1035
+ // stretching its last column to the page bottom meanwhile.
1036
+ proposeTrailingCap(startIdx);
1037
+ markForcedBreak();
1038
+ }
1039
+ else if (capActive && cap.kind === 'trailing') {
1040
+ // Reached inside the band cut level for it: delivered even though
1041
+ // the box moves on — the columns stay cut.
1042
+ spanPlacedInBand.add(startIdx);
1043
+ }
783
1044
  }
784
- }
785
- if (!fit || !fit.room) {
786
1045
  // Open the next page (flushing pending floats into its bands). A page
787
1046
  // holding only floats counts as occupied here — its float band is what
788
1047
  // left no room — but a truly empty page is kept: the box then simply
789
1048
  // does not fit a page and is force-placed (overflowing, like inline).
1049
+ // On a fresh page the whole box is force-placed above whenever the
1050
+ // page has a text column to cut; reaching this point there means it
1051
+ // has none — leave the box to the inline path (its head, if any, is
1052
+ // already committed: `true` keeps the rest from being placed twice).
1053
+ if (forceHere)
1054
+ return part > 0;
790
1055
  const curPage = doc.pages[cursor.pageIndex];
791
1056
  if (pageIsOccupied(curPage)) {
792
1057
  pendingSpacing = 0;
@@ -795,26 +1060,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
795
1060
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
796
1061
  } while (cursor.pageIndex === startPageIndex);
797
1062
  page = doc.pages[cursor.pageIndex];
798
- if (Math.abs(page.contentArea.width - result.width) > 0.01) {
799
- result = layoutAt(page.contentArea.width);
800
- }
801
1063
  }
802
- // A freshly opened page is level; force-place (overflow) when the box
803
- // is taller than the page.
804
- fit = measureBand(true) ?? measureBand(false);
805
- if (!fit)
1064
+ // A freshly opened (or empty) page is level; force-place (overflow)
1065
+ // when the box is taller than the page.
1066
+ if (bandColumns(page, currentBand(page, cursor)).length === 0)
806
1067
  return false; // no text column to cut — leave it to the inline path
1068
+ forceHere = true;
807
1069
  }
808
- const spanCol = closeBandAndInsertSpan(page, fit.cols, fit.cutY, result.frame, fit.need, cursor, fit.spacing, result.totalHeight);
809
- commitCallout(result, startIdx, plan, spanCol);
810
- // Floats first-referenced inside the box enqueue once it is committed,
811
- // in reading order (same as the inline path).
812
- for (let i = startIdx + 1; i <= plan.endIdx; i++)
813
- enqueueFloatsFor(i);
814
- // The new band starts on the grid right below the span column; nothing
815
- // to snap — `need` already bakes in `marginBottom`.
816
- pendingSpacing = 0;
817
- return true;
818
1070
  };
819
1071
  /** Whether the flow ends at a chapter-level boundary right after block
820
1072
  * `from` (skipping container markers): a chapter opener, a `:::part`, a
@@ -992,8 +1244,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
992
1244
  * baked into the post-box grid snap. A box that does not fit moves to the
993
1245
  * next column/page (like a resource), pulling a run of trailing headings
994
1246
  * along (keep-with-next); a box taller than an empty column is placed
995
- * anyway and overflows (the sandbox warns). Returns the content index to
996
- * rewind the main loop to when headings were rolled back, else `undefined`.
1247
+ * anyway and overflows (the sandbox warns). A splittable box
1248
+ * (`keepTogether: false`) instead leaves the longest run of its children
1249
+ * that fits in the column and continues — in a box of its own, without
1250
+ * the title or icon — at the top of the next one, splitting again if needed.
1251
+ * Returns the content index to rewind the main loop to when headings
1252
+ * were rolled back, else `undefined`.
997
1253
  *
998
1254
  * `span: 'page'` boxes in multi-column layouts take the span-block path
999
1255
  * (`placeCalloutSpan`) instead; `placement: 'top' | 'bottom'` (floating
@@ -1002,25 +1258,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1002
1258
  */
1003
1259
  const placeCalloutInline = (startIdx, plan) => {
1004
1260
  const style = pickCalloutStyle(resolved.calloutStyles, plan.attrs.type);
1005
- const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
1006
- const frameId = `block-${blockIdCounter++}`;
1261
+ const firstFrameId = `block-${blockIdCounter++}`;
1007
1262
  const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
1008
- const layoutAt = (width) => {
1009
- let n = 0;
1010
- return layoutCallout({
1011
- style,
1012
- attrs: plan.attrs,
1013
- children,
1014
- childStartIdx: startIdx + 1,
1015
- width,
1016
- ctx: measureCtx,
1017
- resolved,
1018
- containerId: plan.containerId,
1019
- frameId,
1020
- nextChildId: () => `${frameId}-c${n++}`,
1021
- paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
1022
- });
1023
- };
1263
+ const L = makeCalloutLayouter(startIdx, plan, style);
1264
+ const layoutAt = (width) => L.layoutRange(0, L.children.length, width, firstFrameId, false);
1024
1265
  // Fixed boxes leave the flow entirely.
1025
1266
  if (placement === 'fixed') {
1026
1267
  placeCalloutFixed(startIdx, plan, style, layoutAt);
@@ -1033,61 +1274,88 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1033
1274
  if (span === 'page'
1034
1275
  && placement === 'here'
1035
1276
  && bandColumns(page, currentBand(page, cursor)).length > 1
1036
- && placeCalloutSpan(startIdx, plan, layoutAt)) {
1277
+ && placeCalloutSpan(startIdx, plan, style, L, firstFrameId)) {
1037
1278
  return undefined;
1038
1279
  }
1039
1280
  }
1040
- let curCol = currentColumn(doc, cursor);
1041
- let result = layoutAt(curCol.bbox.width);
1042
- if (curCol.blocks.length > 0) {
1043
- const spacingBefore = Math.max(pendingSpacing, result.marginTopPx);
1044
- if (result.totalHeight > curCol.availableHeight - spacingBefore) {
1045
- // Keep-with-next: a run of headings at the column's tail travels
1046
- // with the box. Skipped when the column holds nothing else (rolling
1047
- // back again would loop) — the headings stay, orphaned.
1048
- let run = 0;
1049
- for (let j = curCol.blocks.length - 1; j >= 0; j--) {
1050
- if (isFreeHeading(curCol.blocks[j]))
1051
- run++;
1052
- else
1053
- break;
1054
- }
1055
- pendingSpacing = 0;
1056
- if (resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
1057
- const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
1281
+ const splittable = !style.keepTogether;
1282
+ let from = 0;
1283
+ let part = 0;
1284
+ let frameId = firstFrameId;
1285
+ for (;;) {
1286
+ let curCol = currentColumn(doc, cursor);
1287
+ 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);
1290
+ const roomPx = curCol.availableHeight - spacing;
1291
+ let fragment = null;
1292
+ if (result.totalHeight > roomPx + 0.01) {
1293
+ // The (rest of the) box does not fit the column: a splittable box
1294
+ // leaves the head that fits here…
1295
+ if (splittable)
1296
+ fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation);
1297
+ if (!fragment && curCol.blocks.length > 0) {
1298
+ // …otherwise it moves whole to the next column. Keep-with-next: a
1299
+ // run of headings at the column's tail travels with the box.
1300
+ // Skipped when the column holds nothing else (rolling back again
1301
+ // would loop) — the headings stay, orphaned. Only the head of a
1302
+ // box can roll headings back: a continuation always lands in a
1303
+ // fresh column.
1304
+ let run = 0;
1305
+ for (let j = curCol.blocks.length - 1; j >= 0; j--) {
1306
+ if (isFreeHeading(curCol.blocks[j]))
1307
+ run++;
1308
+ else
1309
+ break;
1310
+ }
1311
+ pendingSpacing = 0;
1312
+ if (part === 0 && resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
1313
+ const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
1314
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1315
+ return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
1316
+ }
1058
1317
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1059
- return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
1060
- }
1061
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1062
- curCol = currentColumn(doc, cursor);
1063
- // Columns of different widths (oneAndHalf): re-lay out for the new one.
1064
- if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
1065
- result = layoutAt(curCol.bbox.width);
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
+ }
1066
1323
  }
1324
+ // An empty column that is still too short: placed anyway, overflowing.
1067
1325
  }
1326
+ // Floats first-referenced inside the box still enqueue in reading order
1327
+ // (only once the box is committed, so a keep-with-next replay does not
1328
+ // enqueue them twice).
1329
+ if (part === 0)
1330
+ for (let i = startIdx + 1; i <= plan.endIdx; i++)
1331
+ enqueueFloatsFor(i);
1332
+ const placed = fragment ? fragment.result : result;
1333
+ const to = fragment ? fragment.to : L.children.length;
1334
+ if (part > 0 || fragment)
1335
+ markFragment(placed, part, fragment !== null);
1336
+ const spacingBefore = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx);
1337
+ enterBand(startIdx, 0);
1338
+ placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
1339
+ enterBand(startIdx, 0);
1340
+ curCol = currentColumn(doc, cursor);
1341
+ commitCallout(placed, startIdx, plan, curCol, part > 0 || fragment ? fragmentRange(L, from, to) : undefined);
1342
+ // Snap the flow after the box to the baseline grid, baking in at least
1343
+ // `marginBottom` (grid wins, margin is a minimum — the resource rule).
1344
+ {
1345
+ const usedHeight = curCol.bbox.height - curCol.availableHeight;
1346
+ const naturalBottom = usedHeight + placed.marginBottomPx;
1347
+ const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
1348
+ curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
1349
+ }
1350
+ pendingSpacing = 0;
1351
+ if (!fragment)
1352
+ return undefined;
1353
+ // The rest continues at the top of the next column.
1354
+ from = to;
1355
+ part++;
1356
+ frameId = `block-${blockIdCounter++}`;
1357
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1068
1358
  }
1069
- // Floats first-referenced inside the box still enqueue in reading order
1070
- // (only once the box is committed, so a keep-with-next replay does not
1071
- // enqueue them twice).
1072
- for (let i = startIdx + 1; i <= plan.endIdx; i++)
1073
- enqueueFloatsFor(i);
1074
- const frame = result.frame;
1075
- const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
1076
- enterBand(startIdx, 0);
1077
- placeAtomicBlock(frame, result.totalHeight, spacing, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
1078
- enterBand(startIdx, 0);
1079
- curCol = currentColumn(doc, cursor);
1080
- commitCallout(result, startIdx, plan, curCol);
1081
- // Snap the flow after the box to the baseline grid, baking in at least
1082
- // `marginBottom` (grid wins, margin is a minimum — the resource rule).
1083
- {
1084
- const usedHeight = curCol.bbox.height - curCol.availableHeight;
1085
- const naturalBottom = usedHeight + result.marginBottomPx;
1086
- const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
1087
- curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
1088
- }
1089
- pendingSpacing = 0;
1090
- return undefined;
1091
1359
  };
1092
1360
  for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
1093
1361
  if (options?.shouldCancel?.())
@@ -1099,7 +1367,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1099
1367
  // order. Then enqueue the floats first-referenced in this block, so the
1100
1368
  // next page opened while placing it (or any later block) reserves their
1101
1369
  // band and the next iteration offers them the slots that follow.
1102
- tryPlacePendingFloatsOnCurrentPage();
1370
+ tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
1103
1371
  enqueueFloatsFor(blockIdx);
1104
1372
  // --- Directives ----------------------------------------------------
1105
1373
  if (rawBlock.type === 'directive') {
@@ -1183,6 +1451,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1183
1451
  createPartPage(doc.pages[cursor.pageIndex], pageMetrics, resolved, {
1184
1452
  number: plan.number,
1185
1453
  title: plan.title,
1454
+ palette: plan.palette,
1186
1455
  titleSourceStart,
1187
1456
  titleSourceEnd,
1188
1457
  }, pageIndexOffset);
@@ -1218,7 +1487,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1218
1487
  // slots, else on pages opened ahead of it — so no float escapes
1219
1488
  // past it. The page stays balanceable (no forced break).
1220
1489
  if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier) {
1221
- tryPlacePendingFloatsOnCurrentPage();
1490
+ tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
1222
1491
  drainPendingFloats();
1223
1492
  }
1224
1493
  // `span: 'page'` boxes in multi-column layouts branch to the
@@ -1411,6 +1680,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1411
1680
  // A justified line a link leaves with too few spaces is set ragged.
1412
1681
  let remainingLines = [...raggedUrlLines(measured.lines, style.textAlign, rawBlock.text)];
1413
1682
  let partIndex = 0;
1683
+ /** Times this block left an EMPTY short column (see `shortColumn`) —
1684
+ * bounded so a page whose columns are all short (footnotes, design
1685
+ * bands) cannot make it wander forever. */
1686
+ let shortColumnMoves = 0;
1414
1687
  // "Keep with next" for colon-introduced lists: a paragraph ending in `:`
1415
1688
  // followed directly by a list acts as a lead-in title — the colon-bearing
1416
1689
  // line must share a column with the first list item. Only checked for the
@@ -1439,8 +1712,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1439
1712
  // page top.
1440
1713
  if (vdtType === 'heading') {
1441
1714
  const extraPx = balanceExtraPx?.get(blockIdx);
1442
- if (extraPx)
1715
+ if (extraPx) {
1443
1716
  spacingBefore += extraPx;
1717
+ addBalanceExtra(curCol, extraPx);
1718
+ }
1444
1719
  }
1445
1720
  }
1446
1721
  else if (vdtType === 'listItem') {
@@ -1454,10 +1729,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1454
1729
  // handled inside the heading branch above (after margin collapsing).
1455
1730
  if (vdtType !== 'heading' && vdtType !== 'mathDisplay' && partIndex === 0) {
1456
1731
  const extraPx = balanceExtraPx?.get(blockIdx);
1457
- if (extraPx)
1732
+ if (extraPx) {
1458
1733
  spacingBefore += extraPx;
1734
+ addBalanceExtra(curCol, extraPx);
1735
+ }
1736
+ }
1737
+ }
1738
+ else if (partIndex === 0 && reservedOf(curCol).top > 0) {
1739
+ // Column balancing: extra grid lines between the float band at the
1740
+ // head of this column and its first block (the after-float lever).
1741
+ const extraPx = balanceExtraPx?.get(blockIdx);
1742
+ if (extraPx) {
1743
+ spacingBefore += extraPx;
1744
+ addBalanceExtra(curCol, extraPx);
1459
1745
  }
1460
1746
  }
1747
+ // A loose paragraph's extra line is balancing height too (it lands
1748
+ // whole in this column — loose candidates are never split parts).
1749
+ if (partIndex === 0 && tryLoose && looseLines !== undefined && typeof looseOutcome.get(blockIdx) === 'number') {
1750
+ addBalanceExtra(curCol, looseLines * style.lineHeightPx);
1751
+ }
1461
1752
  const effectiveAvailable = curCol.availableHeight - spacingBefore;
1462
1753
  const linesPerAvailable = Math.floor(effectiveAvailable / style.lineHeightPx);
1463
1754
  // Math display blocks carry their natural pixel height on the single
@@ -1501,7 +1792,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1501
1792
  && curCol.blocks.length > 0) {
1502
1793
  const usedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
1503
1794
  const paragraphBottom = usedHeight + totalRemainHeight;
1504
- const availableAfter = curCol.bbox.height - paragraphBottom;
1795
+ // Slack after the paragraph as the plain pass saw it: the height
1796
+ // balancing added above in this column is not room the list lost.
1797
+ const availableAfter = curCol.bbox.height - paragraphBottom + (balanceExtraInColumn.get(curCol) ?? 0);
1505
1798
  const nextListKind = nextBlock?.listKind ?? 'unordered';
1506
1799
  const nextListMarginDim = nextListKind === 'ordered'
1507
1800
  ? resolved.orderedLists.marginTop
@@ -1581,6 +1874,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1581
1874
  // headingRunCount === curCol.blocks.length: fall through to place.
1582
1875
  }
1583
1876
  }
1877
+ // A block opening a column normally stays (no column can take it any
1878
+ // better), except in a column at least a line shorter than the content
1879
+ // area — the band under a page-span box, or a column cut by a float:
1880
+ // a heading that fits there alone, or a block that does not fit there
1881
+ // but would fit a full column, opens the next column instead (the move
1882
+ // count is bounded besides). A column cut by a band cap is short on
1883
+ // purpose — its content is meant to end at the cut.
1884
+ const shortColumn = shortColumnMoves < 4
1885
+ && !uncappedBottoms.has(curCol)
1886
+ && curCol.bbox.height < contentArea.height - baselineGrid;
1584
1887
  // Block fits in current column
1585
1888
  if (effectiveRemainHeight <= effectiveAvailable) {
1586
1889
  // Heading keep-with-next: never leave a heading as the last block of a
@@ -1597,7 +1900,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1597
1900
  && resolved.headings.keepWithNext
1598
1901
  && !nextIsHeading
1599
1902
  && nextBlock !== null
1600
- && curCol.blocks.length > 0) {
1903
+ && (curCol.blocks.length > 0 || shortColumn)) {
1601
1904
  const wouldUsedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
1602
1905
  const naturalBottom = wouldUsedHeight + effectiveRemainHeight + style.marginBottomPx;
1603
1906
  const snappedBottom = shouldSnapToGrid
@@ -1609,6 +1912,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1609
1912
  : 1;
1610
1913
  const minSpaceAfter = minLinesNeeded * bodyStyle.lineHeightPx;
1611
1914
  if (remainAfterHeading < minSpaceAfter) {
1915
+ if (curCol.blocks.length === 0)
1916
+ shortColumnMoves++;
1612
1917
  // Roll back any immediately-preceding heading blocks in this
1613
1918
  // column so they travel with this one.
1614
1919
  const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
@@ -1772,8 +2077,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1772
2077
  }
1773
2078
  // choice.splitAt === 0: fall through to push whole paragraph to next column
1774
2079
  }
1775
- // Cannot split — advance to next column if current has content
1776
- if (curCol.blocks.length > 0) {
2080
+ // Cannot split — advance to next column if current has content (or
2081
+ // the column is a short band that cannot hold the block at all).
2082
+ if (curCol.blocks.length > 0 || (shortColumn && effectiveRemainHeight <= contentArea.height)) {
2083
+ if (curCol.blocks.length === 0)
2084
+ shortColumnMoves++;
1777
2085
  // Heading keep-with-next (no-fit variant): when a heading can't fit
1778
2086
  // in the current column and the column's tail is a run of headings,
1779
2087
  // pull those headings along so they don't remain stranded as orphans
@@ -1896,17 +2204,67 @@ export function buildDocument(content, config, cache, options) {
1896
2204
  let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
1897
2205
  let applied = { lines: new Map(), loose: new Map() };
1898
2206
  const failedLoose = new Set();
2207
+ const failedLines = new Set();
1899
2208
  let converged = bestScore === 0;
2209
+ /**
2210
+ * A rejected pass moved content across a column break somewhere (a
2211
+ * split paragraph whose head no longer fits, a float that lost its slot,
2212
+ * a lead-in that left with its list…): every page after that point is
2213
+ * re-flowed, gaps open elsewhere and a span cap may miss its band. The
2214
+ * levers are meant to be local, so contain the damage: find the first
2215
+ * column whose content changed and blacklist the levers this pass newly
2216
+ * applied there (failing that, on its page; failing that, everywhere), so
2217
+ * the next proposal keeps the working levers before it and tries again
2218
+ * without the one that cascaded. Returns whether anything was blacklisted.
2219
+ */
2220
+ const containCascade = (next, proposal) => {
2221
+ const div = firstDivergentColumn(best.doc, next.doc);
2222
+ if (!div)
2223
+ return false;
2224
+ const newLines = [...proposal.lines].filter(([k, n]) => n > (applied.lines.get(k) ?? 0)).map(([k]) => k);
2225
+ const newLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
2226
+ if (newLines.length === 0 && newLoose.length === 0)
2227
+ return false;
2228
+ const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
2229
+ const blacklist = (cands) => {
2230
+ let hit = false;
2231
+ for (const k of newLines)
2232
+ if (!cands || cands.has(k)) {
2233
+ failedLines.add(k);
2234
+ hit = true;
2235
+ }
2236
+ for (const k of newLoose)
2237
+ if (!cands || cands.has(k)) {
2238
+ failedLoose.add(k);
2239
+ hit = true;
2240
+ }
2241
+ return hit;
2242
+ };
2243
+ const inColumn = gaps
2244
+ .filter((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)
2245
+ .flatMap((g) => g.candidates.map((c) => c.contentIndex));
2246
+ if (blacklist(new Set(inColumn)))
2247
+ return true;
2248
+ const onPage = gaps
2249
+ .filter((g) => g.pageIndex === div.pageIndex)
2250
+ .flatMap((g) => g.candidates.map((c) => c.contentIndex));
2251
+ if (blacklist(new Set(onPage)))
2252
+ return true;
2253
+ return blacklist(null);
2254
+ };
1900
2255
  const balance = () => {
1901
2256
  while (!converged && passCount < MAX_BALANCING_PASSES) {
1902
2257
  const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
1903
2258
  maxLinesPerHeading: balancing.maxLinesPerHeading,
1904
2259
  stretchAfterLists: balancing.stretchAfterLists,
1905
2260
  maxLinesAfterList: balancing.maxLinesAfterList,
2261
+ stretchAfterFloats: balancing.stretchAfterFloats,
2262
+ maxLinesAfterFloat: balancing.maxLinesAfterFloat,
1906
2263
  looseParagraphs: balancing.looseParagraphs,
1907
2264
  maxLooseParagraphs: balancing.maxLooseParagraphs,
1908
2265
  optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
1909
2266
  failedLoose,
2267
+ failedLines,
1910
2268
  });
1911
2269
  if (!proposal.changed) {
1912
2270
  // No stretch point can absorb the remaining gaps — stable.
@@ -1950,11 +2308,16 @@ export function buildDocument(content, config, cache, options) {
1950
2308
  converged = score === 0;
1951
2309
  }
1952
2310
  else {
1953
- // Plateau or regression. Retry when a loose candidate was just
1954
- // blacklisted (the proposer falls through to the next one), or when
1955
- // the new loose paragraphs gained their lines yet the layout did not
1956
- // improve (the gain landed elsewhere — drop them too). A pure spacing
1957
- // plateau means we're done: keep the best layout found so far.
2311
+ // Plateau or regression. First contain a cascade: a lever that
2312
+ // moved content across a column break is blacklisted and the loop
2313
+ // retries without it. Otherwise retry when a loose candidate was
2314
+ // just blacklisted (the proposer falls through to the next one), or
2315
+ // when the new loose paragraphs gained their lines yet the layout
2316
+ // did not improve (the gain landed elsewhere — drop them too). A
2317
+ // pure spacing plateau means we're done: keep the best layout found
2318
+ // so far.
2319
+ if (containCascade(next, proposal))
2320
+ continue;
1958
2321
  if (looseFailed.length > 0 || looseWon.length > 0) {
1959
2322
  for (const k of looseWon)
1960
2323
  failedLoose.add(k);