postext 0.3.27 → 0.3.28

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 (63) hide show
  1. package/dist/__tests__/defaults/calloutStyles.test.js +4 -2
  2. package/dist/__tests__/defaults/calloutStyles.test.js.map +1 -1
  3. package/dist/__tests__/defaults/headingsBalancing.test.d.ts +2 -0
  4. package/dist/__tests__/defaults/headingsBalancing.test.d.ts.map +1 -0
  5. package/dist/__tests__/defaults/headingsBalancing.test.js +17 -0
  6. package/dist/__tests__/defaults/headingsBalancing.test.js.map +1 -0
  7. package/dist/__tests__/parts.test.js +82 -1
  8. package/dist/__tests__/parts.test.js.map +1 -1
  9. package/dist/__tests__/pipeline/calloutSplit.test.d.ts +2 -0
  10. package/dist/__tests__/pipeline/calloutSplit.test.d.ts.map +1 -0
  11. package/dist/__tests__/pipeline/calloutSplit.test.js +156 -0
  12. package/dist/__tests__/pipeline/calloutSplit.test.js.map +1 -0
  13. package/dist/__tests__/pipeline/spanBlocks.test.js +4 -2
  14. package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
  15. package/dist/__tests__/pipeline/spanLeaveLevel.test.d.ts +2 -0
  16. package/dist/__tests__/pipeline/spanLeaveLevel.test.d.ts.map +1 -0
  17. package/dist/__tests__/pipeline/spanLeaveLevel.test.js +103 -0
  18. package/dist/__tests__/pipeline/spanLeaveLevel.test.js.map +1 -0
  19. package/dist/defaults/calloutStyles.d.ts.map +1 -1
  20. package/dist/defaults/calloutStyles.js +3 -3
  21. package/dist/defaults/calloutStyles.js.map +1 -1
  22. package/dist/defaults/headings.d.ts +1 -0
  23. package/dist/defaults/headings.d.ts.map +1 -1
  24. package/dist/defaults/headings.js +7 -0
  25. package/dist/defaults/headings.js.map +1 -1
  26. package/dist/design/layout.d.ts +6 -1
  27. package/dist/design/layout.d.ts.map +1 -1
  28. package/dist/design/layout.js +38 -2
  29. package/dist/design/layout.js.map +1 -1
  30. package/dist/design/placeholders.d.ts +4 -0
  31. package/dist/design/placeholders.d.ts.map +1 -1
  32. package/dist/design/placeholders.js.map +1 -1
  33. package/dist/pipeline/build.d.ts.map +1 -1
  34. package/dist/pipeline/build.js +355 -152
  35. package/dist/pipeline/build.js.map +1 -1
  36. package/dist/pipeline/calloutLayout.d.ts +9 -3
  37. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  38. package/dist/pipeline/calloutLayout.js +7 -5
  39. package/dist/pipeline/calloutLayout.js.map +1 -1
  40. package/dist/pipeline/continuation.d.ts.map +1 -1
  41. package/dist/pipeline/continuation.js +14 -2
  42. package/dist/pipeline/continuation.js.map +1 -1
  43. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  44. package/dist/pipeline/headerFooter.js +7 -1
  45. package/dist/pipeline/headerFooter.js.map +1 -1
  46. package/dist/pipeline/parts.d.ts +9 -0
  47. package/dist/pipeline/parts.d.ts.map +1 -1
  48. package/dist/pipeline/parts.js +15 -0
  49. package/dist/pipeline/parts.js.map +1 -1
  50. package/dist/pipeline/placeholders.d.ts +12 -9
  51. package/dist/pipeline/placeholders.d.ts.map +1 -1
  52. package/dist/pipeline/placeholders.js +13 -12
  53. package/dist/pipeline/placeholders.js.map +1 -1
  54. package/dist/pipeline/placement.d.ts +1 -0
  55. package/dist/pipeline/placement.d.ts.map +1 -1
  56. package/dist/pipeline/placement.js +1 -0
  57. package/dist/pipeline/placement.js.map +1 -1
  58. package/dist/types.d.ts +30 -1
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/vdt.d.ts +14 -1
  61. package/dist/vdt.d.ts.map +1 -1
  62. package/dist/vdt.js.map +1 -1
  63. 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';
