postext 0.3.26 → 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 (130) hide show
  1. package/dist/__tests__/columnBalancing.test.js +46 -0
  2. package/dist/__tests__/columnBalancing.test.js.map +1 -1
  3. package/dist/__tests__/continuation.test.d.ts +2 -0
  4. package/dist/__tests__/continuation.test.d.ts.map +1 -0
  5. package/dist/__tests__/continuation.test.js +124 -0
  6. package/dist/__tests__/continuation.test.js.map +1 -0
  7. package/dist/__tests__/defaults/calloutStyles.test.js +62 -3
  8. package/dist/__tests__/defaults/calloutStyles.test.js.map +1 -1
  9. package/dist/__tests__/defaults/headingsBalancing.test.d.ts +2 -0
  10. package/dist/__tests__/defaults/headingsBalancing.test.d.ts.map +1 -0
  11. package/dist/__tests__/defaults/headingsBalancing.test.js +17 -0
  12. package/dist/__tests__/defaults/headingsBalancing.test.js.map +1 -0
  13. package/dist/__tests__/exports.test.js +1 -0
  14. package/dist/__tests__/exports.test.js.map +1 -1
  15. package/dist/__tests__/parts.test.js +82 -1
  16. package/dist/__tests__/parts.test.js.map +1 -1
  17. package/dist/__tests__/pipeline/calloutLayout.test.js +171 -0
  18. package/dist/__tests__/pipeline/calloutLayout.test.js.map +1 -1
  19. package/dist/__tests__/pipeline/calloutSplit.test.d.ts +2 -0
  20. package/dist/__tests__/pipeline/calloutSplit.test.d.ts.map +1 -0
  21. package/dist/__tests__/pipeline/calloutSplit.test.js +156 -0
  22. package/dist/__tests__/pipeline/calloutSplit.test.js.map +1 -0
  23. package/dist/__tests__/pipeline/raggedUrl.test.d.ts +2 -0
  24. package/dist/__tests__/pipeline/raggedUrl.test.d.ts.map +1 -0
  25. package/dist/__tests__/pipeline/raggedUrl.test.js +22 -0
  26. package/dist/__tests__/pipeline/raggedUrl.test.js.map +1 -0
  27. package/dist/__tests__/pipeline/spanBlocks.test.js +4 -2
  28. package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
  29. package/dist/__tests__/pipeline/spanLeaveLevel.test.d.ts +2 -0
  30. package/dist/__tests__/pipeline/spanLeaveLevel.test.d.ts.map +1 -0
  31. package/dist/__tests__/pipeline/spanLeaveLevel.test.js +103 -0
  32. package/dist/__tests__/pipeline/spanLeaveLevel.test.js.map +1 -0
  33. package/dist/__tests__/pipeline/trailingBandZone.test.d.ts +2 -0
  34. package/dist/__tests__/pipeline/trailingBandZone.test.d.ts.map +1 -0
  35. package/dist/__tests__/pipeline/trailingBandZone.test.js +77 -0
  36. package/dist/__tests__/pipeline/trailingBandZone.test.js.map +1 -0
  37. package/dist/canvas-backend/blockRender.js +1 -1
  38. package/dist/canvas-backend/blockRender.js.map +1 -1
  39. package/dist/defaults/calloutStyles.d.ts +17 -1
  40. package/dist/defaults/calloutStyles.d.ts.map +1 -1
  41. package/dist/defaults/calloutStyles.js +77 -4
  42. package/dist/defaults/calloutStyles.js.map +1 -1
  43. package/dist/defaults/headings.d.ts +1 -0
  44. package/dist/defaults/headings.d.ts.map +1 -1
  45. package/dist/defaults/headings.js +7 -0
  46. package/dist/defaults/headings.js.map +1 -1
  47. package/dist/defaults/shared.d.ts.map +1 -1
  48. package/dist/defaults/shared.js +12 -0
  49. package/dist/defaults/shared.js.map +1 -1
  50. package/dist/design/layout.d.ts +6 -1
  51. package/dist/design/layout.d.ts.map +1 -1
  52. package/dist/design/layout.js +38 -2
  53. package/dist/design/layout.js.map +1 -1
  54. package/dist/design/placeholders.d.ts +4 -0
  55. package/dist/design/placeholders.d.ts.map +1 -1
  56. package/dist/design/placeholders.js.map +1 -1
  57. package/dist/html-backend.js +1 -1
  58. package/dist/html-backend.js.map +1 -1
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/numbering.d.ts +3 -1
  64. package/dist/numbering.d.ts.map +1 -1
  65. package/dist/numbering.js +4 -1
  66. package/dist/numbering.js.map +1 -1
  67. package/dist/pipeline/bandCaps.d.ts +21 -1
  68. package/dist/pipeline/bandCaps.d.ts.map +1 -1
  69. package/dist/pipeline/bandCaps.js +29 -3
  70. package/dist/pipeline/bandCaps.js.map +1 -1
  71. package/dist/pipeline/build.d.ts +17 -0
  72. package/dist/pipeline/build.d.ts.map +1 -1
  73. package/dist/pipeline/build.js +413 -172
  74. package/dist/pipeline/build.js.map +1 -1
  75. package/dist/pipeline/buildHelpers.d.ts +1 -1
  76. package/dist/pipeline/buildHelpers.d.ts.map +1 -1
  77. package/dist/pipeline/buildHelpers.js +2 -2
  78. package/dist/pipeline/buildHelpers.js.map +1 -1
  79. package/dist/pipeline/calloutLayout.d.ts +14 -3
  80. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  81. package/dist/pipeline/calloutLayout.js +153 -48
  82. package/dist/pipeline/calloutLayout.js.map +1 -1
  83. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  84. package/dist/pipeline/columnBalancing.js +17 -1
  85. package/dist/pipeline/columnBalancing.js.map +1 -1
  86. package/dist/pipeline/continuation.d.ts +7 -0
  87. package/dist/pipeline/continuation.d.ts.map +1 -0
  88. package/dist/pipeline/continuation.js +38 -0
  89. package/dist/pipeline/continuation.js.map +1 -0
  90. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  91. package/dist/pipeline/headerFooter.js +16 -8
  92. package/dist/pipeline/headerFooter.js.map +1 -1
  93. package/dist/pipeline/index.d.ts +2 -1
  94. package/dist/pipeline/index.d.ts.map +1 -1
  95. package/dist/pipeline/index.js +1 -0
  96. package/dist/pipeline/index.js.map +1 -1
  97. package/dist/pipeline/parts.d.ts +9 -0
  98. package/dist/pipeline/parts.d.ts.map +1 -1
  99. package/dist/pipeline/parts.js +15 -0
  100. package/dist/pipeline/parts.js.map +1 -1
  101. package/dist/pipeline/placeholders.d.ts +13 -10
  102. package/dist/pipeline/placeholders.d.ts.map +1 -1
  103. package/dist/pipeline/placeholders.js +17 -15
  104. package/dist/pipeline/placeholders.js.map +1 -1
  105. package/dist/pipeline/placement.d.ts +3 -2
  106. package/dist/pipeline/placement.d.ts.map +1 -1
  107. package/dist/pipeline/placement.js +13 -8
  108. package/dist/pipeline/placement.js.map +1 -1
  109. package/dist/pipeline/raggedUrl.d.ts +15 -0
  110. package/dist/pipeline/raggedUrl.d.ts.map +1 -0
  111. package/dist/pipeline/raggedUrl.js +28 -0
  112. package/dist/pipeline/raggedUrl.js.map +1 -0
  113. package/dist/pipeline/resourceNumbering.d.ts +27 -19
  114. package/dist/pipeline/resourceNumbering.d.ts.map +1 -1
  115. package/dist/pipeline/resourceNumbering.js +19 -17
  116. package/dist/pipeline/resourceNumbering.js.map +1 -1
  117. package/dist/types.d.ts +128 -2
  118. package/dist/types.d.ts.map +1 -1
  119. package/dist/vdt.d.ts +27 -1
  120. package/dist/vdt.d.ts.map +1 -1
  121. package/dist/vdt.js.map +1 -1
  122. package/dist/worker/client.d.ts +4 -0
  123. package/dist/worker/client.d.ts.map +1 -1
  124. package/dist/worker/client.js +4 -0
  125. package/dist/worker/client.js.map +1 -1
  126. package/dist/worker/layout.worker.js +14 -0
  127. package/dist/worker/layout.worker.js.map +1 -1
  128. package/dist/worker/protocol.d.ts +5 -0
  129. package/dist/worker/protocol.d.ts.map +1 -1
  130. 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,8 +22,9 @@ 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';