@@ -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'`.
@@ -663,22 +667,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
663
667
  * keep-with-next rollbacks may pull along (a callout is one unbreakable
664
668
  * unit; its children never leave it). */
665
669
  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) => {
670
+ /** Stamp a callout frame's content index and source range: the whole
671
+ * fence (opening to closing marker) for an unsplit box; for a fragment
672
+ * of a split box, from the fence start (first fragment) or the first
673
+ * child it holds, to the fence end (last fragment) or the last child. */
674
+ const stampCalloutSource = (frame, startIdx, plan, range) => {
673
675
  const startBlock = contentBlocks[startIdx];
674
676
  const endBlock = contentBlocks[plan.endIdx];
675
677
  frame.contentIndex = startIdx;
676
- frame.sourceStart = startBlock.sourceStart + bodyOffset;
677
- frame.sourceEnd = endBlock.sourceEnd + bodyOffset;
678
+ const first = range && range.firstChildIdx > startIdx + 1 ? contentBlocks[range.firstChildIdx] : startBlock;
679
+ const last = range && range.lastChildIdx < plan.endIdx - 1 ? contentBlocks[range.lastChildIdx] : endBlock;
680
+ frame.sourceStart = first.sourceStart + bodyOffset;
681
+ frame.sourceEnd = last.sourceEnd + bodyOffset;
678
682
  };
679
- const commitCallout = (result, startIdx, plan, col) => {
683
+ /** Shared tail of callout placement: stamp the frame's source range,
684
+ * convert the laid-out box to absolute coordinates at the frame's placed
685
+ * origin, and push frame + children — in that order — to `doc.blocks`
686
+ * and to the column the frame landed in. */
687
+ const commitCallout = (result, startIdx, plan, col, range) => {
680
688
  const frame = result.frame;
681
- stampCalloutSource(frame, startIdx, plan);
689
+ stampCalloutSource(frame, startIdx, plan, range);
682
690
  offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
683
691
  doc.blocks.push(frame);
684
692
  for (const child of result.children) {
@@ -688,6 +696,77 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
688
696
  doc.blocks.push(child);
689
697
  }
690
698
  };
699
+ const makeCalloutLayouter = (startIdx, plan, style) => {
700
+ const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
701
+ const realAt = [];
702
+ children.forEach((c, k) => {
703
+ if (c.type !== 'directive' && !isMarkerBlock(c))
704
+ realAt.push(k);
705
+ });
706
+ const layoutRange = (from, to, width, frameId, continuation) => {
707
+ let n = 0;
708
+ return layoutCallout({
709
+ style,
710
+ attrs: plan.attrs,
711
+ continuation,
712
+ children: children.slice(from, to),
713
+ childStartIdx: startIdx + 1 + from,
714
+ width,
715
+ ctx: measureCtx,
716
+ resolved,
717
+ containerId: plan.containerId,
718
+ frameId,
719
+ nextChildId: () => `${frameId}-c${n++}`,
720
+ paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
721
+ });
722
+ };
723
+ return { children, childBase: startIdx + 1, realAt, layoutRange };
724
+ };
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);
736
+ const lastChild = full.children[full.children.length - 1];
737
+ if (!lastChild)
738
+ return null;
739
+ 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);
746
+ }
747
+ }
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);
756
+ if (result.totalHeight <= roomPx + 0.01)
757
+ return { to, result };
758
+ }
759
+ return null;
760
+ };
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 });
764
+ const markFragment = (result, part, continued) => {
765
+ if (result.frame.callout) {
766
+ result.frame.callout.part = part;
767
+ result.frame.callout.continued = continued;
768
+ }
769
+ };
691
770
  /**
692
771
  * Place a `span: 'page'` `:::callout` in a multi-column layout as a span
693
772
  * block (stage 1): the box is laid out at the page's content width and
@@ -716,6 +795,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
716
795
  * overflowed instead (the box arrives elsewhere), the driver grows or
717
796
  * drops the cap.
718
797
  *
798
+ * Leaving level (`headings.balancing.beforeSpan`): a box that does not
799
+ * fit even after a level cut proposes a TRAILING cap instead — the band
800
+ * it leaves is cut level, the way a chapter's closing band is, and the
801
+ * page is marked as a forced break in that pass so balancing does not
802
+ * stretch its last column back to the page bottom. The cap is resolved
803
+ * after balancing (`resolveTrailingCaps`); when the box reaches the
804
+ * capped band it counts as delivered whether it fits there or moves on,
805
+ * and the cut columns stay cut (the polish round fills a column ending a
806
+ * line under the cap).
807
+ *
808
+ * Splitting (`keepTogether: false`): a box that does not fit a level (or
809
+ * capped, or nearly level — within one grid line) band breaks between
810
+ * its children: the longest fragment that fits closes the page flush
811
+ * with the band bottom, the rest opens the next page in a box without
812
+ * the title or icon, and splits again if it is still too tall. Such a box that
813
+ * fits whole only flush with the page bottom (no text below) is placed
814
+ * whole. When the band is uneven, the cap that levels it (span cap when
815
+ * the whole box fits after the cut, trailing cap otherwise) comes first;
816
+ * the fragment is cut in the capped pass.
817
+ *
719
818
  * Geometry stays on the baseline grid: the cut line is the band's used
720
819
  * bottom snapped UP to the next grid line (anchored at the content-area
721
820
  * top, like every column start), and the span column's height is the
@@ -724,69 +823,169 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
724
823
  * stay in the outer page bands: the band inherits the float-reduced top
725
824
  * / bottom of the page's columns, so a span column never overlaps a float.
726
825
  */