26
- import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, } from './bandCaps';
25
+ import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES } from './columnBalancing';
26
+ import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, bandCapLinesAroundZone, } from './bandCaps';
27
+ import { raggedUrlLines } from './raggedUrl';
27
28
  export class BuildCancelledError extends Error {
28
29
  constructor() {
29
30
  super('Build cancelled');
@@ -106,13 +107,25 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
106
107
  const baselineGrid = computeBaselineGrid(resolved);
107
108
  // Create document
108
109
  const doc = createVDTDocument(resolved, baselineGrid);
110
+ // A chapter laid out after the pages before it: shift parity and carry the
111
+ // counters over (see `PostextContent.continuation`).
112
+ const continuation = content.continuation;
113
+ const pageIndexOffset = Math.max(0, Math.floor(continuation?.pageIndexOffset ?? 0));
114
+ if (pageIndexOffset > 0)
115
+ doc.pageIndexOffset = pageIndexOffset;
116
+ if (continuation?.headings && continuation.headings.h1 > 0)
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;
109
122
  const pageMetrics = computePageMetrics(resolved);
110
123
  const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = pageMetrics;
111
124
  // Page/bleed frames for design elements anchored to `'page'` / `'bleed'`.
112
125
  const designFrames = { page: pageMetrics.trimBox, bleed: pageMetrics.bleedBox };
113
126
  doc.trimOffset = trimOffset;
114
127
  // Create first page
115
- const firstPage = createPageWithColumns(0, resolved, contentArea, pageWidthPx, pageHeightPx);
128
+ const firstPage = createPageWithColumns(0, resolved, contentArea, pageWidthPx, pageHeightPx, pageIndexOffset);
116
129
  doc.pages.push(firstPage);
117
130
  // Extract frontmatter, then parse the remaining markdown body
118
131
  const { metadata: frontmatterMeta, content: markdownBody, contentOffset: bodyOffset } = extractFrontmatter(content.markdown);
@@ -124,15 +137,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
124
137
  headingTemplates[lvl.level] = lvl.numberingTemplate;
125
138
  }
126
139
  }
127
- const headingPrefixes = computeHeadingNumbers(contentBlocks, headingTemplates);
140
+ const headingStart = continuation?.headings;
141
+ const headingPrefixes = computeHeadingNumbers(contentBlocks, headingTemplates, headingStart ? [headingStart.h1, headingStart.h2, headingStart.h3, headingStart.h4, headingStart.h5, headingStart.h6] : undefined);
128
142
  // Resource numbering — computed up front (before the placement loop) so that
129
143
  // captions and inline `:ref`s can resolve their rendered number strings
130
144
  // before measurement. Numbering follows order of first reference in the
131
145
  // document.
132
146
  const resourceTypes = config?.resourceTypes ?? defaultResourceTypes();
133
147
  const resources = content.resources ?? [];
134
- const headingContext = computeHeadingContext(contentBlocks);
135
- const resourceNumbering = computeResourceNumbering(contentBlocks, resourceTypes, resources, headingContext);
148
+ const headingContext = computeHeadingContext(contentBlocks, headingStart);
149
+ const resourceNumbering = computeResourceNumbering(contentBlocks, resourceTypes, resources, headingContext, continuation ? { counters: continuation.resourceCounters, numbered: continuation.resourceNumbers } : undefined);
136
150
  // Lookups threaded into block-kind resolution + measurement.