727
- const placeCalloutSpan = (startIdx, plan, layoutAt) => {
728
- let page = doc.pages[cursor.pageIndex];
729
- let result = layoutAt(page.contentArea.width);
826
+ const placeCalloutSpan = (startIdx, plan, style, L, firstFrameId) => {
827
+ const splittable = !style.keepTogether;
828
+ const balancingCfg = resolved.headings.balancing;
829
+ const levelBefore = balancingCfg.enabled && balancingCfg.beforeSpan;
730
830
  const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
731
831
  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 };
832
+ const gridUp = (page, v) => page.contentArea.y + Math.ceil((v - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
833
+ const needFor = (spacing, height, margin) => Math.ceil((spacing + height + margin - 0.01) / baselineGrid) * baselineGrid;
834
+ const nearlyLevel = (cols) => {
835
+ const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
836
+ return Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5;
750
837
  };
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
- });
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;
841
+ let part = 0;
842
+ let frameId = firstFrameId;
843
+ /** The box already moved to a fresh page (or sits on an empty one):
844
+ * whatever does not fit there is force-placed and overflows. */
845
+ let forceHere = false;
846
+ for (;;) {
847
+ let page = doc.pages[cursor.pageIndex];
848
+ const continuation = part > 0;
849
+ const layoutAt = (width) => L.layoutRange(from, L.children.length, width, frameId, continuation);
850
+ const result = layoutAt(page.contentArea.width);
851
+ /** Where the box would cut the current band, and whether it fits (room
852
+ * is measured against the columns' TRUE bottoms — a capped band keeps
853
+ * its slack below the cap). Null when the band is not level (or the
854
+ * cursor sits on a span column with no band below it) and
855
+ * `requireLevel` is set. */
856
+ const measureBand = (requireLevel) => {
857
+ const cols = bandColumns(page, currentBand(page, cursor));
858
+ if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
859
+ return null;
860
+ const cutY = gridUp(page, bandUsedBottom(cols));
861
+ const bandHasContent = cols.some((c) => c.blocks.length > 0);
862
+ const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
863
+ const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
864
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
865
+ const room = cutY + need + minRoomPx <= bandBottom + 0.01;
866
+ const needFlush = needFor(spacing, result.totalHeight, 0);
867
+ const roomFlush = cutY + needFlush <= bandBottom + 0.01;
868
+ return { cols, cutY, spacing, need, room, needFlush, roomFlush, roomPx: bandBottom - cutY };
869
+ };
870
+ // Is this band capped for this very box? Then it cuts at the band's
871
+ // used bottom (at most the cap) even when the last column is short.
872
+ const cap = part === 0 ? bandCaps?.get(startIdx) : undefined;
873
+ const capActive = cap !== undefined
874
+ && activeCap !== null
875
+ && activeCap.spanIndex === startIdx
876
+ && activeCap.pageIndex === page.index
877
+ && activeCap.band === currentBand(page, cursor);
878
+ const fit = forceHere ? (measureBand(true) ?? measureBand(false)) : measureBand(!capActive);
879
+ let action = null;
880
+ if (fit) {
881
+ if (fit.room)
882
+ action = { kind: 'whole', fit, need: fit.need, result };
883
+ else if (splittable && fit.roomFlush)
884
+ action = { kind: 'whole', fit, need: fit.needFlush, result };
885
+ }
886
+ if (!action && splittable) {
887
+ // A band uneven by no more than a grid line still takes a fragment
888
+ // cut at its used bottom: balancing fills the line the short column
889
+ // is left under the cut.
890
+ let band = fit;
891
+ if (!band) {
892
+ const cols = bandColumns(page, currentBand(page, cursor));
893
+ if (cols.length > 0 && nearlyLevel(cols))
894
+ band = measureBand(false);
895
+ }
896
+ if (band) {
897
+ const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation);
898
+ if (fragment) {
899
+ action = { kind: 'split', fit: band, need: needFor(band.spacing, fragment.result.totalHeight, 0), fragment };
900
+ }
901
+ }
902
+ }
903
+ if (!action && forceHere && fit)
904
+ action = { kind: 'whole', fit, need: fit.need, result };
905
+ if (action) {
906
+ if (capActive) {
907
+ uncapBand(action.fit.cols, uncappedBottoms);
908
+ spanPlacedInBand.add(startIdx);
909
+ }
910
+ const placed = action.kind === 'whole' ? action.result : action.fragment.result;
911
+ const to = action.kind === 'whole' ? L.children.length : action.fragment.to;
912
+ if (part > 0 || action.kind === 'split')
913
+ markFragment(placed, part, action.kind === 'split');
914
+ const spanCol = closeBandAndInsertSpan(page, action.fit.cols, action.fit.cutY, placed.frame, action.need, cursor, action.fit.spacing, placed.totalHeight);
915
+ commitCallout(placed, startIdx, plan, spanCol, part > 0 || action.kind === 'split' ? fragmentRange(L, from, to) : undefined);
916
+ // Floats first-referenced inside the box enqueue once its head is
917
+ // committed, in reading order (same as the inline path).
918
+ if (part === 0)
919
+ for (let i = startIdx + 1; i <= plan.endIdx; i++)
920
+ enqueueFloatsFor(i);
921
+ // The new band starts on the grid right below the span column; nothing
922
+ // to snap — `need` already bakes in `marginBottom`.
923
+ pendingSpacing = 0;
924
+ if (action.kind === 'whole')
925
+ return true;
926
+ // The rest opens the next page.
927
+ from = to;
928
+ part++;
929
+ frameId = `block-${blockIdCounter++}`;
930
+ const startPageIndex = cursor.pageIndex;
931
+ do {
932
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
933
+ } while (cursor.pageIndex === startPageIndex);
934
+ forceHere = true;
935
+ continue;
936
+ }
937
+ // Nothing of the box lands in this band.
938
+ if (part === 0) {
939
+ let proposedSpan = false;
940
+ if (!fit && cap === undefined && bandStart && registeredBand
941
+ && registeredBand.pageIndex === page.index
942
+ && registeredBand.band === currentBand(page, cursor)) {
943
+ // Uneven band, no cap yet: propose one when a level cut would leave
944
+ // room for the box plus the widow minimum of body lines below it
945
+ // (or, for a splittable box, for the whole box flush with the
946
+ // band bottom).
947
+ const cols = bandColumns(page, currentBand(page, cursor));
948
+ const lines = bandCapLines(cols, baselineGrid);
949
+ const capBottom = bandTop(cols) + lines * baselineGrid;
950
+ const spacing = Math.max(pendingSpacing, result.marginTopPx);
951
+ const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
952
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
953
+ const fitsAfterCut = capBottom + need + minRoomPx <= bandBottom + 0.01
954
+ || (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01);
955
+ if (fitsAfterCut) {
956
+ bandCapProposals.set(startIdx, {
957
+ kind: 'span',
958
+ startContentIndex: bandStart.contentIndex,
959
+ startPart: bandStart.part,
960
+ lines,
961
+ retries: 0,
962
+ });
963
+ proposedSpan = true;
964
+ }
965
+ }
966
+ if (levelBefore && !capActive && !proposedSpan) {
967
+ // The box leaves an uncapped band: level it behind the box (a
968
+ // trailing cap, resolved after balancing) and keep balancing from
969
+ // stretching its last column to the page bottom meanwhile.
970
+ proposeTrailingCap(startIdx);
971
+ markForcedBreak();
972
+ }
973
+ else if (capActive && cap.kind === 'trailing') {
974
+ // Reached inside the band cut level for it: delivered even though
975
+ // the box moves on — the columns stay cut.
976
+ spanPlacedInBand.add(startIdx);
977
+ }
783
978
  }
784
- }
785
- if (!fit || !fit.room) {
786
979
  // Open the next page (flushing pending floats into its bands). A page
787
980
  // holding only floats counts as occupied here — its float band is what
788
981
  // left no room — but a truly empty page is kept: the box then simply
789
982
  // does not fit a page and is force-placed (overflowing, like inline).
983
+ // On a fresh page the whole box is force-placed above whenever the
984
+ // page has a text column to cut; reaching this point there means it
985
+ // has none — leave the box to the inline path (its head, if any, is
986
+ // already committed: `true` keeps the rest from being placed twice).
987
+ if (forceHere)
988
+ return part > 0;
790
989
  const curPage = doc.pages[cursor.pageIndex];
791
990
  if (pageIsOccupied(curPage)) {
792
991
  pendingSpacing = 0;
@@ -795,26 +994,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
795
994
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
796
995
  } while (cursor.pageIndex === startPageIndex);
797
996
  page = doc.pages[cursor.pageIndex];
798
- if (Math.abs(page.contentArea.width - result.width) > 0.01) {
799
- result = layoutAt(page.contentArea.width);
800
- }
801
997
  }
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)
998
+ // A freshly opened (or empty) page is level; force-place (overflow)
999
+ // when the box is taller than the page.
1000
+ if (bandColumns(page, currentBand(page, cursor)).length === 0)
806
1001
  return false; // no text column to cut — leave it to the inline path
1002
+ forceHere = true;
807
1003
  }
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
1004
  };
819
1005
  /** Whether the flow ends at a chapter-level boundary right after block
820
1006
  * `from` (skipping container markers): a chapter opener, a `:::part`, a
@@ -992,8 +1178,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
992
1178
  * baked into the post-box grid snap. A box that does not fit moves to the
993
1179
  * next column/page (like a resource), pulling a run of trailing headings
994
1180
  * 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`.
1181
+ * anyway and overflows (the sandbox warns). A splittable box
1182
+ * (`keepTogether: false`) instead leaves the longest run of its children
1183
+ * that fits in the column and continues — in a box of its own, without
1184
+ * the title or icon — at the top of the next one, splitting again if needed.
1185
+ * Returns the content index to rewind the main loop to when headings
1186
+ * were rolled back, else `undefined`.
997
1187
  *
998
1188
  * `span: 'page'` boxes in multi-column layouts take the span-block path
999
1189
  * (`placeCalloutSpan`) instead; `placement: 'top' | 'bottom'` (floating
@@ -1002,25 +1192,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1002
1192
  */
1003
1193
  const placeCalloutInline = (startIdx, plan) => {
1004
1194
  const style = pickCalloutStyle(resolved.calloutStyles, plan.attrs.type);
1005
- const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
1006
- const frameId = `block-${blockIdCounter++}`;
1195
+ const firstFrameId = `block-${blockIdCounter++}`;
1007
1196
  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
- };
1197
+ const L = makeCalloutLayouter(startIdx, plan, style);
1198
+ const layoutAt = (width) => L.layoutRange(0, L.children.length, width, firstFrameId, false);
1024
1199
  // Fixed boxes leave the flow entirely.