137
151
  const resourceById = new Map();
138
152
  for (const r of resources)
@@ -533,7 +547,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
533
547
  for (const [spanIndex, cap] of bandCaps) {
534
548
  if (cap.startContentIndex !== contentIndex || cap.startPart !== part)
535
549
  continue;
536
- applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms);
550
+ applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms, cap.zone);
537
551
  activeCap = { spanIndex, pageIndex: page.index, band };
538
552
  bandCapsApplied.add(spanIndex);
539
553
  break;
@@ -550,8 +564,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
550
564
  * the cap delivered when it is reached inside the capped band (nothing
551
565
  * spilled past the cut); the columns are NOT uncapped afterwards, so
552
566
  * column balancing does not stretch them back to the page bottom.
567
+ *
568
+ * A chapter-closing fixed box passes the `zone` it will take at the
569
+ * bottom of the band: when the level cut would run into it, the columns
570
+ * under the box are cut at the zone's top instead and the others take
571
+ * the displaced text (see {@link bandCapLinesAroundZone}) — the box then
572
+ * fits on the page, under its columns, rather than opening a page alone.
553
573
  */
554
- const proposeTrailingCap = (boundaryIndex) => {
574
+ const proposeTrailingCap = (boundaryIndex, zone) => {
555
575
  const balancingCfg = resolved.headings.balancing;
556
576
  if (!balancingCfg.enabled || !balancingCfg.trailing)
557
577
  return;
@@ -576,14 +596,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
576
596
  if (!bandStart || !registeredBand || registeredBand.pageIndex !== page.index || registeredBand.band !== band)
577
597
  return;
578
598
  const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
579
- if (Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5)
599
+ const aroundZone = zone ? bandCapLinesAroundZone(cols, baselineGrid, zone, (c) => columnBottom(c, uncappedBottoms)) : null;
600
+ if (aroundZone === null && Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5)
580
601
  return;
581
602
  bandCapProposals.set(boundaryIndex, {
582
603
  kind: 'trailing',
583
604
  startContentIndex: bandStart.contentIndex,
584
605
  startPart: bandStart.part,
585
- lines: bandCapLines(cols, baselineGrid),
606
+ lines: aroundZone ?? bandCapLines(cols, baselineGrid),
586
607
  retries: 0,
608
+ ...(aroundZone !== null ? { zone } : {}),
587
609
  });
588
610
  };
589
611
  /** Close the flow at a chapter-level boundary (block `boundaryIndex`):
@@ -617,11 +639,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
617
639
  // Page-numbering segments. The implicit first segment comes from
618
640
  // `cfg.page.pageNumbering`; `:::numbering` directives append more,
619
641
  // each applied at the next page boundary.
642
+ // A continued document starts where the previous page left off.
620
643
  const pageNumberSegments = [
621
644
  {
622
645
  startPageIndex: 0,
623
- format: resolved.page.pageNumbering.format,
624
- startAt: resolved.page.pageNumbering.startAt,
646
+ format: continuation?.pageNumbering?.format ?? resolved.page.pageNumbering.format,
647
+ startAt: continuation?.pageNumbering?.startAt ?? resolved.page.pageNumbering.startAt,
625
648
  },
626
649
  ];
627
650
  let pendingNumberingChange = null;
@@ -644,22 +667,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
644
667
  * keep-with-next rollbacks may pull along (a callout is one unbreakable
645
668
  * unit; its children never leave it). */
646
669
  const isFreeHeading = (b) => b.type === 'heading' && b.containerId === undefined;
647
- /** Shared tail of callout placement: stamp the frame's source range,
648
- * convert the laid-out box to absolute coordinates at the frame's placed
649
- * origin, and push frame + children — in that order — to `doc.blocks`
650
- * and to the column the frame landed in. */
651
- /** Stamp a callout frame's content index and source range (the whole
652
- * fence, opening to closing marker). */
653
- 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) => {
654
675
  const startBlock = contentBlocks[startIdx];
655
676
  const endBlock = contentBlocks[plan.endIdx];
656
677
  frame.contentIndex = startIdx;
657
- frame.sourceStart = startBlock.sourceStart + bodyOffset;
658
- 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;
659
682
  };