1025
1200
  if (placement === 'fixed') {
1026
1201
  placeCalloutFixed(startIdx, plan, style, layoutAt);
@@ -1033,61 +1208,88 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1033
1208
  if (span === 'page'
1034
1209
  && placement === 'here'
1035
1210
  && bandColumns(page, currentBand(page, cursor)).length > 1
1036
- && placeCalloutSpan(startIdx, plan, layoutAt)) {
1211
+ && placeCalloutSpan(startIdx, plan, style, L, firstFrameId)) {
1037
1212
  return undefined;
1038
1213
  }
1039
1214
  }
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);
1215
+ const splittable = !style.keepTogether;
1216
+ let from = 0;
1217
+ let part = 0;
1218
+ let frameId = firstFrameId;
1219
+ for (;;) {
1220
+ let curCol = currentColumn(doc, cursor);
1221
+ 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);
1224
+ const roomPx = curCol.availableHeight - spacing;
1225
+ let fragment = null;
1226
+ if (result.totalHeight > roomPx + 0.01) {
1227
+ // The (rest of the) box does not fit the column: a splittable box
1228
+ // leaves the head that fits here…
1229
+ if (splittable)
1230
+ fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation);
1231
+ if (!fragment && curCol.blocks.length > 0) {
1232
+ // …otherwise it moves whole to the next column. Keep-with-next: a
1233
+ // run of headings at the column's tail travels with the box.
1234
+ // Skipped when the column holds nothing else (rolling back again
1235
+ // would loop) — the headings stay, orphaned. Only the head of a
1236
+ // box can roll headings back: a continuation always lands in a
1237
+ // fresh column.
1238
+ let run = 0;
1239
+ for (let j = curCol.blocks.length - 1; j >= 0; j--) {
1240
+ if (isFreeHeading(curCol.blocks[j]))
1241
+ run++;
1242
+ else
1243
+ break;
1244
+ }
1245
+ pendingSpacing = 0;
1246
+ if (part === 0 && resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
1247
+ const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
1248
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1249
+ return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
1250
+ }
1058
1251
  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);
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
+ }
1066
1257
  }