660
- 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) => {
661
688
  const frame = result.frame;
662
- stampCalloutSource(frame, startIdx, plan);
689
+ stampCalloutSource(frame, startIdx, plan, range);
663
690
  offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
664
691
  doc.blocks.push(frame);
665
692
  for (const child of result.children) {
@@ -669,6 +696,77 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
669
696
  doc.blocks.push(child);
670
697
  }
671
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
+ };
672
770
  /**
673
771
  * Place a `span: 'page'` `:::callout` in a multi-column layout as a span
674
772
  * block (stage 1): the box is laid out at the page's content width and
@@ -697,6 +795,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
697
795
  * overflowed instead (the box arrives elsewhere), the driver grows or
698
796
  * drops the cap.
699
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
+ *
700
818
  * Geometry stays on the baseline grid: the cut line is the band's used
701
819
  * bottom snapped UP to the next grid line (anchored at the content-area
702
820
  * top, like every column start), and the span column's height is the
@@ -705,69 +823,169 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
705
823
  * stay in the outer page bands: the band inherits the float-reduced top
706
824
  * / bottom of the page's columns, so a span column never overlaps a float.
707
825
  */
708
- const placeCalloutSpan = (startIdx, plan, layoutAt) => {
709
- let page = doc.pages[cursor.pageIndex];
710
- 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;
711
830
  const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
712
831
  const minRoomPx = minLines * bodyStyle.lineHeightPx;
713
- /** Where the box would cut the current band, and whether it fits (room
714
- * is measured against the columns' TRUE bottoms — a capped band keeps
715
- * its slack below the cap). Null when the band is not level (or the
716
- * cursor sits on a span column with no band below it) and
717
- * `requireLevel` is set. */
718
- const measureBand = (requireLevel) => {
719
- const cols = bandColumns(page, currentBand(page, cursor));
720
- if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
721
- return null;
722
- const usedBottom = bandUsedBottom(cols);
723
- const cutY = page.contentArea.y
724
- + Math.ceil((usedBottom - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
725
- const bandHasContent = cols.some((c) => c.blocks.length > 0);
726
- const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
727
- const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
728
- const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
729
- const room = cutY + need + minRoomPx <= bandBottom + 0.01;
730
- 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;
731
837
  };
732
- // Is this band capped for this very box? Then it cuts at the band's
733
- // used bottom (at most the cap) even when the last column is short.
734
- const cap = bandCaps?.get(startIdx);
735
- const capActive = cap !== undefined
736
- && activeCap !== null
737
- && activeCap.spanIndex === startIdx
738
- && activeCap.pageIndex === page.index
739
- && activeCap.band === currentBand(page, cursor);
740
- let fit = measureBand(!capActive);
741
- if (fit?.room && capActive) {
742
- uncapBand(fit.cols, uncappedBottoms);
743
- spanPlacedInBand.add(startIdx);
744
- }
745
- if (!fit && cap === undefined && bandStart && registeredBand
746
- && registeredBand.pageIndex === page.index
747
- && registeredBand.band === currentBand(page, cursor)) {
748
- // Uneven band, no cap yet: propose one when a level cut would leave
749
- // room for the box plus the widow minimum of body lines below it.
750
- const cols = bandColumns(page, currentBand(page, cursor));
751
- const lines = bandCapLines(cols, baselineGrid);
752
- const capBottom = bandTop(cols) + lines * baselineGrid;
753
- const spacing = Math.max(pendingSpacing, result.marginTopPx);
754
- const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
755
- const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
756
- if (capBottom + need + minRoomPx <= bandBottom + 0.01) {
757
- bandCapProposals.set(startIdx, {
758
- kind: 'span',
759
- startContentIndex: bandStart.contentIndex,
760
- startPart: bandStart.part,
761
- lines,
762
- retries: 0,
763
- });
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
+ }
764
978
  }
765
- }
766
- if (!fit || !fit.room) {
767
979
  // Open the next page (flushing pending floats into its bands). A page
768
980
  // holding only floats counts as occupied here — its float band is what
769
981
  // left no room — but a truly empty page is kept: the box then simply
770
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;
771
989
  const curPage = doc.pages[cursor.pageIndex];
772
990
  if (pageIsOccupied(curPage)) {
773
991
  pendingSpacing = 0;
@@ -776,26 +994,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
776
994
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
777
995
  } while (cursor.pageIndex === startPageIndex);
778
996
  page = doc.pages[cursor.pageIndex];
779
- if (Math.abs(page.contentArea.width - result.width) > 0.01) {
780
- result = layoutAt(page.contentArea.width);
781
- }
782
997
  }
783
- // A freshly opened page is level; force-place (overflow) when the box
784
- // is taller than the page.
785
- fit = measureBand(true) ?? measureBand(false);
786
- 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)
787
1001
  return false; // no text column to cut — leave it to the inline path
1002
+ forceHere = true;
788
1003
  }
789
- const spanCol = closeBandAndInsertSpan(page, fit.cols, fit.cutY, result.frame, fit.need, cursor, fit.spacing, result.totalHeight);
790
- commitCallout(result, startIdx, plan, spanCol);
791
- // Floats first-referenced inside the box enqueue once it is committed,
792
- // in reading order (same as the inline path).
793
- for (let i = startIdx + 1; i <= plan.endIdx; i++)
794
- enqueueFloatsFor(i);
795
- // The new band starts on the grid right below the span column; nothing
796
- // to snap — `need` already bakes in `marginBottom`.
797
- pendingSpacing = 0;
798
- return true;
799
1004
  };
800
1005
  /** Whether the flow ends at a chapter-level boundary right after block
801
1006
  * `from` (skipping container markers): a chapter opener, a `:::part`, a
@@ -846,7 +1051,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
846
1051
  };
847
1052
  const snapDown = (page, v) => page.contentArea.y + Math.floor((v - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
848
1053
  const snapUp = (page, v) => page.contentArea.y + Math.ceil((v - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
849
- const attempt = (page, force) => {
1054
+ /** The box laid out for `page` and the zone it takes there. */
1055
+ const zoneOn = (page) => {
850
1056
  const ref = anchor.to === 'page' ? designFrames.page
851
1057
  : anchor.to === 'bleed' ? designFrames.bleed
852
1058
  : page.contentArea;
@@ -864,10 +1070,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
864
1070
  const rect = anchorBox(anchor.edge, ref, result.width, result.totalHeight, offset);
865
1071
  const zoneTop = snapDown(page, rect.y - result.marginTopPx);
866
1072
  const zoneBottom = snapUp(page, rect.y + rect.height + result.marginBottomPx);
1073
+ const meetsX = (col) => rect.x < col.bbox.x + col.bbox.width - 0.5 && rect.x + rect.width > col.bbox.x + 0.5;
1074
+ return { cols, result, rect, zoneTop, zoneBottom, meetsX };
1075
+ };
1076
+ const attempt = (page, force) => {
1077
+ const { cols, result, rect, zoneTop, zoneBottom, meetsX } = zoneOn(page);
867
1078
  const cuts = [];
868
1079
  for (const col of cols) {
869
- const meetsX = rect.x < col.bbox.x + col.bbox.width - 0.5 && rect.x + rect.width > col.bbox.x + 0.5;
870
- if (!meetsX)
1080
+ if (!meetsX(col))
871
1081
  continue;
872
1082
  const colTop = col.bbox.y;
873
1083
  const colBottom = trueBottom(col, uncappedBottoms);
@@ -903,10 +1113,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
903
1113
  }
904
1114
  return { rect, result, cuts };
905
1115
  };