1258
+ // An empty column that is still too short: placed anyway, overflowing.
1067
1259
  }
1260
+ // Floats first-referenced inside the box still enqueue in reading order
1261
+ // (only once the box is committed, so a keep-with-next replay does not
1262
+ // enqueue them twice).
1263
+ if (part === 0)
1264
+ for (let i = startIdx + 1; i <= plan.endIdx; i++)
1265
+ enqueueFloatsFor(i);
1266
+ const placed = fragment ? fragment.result : result;
1267
+ const to = fragment ? fragment.to : L.children.length;
1268
+ if (part > 0 || fragment)
1269
+ markFragment(placed, part, fragment !== null);
1270
+ const spacingBefore = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx);
1271
+ enterBand(startIdx, 0);
1272
+ placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
1273
+ enterBand(startIdx, 0);
1274
+ curCol = currentColumn(doc, cursor);
1275
+ commitCallout(placed, startIdx, plan, curCol, part > 0 || fragment ? fragmentRange(L, from, to) : undefined);
1276
+ // Snap the flow after the box to the baseline grid, baking in at least
1277
+ // `marginBottom` (grid wins, margin is a minimum — the resource rule).
1278
+ {
1279
+ const usedHeight = curCol.bbox.height - curCol.availableHeight;
1280
+ const naturalBottom = usedHeight + placed.marginBottomPx;
1281
+ const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
1282
+ curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
1283
+ }
1284
+ pendingSpacing = 0;
1285
+ if (!fragment)
1286
+ return undefined;
1287
+ // The rest continues at the top of the next column.
1288
+ from = to;
1289
+ part++;
1290
+ frameId = `block-${blockIdCounter++}`;
1291
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1068
1292
  }
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
1293
  };
1092
1294
  for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
1093
1295
  if (options?.shouldCancel?.())
@@ -1183,6 +1385,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1183
1385
  createPartPage(doc.pages[cursor.pageIndex], pageMetrics, resolved, {
1184
1386
  number: plan.number,
1185
1387
  title: plan.title,
1388
+ palette: plan.palette,
1186
1389
  titleSourceStart,
1187
1390
  titleSourceEnd,
1188
1391
  }, pageIndexOffset);