906
- // A box that closes the chapter levels the columns above it first.
1116
+ // A box that closes the chapter levels the columns above it first —
1117
+ // around the zone it takes on the current page.
907
1118
  if (nextIsBarrier(plan.endIdx + 1)) {
908
1119
  tryPlacePendingFloatsOnCurrentPage();
909
- proposeTrailingCap(startIdx);
1120
+ const here = zoneOn(doc.pages[cursor.pageIndex]);
1121
+ const columns = here.cols.flatMap((c, i) => (here.meetsX(c) ? [i] : []));
1122
+ proposeTrailingCap(startIdx, columns.length > 0 ? { top: here.zoneTop, columns } : undefined);
910
1123
  }
911
1124
  let page = doc.pages[cursor.pageIndex];
912
1125
  let fit = attempt(page, false);
@@ -965,8 +1178,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
965
1178
  * baked into the post-box grid snap. A box that does not fit moves to the
966
1179
  * next column/page (like a resource), pulling a run of trailing headings
967
1180
  * along (keep-with-next); a box taller than an empty column is placed
968
- * anyway and overflows (the sandbox warns). Returns the content index to
969
- * 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`.
970
1187
  *
971
1188
  * `span: 'page'` boxes in multi-column layouts take the span-block path
972
1189
  * (`placeCalloutSpan`) instead; `placement: 'top' | 'bottom'` (floating
@@ -975,25 +1192,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
975
1192
  */
976
1193
  const placeCalloutInline = (startIdx, plan) => {
977
1194
  const style = pickCalloutStyle(resolved.calloutStyles, plan.attrs.type);
978
- const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
979
- const frameId = `block-${blockIdCounter++}`;
1195
+ const firstFrameId = `block-${blockIdCounter++}`;
980
1196
  const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
981
- const layoutAt = (width) => {
982
- let n = 0;
983
- return layoutCallout({
984
- style,
985
- attrs: plan.attrs,
986
- children,
987
- childStartIdx: startIdx + 1,
988
- width,
989
- ctx: measureCtx,
990
- resolved,
991
- containerId: plan.containerId,
992
- frameId,
993
- nextChildId: () => `${frameId}-c${n++}`,
994
- paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
995
- });
996
- };
1197
+ const L = makeCalloutLayouter(startIdx, plan, style);
1198
+ const layoutAt = (width) => L.layoutRange(0, L.children.length, width, firstFrameId, false);
997
1199
  // Fixed boxes leave the flow entirely.
998
1200
  if (placement === 'fixed') {
999
1201
  placeCalloutFixed(startIdx, plan, style, layoutAt);
@@ -1006,65 +1208,93 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1006
1208
  if (span === 'page'
1007
1209
  && placement === 'here'
1008
1210
  && bandColumns(page, currentBand(page, cursor)).length > 1
1009
- && placeCalloutSpan(startIdx, plan, layoutAt)) {
1211
+ && placeCalloutSpan(startIdx, plan, style, L, firstFrameId)) {
1010
1212
  return undefined;
1011
1213
  }
1012
1214
  }
1013
- let curCol = currentColumn(doc, cursor);
1014
- let result = layoutAt(curCol.bbox.width);
1015
- if (curCol.blocks.length > 0) {
1016
- const spacingBefore = Math.max(pendingSpacing, result.marginTopPx);
1017
- if (result.totalHeight > curCol.availableHeight - spacingBefore) {
1018
- // Keep-with-next: a run of headings at the column's tail travels
1019
- // with the box. Skipped when the column holds nothing else (rolling
1020
- // back again would loop) — the headings stay, orphaned.
1021
- let run = 0;
1022
- for (let j = curCol.blocks.length - 1; j >= 0; j--) {
1023
- if (isFreeHeading(curCol.blocks[j]))
1024
- run++;
1025
- else
1026
- break;
1027
- }
1028
- pendingSpacing = 0;
1029
- if (resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
1030
- 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
+ }
1031
1251
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1032
- return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
1033
- }
1034
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1035
- curCol = currentColumn(doc, cursor);
1036
- // Columns of different widths (oneAndHalf): re-lay out for the new one.
1037
- if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
1038
- 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
+ }
1039
1257
  }
1258
+ // An empty column that is still too short: placed anyway, overflowing.
1040
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);
1041
1292
  }
1042
- // Floats first-referenced inside the box still enqueue in reading order
1043
- // (only once the box is committed, so a keep-with-next replay does not
1044
- // enqueue them twice).
1045
- for (let i = startIdx + 1; i <= plan.endIdx; i++)
1046
- enqueueFloatsFor(i);
1047
- const frame = result.frame;
1048
- const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
1049
- enterBand(startIdx, 0);
1050
- placeAtomicBlock(frame, result.totalHeight, spacing, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
1051
- enterBand(startIdx, 0);
1052
- curCol = currentColumn(doc, cursor);
1053
- commitCallout(result, startIdx, plan, curCol);
1054
- // Snap the flow after the box to the baseline grid, baking in at least
1055
- // `marginBottom` (grid wins, margin is a minimum — the resource rule).
1056
- {
1057
- const usedHeight = curCol.bbox.height - curCol.availableHeight;
1058
- const naturalBottom = usedHeight + result.marginBottomPx;
1059
- const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
1060
- curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
1061
- }
1062
- pendingSpacing = 0;
1063
- return undefined;
1064
1293
  };
1065
1294
  for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
1066
1295
  if (options?.shouldCancel?.())
1067
1296
  throw new BuildCancelledError();
1297
+ options?.onProgress?.({ pass: 1, blocks: blockIdx, totalBlocks: contentBlocks.length, pages: doc.pages.length });
1068
1298
  const rawBlock = contentBlocks[blockIdx];
1069
1299
  // Floats whose reference landed in an earlier iteration take the first
1070
1300
  // free slot of the current page now — after their reference in reading
@@ -1155,9 +1385,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1155
1385
  createPartPage(doc.pages[cursor.pageIndex], pageMetrics, resolved, {
1156
1386
  number: plan.number,
1157
1387
  title: plan.title,
1388
+ palette: plan.palette,
1158
1389
  titleSourceStart,
1159
1390
  titleSourceEnd,
1160
- });
1391
+ }, pageIndexOffset);
1161
1392
  flushPendingNumberingAtBoundary();
1162
1393
  continue;
1163
1394
  }
@@ -1380,7 +1611,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1380
1611
  // List items may split too — orphan/widow protection per-list is gated by
1381
1612
  // `avoidOrphansInLists` / `avoidWidowsInLists`; bullet stays on first part.
1382
1613
  const canSplit = vdtType === 'paragraph' || vdtType === 'blockquote' || vdtType === 'listItem';
1383
- let remainingLines = [...measured.lines];
1614
+ // A justified line a link leaves with too few spaces is set ragged.
1615
+ let remainingLines = [...raggedUrlLines(measured.lines, style.textAlign, rawBlock.text)];
1384
1616
  let partIndex = 0;
1385
1617
  // "Keep with next" for colon-introduced lists: a paragraph ending in `:`
1386
1618
  // followed directly by a list acts as a lead-in title — the colon-bearing
@@ -1831,7 +2063,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1831
2063
  return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome };
1832
2064
  }
1833
2065
  export function buildDocument(content, config, cache, options) {
1834
- const runPass = (hints) => buildDocumentPass(content, config, cache, options, hints);
2066
+ // Each pass reports its own progress, numbered in build order.
2067
+ let passIndex = 0;
2068
+ const onProgress = options?.onProgress;
2069
+ const passOptions = onProgress
2070
+ ? { ...options, onProgress: (p) => onProgress({ ...p, pass: passIndex }) }
2071
+ : options;
2072
+ const runPass = (hints) => {
2073
+ passIndex++;
2074
+ return buildDocumentPass(content, config, cache, passOptions, hints);
2075
+ };
1835
2076
  // --- Band caps (page-span blocks mid-page) -----------------------------
1836
2077
  // A span block that arrived in an uneven band proposes a cap; the driver
1837
2078
  // re-places the document with it (and grows / drops caps whose band