postext 0.3.19 → 0.3.20

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 (259) hide show
  1. package/dist/__tests__/defaults/calloutStyles.test.d.ts +2 -0
  2. package/dist/__tests__/defaults/calloutStyles.test.d.ts.map +1 -0
  3. package/dist/__tests__/defaults/calloutStyles.test.js +201 -0
  4. package/dist/__tests__/defaults/calloutStyles.test.js.map +1 -0
  5. package/dist/__tests__/defaults/captionStyle.test.d.ts +2 -0
  6. package/dist/__tests__/defaults/captionStyle.test.d.ts.map +1 -0
  7. package/dist/__tests__/defaults/captionStyle.test.js +87 -0
  8. package/dist/__tests__/defaults/captionStyle.test.js.map +1 -0
  9. package/dist/__tests__/defaults/pageSizePreset.test.d.ts +2 -0
  10. package/dist/__tests__/defaults/pageSizePreset.test.d.ts.map +1 -0
  11. package/dist/__tests__/defaults/pageSizePreset.test.js +24 -0
  12. package/dist/__tests__/defaults/pageSizePreset.test.js.map +1 -0
  13. package/dist/__tests__/defaults/paragraphStyles.test.d.ts +2 -0
  14. package/dist/__tests__/defaults/paragraphStyles.test.d.ts.map +1 -0
  15. package/dist/__tests__/defaults/paragraphStyles.test.js +93 -0
  16. package/dist/__tests__/defaults/paragraphStyles.test.js.map +1 -0
  17. package/dist/__tests__/design/pageAnchors.test.d.ts +2 -0
  18. package/dist/__tests__/design/pageAnchors.test.d.ts.map +1 -0
  19. package/dist/__tests__/design/pageAnchors.test.js +140 -0
  20. package/dist/__tests__/design/pageAnchors.test.js.map +1 -0
  21. package/dist/__tests__/directives.test.js +58 -0
  22. package/dist/__tests__/directives.test.js.map +1 -1
  23. package/dist/__tests__/exports.test.js +19 -0
  24. package/dist/__tests__/exports.test.js.map +1 -1
  25. package/dist/__tests__/mirroredMargins.test.d.ts +2 -0
  26. package/dist/__tests__/mirroredMargins.test.d.ts.map +1 -0
  27. package/dist/__tests__/mirroredMargins.test.js +136 -0
  28. package/dist/__tests__/mirroredMargins.test.js.map +1 -0
  29. package/dist/__tests__/numbering.test.js +54 -0
  30. package/dist/__tests__/numbering.test.js.map +1 -1
  31. package/dist/__tests__/openerTitleSource.test.d.ts +2 -0
  32. package/dist/__tests__/openerTitleSource.test.d.ts.map +1 -0
  33. package/dist/__tests__/openerTitleSource.test.js +108 -0
  34. package/dist/__tests__/openerTitleSource.test.js.map +1 -0
  35. package/dist/__tests__/pageRoles.test.d.ts +2 -0
  36. package/dist/__tests__/pageRoles.test.d.ts.map +1 -0
  37. package/dist/__tests__/pageRoles.test.js +95 -0
  38. package/dist/__tests__/pageRoles.test.js.map +1 -0
  39. package/dist/__tests__/parse/containers.test.d.ts +2 -0
  40. package/dist/__tests__/parse/containers.test.d.ts.map +1 -0
  41. package/dist/__tests__/parse/containers.test.js +204 -0
  42. package/dist/__tests__/parse/containers.test.js.map +1 -0
  43. package/dist/__tests__/parse/headingAttrs.test.d.ts +2 -0
  44. package/dist/__tests__/parse/headingAttrs.test.d.ts.map +1 -0
  45. package/dist/__tests__/parse/headingAttrs.test.js +62 -0
  46. package/dist/__tests__/parse/headingAttrs.test.js.map +1 -0
  47. package/dist/__tests__/parse/inlineRef.test.js +26 -0
  48. package/dist/__tests__/parse/inlineRef.test.js.map +1 -1
  49. package/dist/__tests__/parts.test.d.ts +2 -0
  50. package/dist/__tests__/parts.test.d.ts.map +1 -0
  51. package/dist/__tests__/parts.test.js +405 -0
  52. package/dist/__tests__/parts.test.js.map +1 -0
  53. package/dist/__tests__/pipeline/calloutLayout.test.d.ts +2 -0
  54. package/dist/__tests__/pipeline/calloutLayout.test.d.ts.map +1 -0
  55. package/dist/__tests__/pipeline/calloutLayout.test.js +273 -0
  56. package/dist/__tests__/pipeline/calloutLayout.test.js.map +1 -0
  57. package/dist/__tests__/pipeline/calloutPlacement.test.d.ts +2 -0
  58. package/dist/__tests__/pipeline/calloutPlacement.test.d.ts.map +1 -0
  59. package/dist/__tests__/pipeline/calloutPlacement.test.js +239 -0
  60. package/dist/__tests__/pipeline/calloutPlacement.test.js.map +1 -0
  61. package/dist/__tests__/pipeline/captionLayout.test.d.ts +2 -0
  62. package/dist/__tests__/pipeline/captionLayout.test.d.ts.map +1 -0
  63. package/dist/__tests__/pipeline/captionLayout.test.js +269 -0
  64. package/dist/__tests__/pipeline/captionLayout.test.js.map +1 -0
  65. package/dist/__tests__/pipeline/inlineRefRender.test.js +53 -0
  66. package/dist/__tests__/pipeline/inlineRefRender.test.js.map +1 -1
  67. package/dist/__tests__/pipeline/orderedSeparator.test.d.ts +2 -0
  68. package/dist/__tests__/pipeline/orderedSeparator.test.d.ts.map +1 -0
  69. package/dist/__tests__/pipeline/orderedSeparator.test.js +196 -0
  70. package/dist/__tests__/pipeline/orderedSeparator.test.js.map +1 -0
  71. package/dist/__tests__/pipeline/paragraphStyles.test.d.ts +2 -0
  72. package/dist/__tests__/pipeline/paragraphStyles.test.d.ts.map +1 -0
  73. package/dist/__tests__/pipeline/paragraphStyles.test.js +172 -0
  74. package/dist/__tests__/pipeline/paragraphStyles.test.js.map +1 -0
  75. package/dist/__tests__/pipeline/spanBands.test.d.ts +2 -0
  76. package/dist/__tests__/pipeline/spanBands.test.d.ts.map +1 -0
  77. package/dist/__tests__/pipeline/spanBands.test.js +303 -0
  78. package/dist/__tests__/pipeline/spanBands.test.js.map +1 -0
  79. package/dist/__tests__/pipeline/spanBlocks.test.d.ts +2 -0
  80. package/dist/__tests__/pipeline/spanBlocks.test.d.ts.map +1 -0
  81. package/dist/__tests__/pipeline/spanBlocks.test.js +407 -0
  82. package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -0
  83. package/dist/__tests__/placeholders.test.d.ts +2 -0
  84. package/dist/__tests__/placeholders.test.d.ts.map +1 -0
  85. package/dist/__tests__/placeholders.test.js +130 -0
  86. package/dist/__tests__/placeholders.test.js.map +1 -0
  87. package/dist/__tests__/table/model.test.js +24 -0
  88. package/dist/__tests__/table/model.test.js.map +1 -1
  89. package/dist/canvas-backend/blockRender.d.ts.map +1 -1
  90. package/dist/canvas-backend/blockRender.js +13 -1
  91. package/dist/canvas-backend/blockRender.js.map +1 -1
  92. package/dist/canvas-backend/decorations.d.ts +3 -0
  93. package/dist/canvas-backend/decorations.d.ts.map +1 -1
  94. package/dist/canvas-backend/decorations.js +14 -8
  95. package/dist/canvas-backend/decorations.js.map +1 -1
  96. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  97. package/dist/canvas-backend/headerFooter.js +23 -0
  98. package/dist/canvas-backend/headerFooter.js.map +1 -1
  99. package/dist/canvas-backend/index.d.ts.map +1 -1
  100. package/dist/canvas-backend/index.js +4 -2
  101. package/dist/canvas-backend/index.js.map +1 -1
  102. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
  103. package/dist/canvas-backend/renderResourceBlock.js +33 -5
  104. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  105. package/dist/columnRule.d.ts +19 -0
  106. package/dist/columnRule.d.ts.map +1 -0
  107. package/dist/columnRule.js +36 -0
  108. package/dist/columnRule.js.map +1 -0
  109. package/dist/defaults/calloutStyles.d.ts +61 -0
  110. package/dist/defaults/calloutStyles.d.ts.map +1 -0
  111. package/dist/defaults/calloutStyles.js +292 -0
  112. package/dist/defaults/calloutStyles.js.map +1 -0
  113. package/dist/defaults/captionStyle.d.ts +11 -1
  114. package/dist/defaults/captionStyle.d.ts.map +1 -1
  115. package/dist/defaults/captionStyle.js +127 -2
  116. package/dist/defaults/captionStyle.js.map +1 -1
  117. package/dist/defaults/headerFooter.d.ts.map +1 -1
  118. package/dist/defaults/headerFooter.js +3 -0
  119. package/dist/defaults/headerFooter.js.map +1 -1
  120. package/dist/defaults/headings.d.ts.map +1 -1
  121. package/dist/defaults/headings.js +12 -6
  122. package/dist/defaults/headings.js.map +1 -1
  123. package/dist/defaults/index.d.ts +4 -1
  124. package/dist/defaults/index.d.ts.map +1 -1
  125. package/dist/defaults/index.js +28 -1
  126. package/dist/defaults/index.js.map +1 -1
  127. package/dist/defaults/orderedLists.d.ts +9 -8
  128. package/dist/defaults/orderedLists.d.ts.map +1 -1
  129. package/dist/defaults/orderedLists.js +66 -4
  130. package/dist/defaults/orderedLists.js.map +1 -1
  131. package/dist/defaults/page.d.ts.map +1 -1
  132. package/dist/defaults/page.js +14 -3
  133. package/dist/defaults/page.js.map +1 -1
  134. package/dist/defaults/paragraphStyles.d.ts +10 -0
  135. package/dist/defaults/paragraphStyles.d.ts.map +1 -0
  136. package/dist/defaults/paragraphStyles.js +67 -0
  137. package/dist/defaults/paragraphStyles.js.map +1 -0
  138. package/dist/defaults/parts.d.ts +22 -0
  139. package/dist/defaults/parts.d.ts.map +1 -0
  140. package/dist/defaults/parts.js +139 -0
  141. package/dist/defaults/parts.js.map +1 -0
  142. package/dist/defaults/shared.d.ts.map +1 -1
  143. package/dist/defaults/shared.js +110 -17
  144. package/dist/defaults/shared.js.map +1 -1
  145. package/dist/defaults/tableStyle.d.ts.map +1 -1
  146. package/dist/defaults/tableStyle.js +6 -0
  147. package/dist/defaults/tableStyle.js.map +1 -1
  148. package/dist/design/layout.d.ts +27 -1
  149. package/dist/design/layout.d.ts.map +1 -1
  150. package/dist/design/layout.js +39 -8
  151. package/dist/design/layout.js.map +1 -1
  152. package/dist/design/placeholders.d.ts +21 -4
  153. package/dist/design/placeholders.d.ts.map +1 -1
  154. package/dist/design/placeholders.js +38 -7
  155. package/dist/design/placeholders.js.map +1 -1
  156. package/dist/html-backend.d.ts.map +1 -1
  157. package/dist/html-backend.js +71 -16
  158. package/dist/html-backend.js.map +1 -1
  159. package/dist/index.d.ts +13 -9
  160. package/dist/index.d.ts.map +1 -1
  161. package/dist/index.js +7 -4
  162. package/dist/index.js.map +1 -1
  163. package/dist/parse/attrs.d.ts +10 -0
  164. package/dist/parse/attrs.d.ts.map +1 -0
  165. package/dist/parse/attrs.js +20 -0
  166. package/dist/parse/attrs.js.map +1 -0
  167. package/dist/parse/blockParser.d.ts +8 -4
  168. package/dist/parse/blockParser.d.ts.map +1 -1
  169. package/dist/parse/blockParser.js +134 -20
  170. package/dist/parse/blockParser.js.map +1 -1
  171. package/dist/parse/index.d.ts +2 -2
  172. package/dist/parse/index.d.ts.map +1 -1
  173. package/dist/parse/index.js +1 -1
  174. package/dist/parse/index.js.map +1 -1
  175. package/dist/parse/inlineFormatting.d.ts +17 -1
  176. package/dist/parse/inlineFormatting.d.ts.map +1 -1
  177. package/dist/parse/inlineFormatting.js +56 -7
  178. package/dist/parse/inlineFormatting.js.map +1 -1
  179. package/dist/parse/sourceMapping.d.ts.map +1 -1
  180. package/dist/parse/sourceMapping.js +13 -1
  181. package/dist/parse/sourceMapping.js.map +1 -1
  182. package/dist/parse/types.d.ts +55 -5
  183. package/dist/parse/types.d.ts.map +1 -1
  184. package/dist/pipeline/bandCaps.d.ts +86 -0
  185. package/dist/pipeline/bandCaps.d.ts.map +1 -0
  186. package/dist/pipeline/bandCaps.js +142 -0
  187. package/dist/pipeline/bandCaps.js.map +1 -0
  188. package/dist/pipeline/build.d.ts +32 -0
  189. package/dist/pipeline/build.d.ts.map +1 -1
  190. package/dist/pipeline/build.js +589 -159
  191. package/dist/pipeline/build.js.map +1 -1
  192. package/dist/pipeline/buildBlockKind.d.ts +8 -0
  193. package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
  194. package/dist/pipeline/buildBlockKind.js +40 -11
  195. package/dist/pipeline/buildBlockKind.js.map +1 -1
  196. package/dist/pipeline/buildHelpers.d.ts +23 -1
  197. package/dist/pipeline/buildHelpers.d.ts.map +1 -1
  198. package/dist/pipeline/buildHelpers.js +50 -6
  199. package/dist/pipeline/buildHelpers.js.map +1 -1
  200. package/dist/pipeline/calloutLayout.d.ts +107 -0
  201. package/dist/pipeline/calloutLayout.d.ts.map +1 -0
  202. package/dist/pipeline/calloutLayout.js +498 -0
  203. package/dist/pipeline/calloutLayout.js.map +1 -0
  204. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  205. package/dist/pipeline/columnBalancing.js +22 -2
  206. package/dist/pipeline/columnBalancing.js.map +1 -1
  207. package/dist/pipeline/config.d.ts.map +1 -1
  208. package/dist/pipeline/config.js +17 -5
  209. package/dist/pipeline/config.js.map +1 -1
  210. package/dist/pipeline/headerFooter.d.ts +37 -2
  211. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  212. package/dist/pipeline/headerFooter.js +231 -15
  213. package/dist/pipeline/headerFooter.js.map +1 -1
  214. package/dist/pipeline/lists.d.ts +15 -0
  215. package/dist/pipeline/lists.d.ts.map +1 -1
  216. package/dist/pipeline/lists.js +73 -16
  217. package/dist/pipeline/lists.js.map +1 -1
  218. package/dist/pipeline/measureContentBlock.d.ts +59 -0
  219. package/dist/pipeline/measureContentBlock.d.ts.map +1 -0
  220. package/dist/pipeline/measureContentBlock.js +124 -0
  221. package/dist/pipeline/measureContentBlock.js.map +1 -0
  222. package/dist/pipeline/pageRoles.d.ts +23 -0
  223. package/dist/pipeline/pageRoles.d.ts.map +1 -0
  224. package/dist/pipeline/pageRoles.js +54 -0
  225. package/dist/pipeline/pageRoles.js.map +1 -0
  226. package/dist/pipeline/paragraphContainers.d.ts +32 -0
  227. package/dist/pipeline/paragraphContainers.d.ts.map +1 -0
  228. package/dist/pipeline/paragraphContainers.js +61 -0
  229. package/dist/pipeline/paragraphContainers.js.map +1 -0
  230. package/dist/pipeline/parts.d.ts +47 -0
  231. package/dist/pipeline/parts.d.ts.map +1 -0
  232. package/dist/pipeline/parts.js +230 -0
  233. package/dist/pipeline/parts.js.map +1 -0
  234. package/dist/pipeline/placeholders.d.ts +46 -1
  235. package/dist/pipeline/placeholders.d.ts.map +1 -1
  236. package/dist/pipeline/placeholders.js +93 -11
  237. package/dist/pipeline/placeholders.js.map +1 -1
  238. package/dist/pipeline/placement.d.ts +61 -7
  239. package/dist/pipeline/placement.d.ts.map +1 -1
  240. package/dist/pipeline/placement.js +151 -12
  241. package/dist/pipeline/placement.js.map +1 -1
  242. package/dist/pipeline/resourceLayout.d.ts +18 -5
  243. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  244. package/dist/pipeline/resourceLayout.js +145 -23
  245. package/dist/pipeline/resourceLayout.js.map +1 -1
  246. package/dist/pipeline/styles.d.ts +9 -1
  247. package/dist/pipeline/styles.d.ts.map +1 -1
  248. package/dist/pipeline/styles.js +50 -0
  249. package/dist/pipeline/styles.js.map +1 -1
  250. package/dist/table/model.d.ts.map +1 -1
  251. package/dist/table/model.js +16 -2
  252. package/dist/table/model.js.map +1 -1
  253. package/dist/types.d.ts +454 -2
  254. package/dist/types.d.ts.map +1 -1
  255. package/dist/vdt.d.ts +128 -4
  256. package/dist/vdt.d.ts.map +1 -1
  257. package/dist/vdt.js +2 -1
  258. package/dist/vdt.js.map +1 -1
  259. package/package.json +8 -8
@@ -1,3 +1,4 @@
1
+ import { applyTitleBreaks } from '../parse/inlineFormatting';
1
2
  import { dimensionToPx } from '../units';
2
3
  import { createVDTDocument, createVDTBlock, createBoundingBox, } from '../vdt';
3
4
  import { parseMarkdownMemo } from '../parse';
@@ -7,17 +8,20 @@ import { initHyphenator } from '../measure';
7
8
  import { resolveAllConfig, computeBaselineGrid, buildHeadingLevelMap } from './config';
8
9
  import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
9
10
  import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
10
- import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeResourceBlock, } from './placement';
11
+ import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
11
12
  import { chooseParagraphSplit } from './orphanWidow';
12
- import { applyStyleAttrs, computeMeasureViewport, computePageMetrics, enrichMathSpans, rollbackTrailingBlocks, stampSourceRanges, } from './buildHelpers';
13
- import { resolveBlockKind } from './buildBlockKind';
14
- import { runMeasurement } from './buildMeasurement';
15
- import { resolveRefSpans, layoutResourceBlock } from './resourceLayout';
13
+ import { applyStyleAttrs, computePageMetrics, nextNonMarkerBlock, prevNonMarkerBlock, rollbackTrailingBlocks, } from './buildHelpers';
14
+ import { measureContentBlock } from './measureContentBlock';
15
+ import { planParagraphContainers } from './paragraphContainers';
16
+ import { planParts, derivePartMeasureContext } from './parts';
17
+ import { layoutCallout, offsetCalloutToAbsolute, pickCalloutStyle, planCallouts, resolveCalloutAttrs, } from './calloutLayout';
18
+ import { layoutResourceBlock } from './resourceLayout';
16
19
  import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
17
20
  import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
18
21
  import { defaultResourceTypes } from '../defaults/resourceTypes';
19
22
  import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
20
23
  import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES, } from './columnBalancing';
24
+ import { applyBandCap, uncapBand, columnBottom, bandCapLines, resolveBandCaps, } from './bandCaps';
21
25
  export class BuildCancelledError extends Error {
22
26
  constructor() {
23
27
  super('Build cancelled');
@@ -33,25 +37,35 @@ const ALLOWED_PAGE_FORMATS = new Set([
33
37
  'upper-alpha',
34
38
  ]);
35
39
  /**
36
- * Single placement pass. `balanceExtraPx` carries the column-balancing
37
- * adjustments (extra top spacing per heading, keyed by content-block index);
38
- * `forcedBreakPages` reports the pages whose break into the next page was
39
- * explicit (`:::pagebreak`, heading `breakBefore`, chapter opener) rather
40
- * than natural content overflow those pages keep their short last column.
40
+ * Single placement pass. `hints` carries the cross-pass adjustments: the
41
+ * column-balancing spacing / looseness and the band caps of page-span
42
+ * blocks. Besides the document it reports `forcedBreakPages` (for
43
+ * balancing) and the band-cap bookkeeping the driver in `buildDocument`
44
+ * needs new cap proposals, the span blocks that landed in their capped
45
+ * band, and the caps that were actually applied.
46
+ *
47
+ * @internal Exposed for tests only; use `buildDocument`.
41
48
  */
42
- function buildDocumentPass(content, config, cache, options, balanceExtraPx, balanceLooseness) {
49
+ export function buildDocumentPass(content, config, cache, options, hints = {}) {
50
+ const { balanceExtraPx, balanceLooseness, bandCaps } = hints;
43
51
  const resolved = resolveAllConfig(config);
44
52
  const headingLevelByNumber = buildHeadingLevelMap(resolved);
45
53
  const dpi = resolved.page.dpi;
46
- // Initialize hyphenator if needed
47
- if (resolved.bodyText.hyphenation.enabled && resolved.bodyText.textAlign === 'justify') {
54
+ // Initialize hyphenator if needed (body text, or any justified paragraph
55
+ // style that hyphenates they share the document locale).
56
+ const needsHyphenator = (resolved.bodyText.hyphenation.enabled && resolved.bodyText.textAlign === 'justify')
57
+ || resolved.paragraphStyles.some((s) => s.hyphenation && s.textAlign === 'justify');
58
+ if (needsHyphenator) {
48
59
  initHyphenator(resolved.bodyText.hyphenation.locale);
49
60
  }
50
61
  // Compute baseline grid
51
62
  const baselineGrid = computeBaselineGrid(resolved);
52
63
  // Create document
53
64
  const doc = createVDTDocument(resolved, baselineGrid);
54
- const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = computePageMetrics(resolved);
65
+ const pageMetrics = computePageMetrics(resolved);
66
+ const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = pageMetrics;
67
+ // Page/bleed frames for design elements anchored to `'page'` / `'bleed'`.
68
+ const designFrames = { page: pageMetrics.trimBox, bleed: pageMetrics.bleedBox };
55
69
  doc.trimOffset = trimOffset;
56
70
  // Create first page
57
71
  const firstPage = createPageWithColumns(0, resolved, contentArea, pageWidthPx, pageHeightPx);
@@ -92,6 +106,12 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
92
106
  const listLevelIndentsPx = computeLevelIndentsPx(resolved, bodyStyle.fontSizePx);
93
107
  const orderedMetrics = computeOrderedListRunMetrics(contentBlocks, resolved, bodyStyle.fontSizePx);
94
108
  const orderedLevelIndentsPx = computeOrderedLevelIndentsPx(resolved, bodyStyle.fontSizePx, orderedMetrics.maxWidthByDepth);
109
+ // `:::paragraphs{style="…"}` containers, resolved per content-block index.
110
+ const paragraphContainers = planParagraphContainers(contentBlocks, resolved);
111
+ // `:::callout` ranges keyed by their start marker index (same rationale).
112
+ const calloutPlan = planCallouts(contentBlocks);
113
+ // `:::part` ranges: start/end marker indices and the enclosed blocks.
114
+ const partPlan = planParts(contentBlocks);
95
115
  // --- Float planning (issue #49 — resources float to page bands) ----------
96
116
  // A resource is incorporated by its first reference (an inline `:ref` or a
97
117
  // `::resource` directive, whichever comes first in reading order). Floated
@@ -122,6 +142,15 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
122
142
  ln.bbox.y += oy;
123
143
  ln.baseline += oy;
124
144
  }
145
+ for (const ln of rb.noteLines) {
146
+ ln.bbox.x += ox;
147
+ ln.bbox.y += oy;
148
+ ln.baseline += oy;
149
+ }
150
+ if (rb.captionBar) {
151
+ rb.captionBar.rect.x += ox;
152
+ rb.captionBar.rect.y += oy;
153
+ }
125
154
  if (rb.table) {
126
155
  for (const cell of rb.table.cells) {
127
156
  cell.rect.x += ox;
@@ -190,8 +219,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
190
219
  targetCols = [best];
191
220
  }
192
221
  const firstCol = page.columns[targetCols[0]];
193
- const width = pageSpan ? contentArea.width : firstCol.bbox.width;
194
- const xLeft = pageSpan ? contentArea.x : firstCol.bbox.x;
222
+ const width = pageSpan ? page.contentArea.width : firstCol.bbox.width;
223
+ const xLeft = pageSpan ? page.contentArea.x : firstCol.bbox.x;
195
224
  const built = buildFloatBlock(f.resourceId, xLeft, width);
196
225
  if (!built)
197
226
  return 'skip';
@@ -225,8 +254,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
225
254
  const cb = page.columns[c].bbox;
226
255
  return cb.y + cb.height;
227
256
  }));
228
- const gridAlignedBottom = contentArea.y
229
- + Math.floor((bottomLimit - contentArea.y + 0.01) / baselineGrid) * baselineGrid;
257
+ const gridAlignedBottom = page.contentArea.y
258
+ + Math.floor((bottomLimit - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
230
259
  const capLines = built.block.resourceBlock.captionLines;
231
260
  if (capLines.length > 0) {
232
261
  // Body baselines sit at 0.2 × grid above each slot bottom; anchor
@@ -313,6 +342,30 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
313
342
  /** Reserve floats on each freshly opened content page. Passed only to the
314
343
  * content-flow column advances — parity / force-blank pages never get it. */
315
344
  const onNewPage = (page) => flushFloatsIntoPage(page);
345
+ // Everything per-block measurement needs that is constant for this pass.
346
+ const measureCtx = {
347
+ resolved,
348
+ bodyStyle,
349
+ blockquoteStyle,
350
+ headingPrefixes,
351
+ listLevelIndentsPx,
352
+ orderedLevelIndentsPx,
353
+ orderedMetrics,
354
+ resourceById,
355
+ resourceTypeById,
356
+ resourceNumberById,
357
+ contentBlocks,
358
+ cache,
359
+ bodyOffset,
360
+ resources,
361
+ resourceTypes,
362
+ resourceNumbering,
363
+ floatedIds,
364
+ };
365
+ // Blocks inside a `:::part` measure with the part body typography.
366
+ const partMeasureCtx = partPlan.byStart.size > 0
367
+ ? derivePartMeasureContext(measureCtx)
368
+ : measureCtx;
316
369
  // Placement cursor
317
370
  const cursor = { pageIndex: 0, columnIndex: 0 };
318
371
  let blockIdCounter = 0;
@@ -327,6 +380,60 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
327
380
  forcedBreakPages.add(cursor.pageIndex);
328
381
  }
329
382
  };
383
+ // --- Column bands: opening block + band caps (span blocks, stage 2) ----
384
+ // The band the cursor last placed into, its opening block (content index
385
+ // + part: a paragraph split across pages opens the next page's band with
386
+ // its continuation), and the cap applied to it, if any. `enterBand` runs
387
+ // at every placement site right before a block is offered to the current
388
+ // column: the first block offered to a band is its opening block, and if
389
+ // a cap names it the band's columns are shortened to the cap BEFORE the
390
+ // block is placed, so the fit / split / keep-with-next rules see the
391
+ // capped height. Atomic placements that advance internally re-run it
392
+ // after landing (the cap then trims whatever the block left).
393
+ let registeredBand = null;
394
+ let bandStart = null;
395
+ let activeCap = null;
396
+ /** True bottoms of capped columns (restored when the span block cuts). */
397
+ const uncappedBottoms = new Map();
398
+ const bandCapProposals = new Map();
399
+ const spanPlacedInBand = new Set();
400
+ const bandCapsApplied = new Set();
401
+ const enterBand = (contentIndex, part) => {
402
+ const page = doc.pages[cursor.pageIndex];
403
+ const band = currentBand(page, cursor);
404
+ if (registeredBand && registeredBand.pageIndex === page.index && registeredBand.band === band)
405
+ return;
406
+ registeredBand = { pageIndex: page.index, band };
407
+ bandStart = { contentIndex, part };
408
+ activeCap = null;
409
+ if (!bandCaps)
410
+ return;
411
+ for (const [spanIndex, cap] of bandCaps) {
412
+ if (cap.startContentIndex !== contentIndex || cap.startPart !== part)
413
+ continue;
414
+ applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms);
415
+ activeCap = { spanIndex, pageIndex: page.index, band };
416
+ bandCapsApplied.add(spanIndex);
417
+ break;
418
+ }
419
+ };
420
+ /** Parity of the page break a closed `:::part` still owes (applied before
421
+ * the next placed block). */
422
+ let pendingPartBreak = null;
423
+ /** `advanceToNextPageBoundary`, except that an empty part page is left
424
+ * behind too (its opener design is content). No floats are reserved on
425
+ * the page opened this way — parity padding may still follow it. */
426
+ const leaveCurrentPage = () => {
427
+ const curPage = doc.pages[cursor.pageIndex];
428
+ if (curPage.partInfo && !pageHasContent(curPage)) {
429
+ const startPageIndex = cursor.pageIndex;
430
+ do {
431
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
432
+ } while (cursor.pageIndex === startPageIndex);
433
+ return;
434
+ }
435
+ advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
436
+ };
330
437
  // Page-numbering segments. The implicit first segment comes from
331
438
  // `cfg.page.pageNumbering`; `:::numbering` directives append more,
332
439
  // each applied at the next page boundary.
@@ -353,6 +460,262 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
353
460
  lastSeenPageIndex = cursor.pageIndex;
354
461
  }
355
462
  };
463
+ /** Heading blocks that are not part of a callout — the only ones the
464
+ * keep-with-next rollbacks may pull along (a callout is one unbreakable
465
+ * unit; its children never leave it). */
466
+ const isFreeHeading = (b) => b.type === 'heading' && b.containerId === undefined;
467
+ /** Shared tail of callout placement: stamp the frame's source range,
468
+ * convert the laid-out box to absolute coordinates at the frame's placed
469
+ * origin, and push frame + children — in that order — to `doc.blocks`
470
+ * and to the column the frame landed in. */
471
+ const commitCallout = (result, startIdx, plan, col) => {
472
+ const frame = result.frame;
473
+ const startBlock = contentBlocks[startIdx];
474
+ const endBlock = contentBlocks[plan.endIdx];
475
+ frame.contentIndex = startIdx;
476
+ frame.sourceStart = startBlock.sourceStart + bodyOffset;
477
+ frame.sourceEnd = endBlock.sourceEnd + bodyOffset;
478
+ offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
479
+ doc.blocks.push(frame);
480
+ for (const child of result.children) {
481
+ child.pageIndex = frame.pageIndex;
482
+ child.columnIndex = frame.columnIndex;
483
+ col.blocks.push(child);
484
+ doc.blocks.push(child);
485
+ }
486
+ };
487
+ /**
488
+ * Place a `span: 'page'` `:::callout` in a multi-column layout as a span
489
+ * block (stage 1): the box is laid out at the page's content width and
490
+ * gets its own full-width `kind: 'span'` column; the text columns of the
491
+ * current band are closed at the cut line and a fresh band of text columns
492
+ * opens below the box, so the flow continues under it in every column.
493
+ *
494
+ * The band must be LEVEL — every column has consumed the same height:
495
+ * the page top, right after a `span: 'page'` opener heading, right after
496
+ * another span block, or right after a top float band — and leave room
497
+ * for the box plus at least the widow minimum of body lines below it.
498
+ * Otherwise the box moves to the top of the next page (a fresh page is
499
+ * trivially level) WITHOUT marking a forced break, so the page it left
500
+ * stays balanceable. Keep-with-next does not apply here: a heading right
501
+ * before a page-span box stays in its text column.
502
+ *
503
+ * Stage 2 — mid-page bands (`bandCaps.ts`): a box arriving in an UNEVEN
504
+ * band with room below proposes a band cap — the band's columns cut
505
+ * level at `ceil(Σ used / N / grid)` lines — and falls back to the next
506
+ * page for this pass; the driver re-runs the pass with the cap, the
507
+ * capped columns fill and overflow naturally (every placement rule still
508
+ * applies), and when the box arrives in the band its cap was applied to
509
+ * it cuts there: the columns above end level (the last one may keep up
510
+ * to a few lines of slack, which balancing absorbs), the box spans the
511
+ * page and the flow resumes in the band below. When the capped band
512
+ * overflowed instead (the box arrives elsewhere), the driver grows or
513
+ * drops the cap.
514
+ *
515
+ * Geometry stays on the baseline grid: the cut line is the band's used
516
+ * bottom snapped UP to the next grid line (anchored at the content-area
517
+ * top, like every column start), and the span column's height is the
518
+ * box plus its collapsed top spacing and `marginBottom`, rounded up to a
519
+ * grid multiple — so the new band's columns start on the grid. Floats
520
+ * stay in the outer page bands: the band inherits the float-reduced top
521
+ * / bottom of the page's columns, so a span column never overlaps a float.
522
+ */
523
+ const placeCalloutSpan = (startIdx, plan, layoutAt) => {
524
+ let page = doc.pages[cursor.pageIndex];
525
+ let result = layoutAt(page.contentArea.width);
526
+ const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
527
+ const minRoomPx = minLines * bodyStyle.lineHeightPx;
528
+ /** Where the box would cut the current band, and whether it fits (room
529
+ * is measured against the columns' TRUE bottoms — a capped band keeps
530
+ * its slack below the cap). Null when the band is not level (or the
531
+ * cursor sits on a span column with no band below it) and
532
+ * `requireLevel` is set. */
533
+ const measureBand = (requireLevel) => {
534
+ const cols = bandColumns(page, currentBand(page, cursor));
535
+ if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
536
+ return null;
537
+ const usedBottom = bandUsedBottom(cols);
538
+ const cutY = page.contentArea.y
539
+ + Math.ceil((usedBottom - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
540
+ const bandHasContent = cols.some((c) => c.blocks.length > 0);
541
+ const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
542
+ const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
543
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
544
+ const room = cutY + need + minRoomPx <= bandBottom + 0.01;
545
+ return { cols, cutY, need, spacing, room };
546
+ };
547
+ // Is this band capped for this very box? Then it cuts at the band's
548
+ // used bottom (at most the cap) even when the last column is short.
549
+ const cap = bandCaps?.get(startIdx);
550
+ const capActive = cap !== undefined
551
+ && activeCap !== null
552
+ && activeCap.spanIndex === startIdx
553
+ && activeCap.pageIndex === page.index
554
+ && activeCap.band === currentBand(page, cursor);
555
+ let fit = measureBand(!capActive);
556
+ if (fit?.room && capActive) {
557
+ uncapBand(fit.cols, uncappedBottoms);
558
+ spanPlacedInBand.add(startIdx);
559
+ }
560
+ if (!fit && cap === undefined && bandStart && registeredBand
561
+ && registeredBand.pageIndex === page.index
562
+ && registeredBand.band === currentBand(page, cursor)) {
563
+ // Uneven band, no cap yet: propose one when a level cut would leave
564
+ // room for the box plus the widow minimum of body lines below it.
565
+ const cols = bandColumns(page, currentBand(page, cursor));
566
+ const lines = bandCapLines(cols, baselineGrid);
567
+ const capBottom = Math.max(...cols.map((c) => c.bbox.y)) + lines * baselineGrid;
568
+ const spacing = Math.max(pendingSpacing, result.marginTopPx);
569
+ const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
570
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
571
+ if (capBottom + need + minRoomPx <= bandBottom + 0.01) {
572
+ bandCapProposals.set(startIdx, {
573
+ startContentIndex: bandStart.contentIndex,
574
+ startPart: bandStart.part,
575
+ lines,
576
+ retries: 0,
577
+ });
578
+ }
579
+ }
580
+ if (!fit || !fit.room) {
581
+ // Open the next page (flushing pending floats into its bands). A page
582
+ // holding only floats counts as occupied here — its float band is what
583
+ // left no room — but a truly empty page is kept: the box then simply
584
+ // does not fit a page and is force-placed (overflowing, like inline).
585
+ const curPage = doc.pages[cursor.pageIndex];
586
+ if (pageHasContent(curPage) || (curPage.floats?.length ?? 0) > 0) {
587
+ pendingSpacing = 0;
588
+ const startPageIndex = cursor.pageIndex;
589
+ do {
590
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
591
+ } while (cursor.pageIndex === startPageIndex);
592
+ page = doc.pages[cursor.pageIndex];
593
+ if (Math.abs(page.contentArea.width - result.width) > 0.01) {
594
+ result = layoutAt(page.contentArea.width);
595
+ }
596
+ }
597
+ // A freshly opened page is level; force-place (overflow) when the box
598
+ // is taller than the page.
599
+ fit = measureBand(true) ?? measureBand(false);
600
+ if (!fit)
601
+ return false; // no text column to cut — leave it to the inline path
602
+ }
603
+ const spanCol = closeBandAndInsertSpan(page, fit.cols, fit.cutY, result.frame, fit.need, cursor, fit.spacing, result.totalHeight);
604
+ commitCallout(result, startIdx, plan, spanCol);
605
+ // Floats first-referenced inside the box enqueue once it is committed,
606
+ // in reading order (same as the inline path).
607
+ for (let i = startIdx + 1; i <= plan.endIdx; i++) {
608
+ const fl = floatsByFirstBlock.get(i);
609
+ if (fl)
610
+ pendingFloats.push(...fl);
611
+ }
612
+ // The new band starts on the grid right below the span column; nothing
613
+ // to snap — `need` already bakes in `marginBottom`.
614
+ pendingSpacing = 0;
615
+ return true;
616
+ };
617
+ /**
618
+ * Place a `:::callout` inline at the current column width as one atomic
619
+ * unit: the frame block followed by its children in the same column. The
620
+ * box's `marginTop` collapses with the pending spacing; `marginBottom` is
621
+ * baked into the post-box grid snap. A box that does not fit moves to the
622
+ * next column/page (like a resource), pulling a run of trailing headings
623
+ * along (keep-with-next); a box taller than an empty column is placed
624
+ * anyway and overflows (the sandbox warns). Returns the content index to
625
+ * rewind the main loop to when headings were rolled back, else `undefined`.
626
+ *
627
+ * `span: 'page'` boxes in multi-column layouts take the span-block path
628
+ * (`placeCalloutSpan`) instead; `placement: 'top' | 'bottom'` (floating
629
+ * boxes) still fall back to this inline placement for v1 — the frame's
630
+ * `callout.placement` records the request.
631
+ */
632
+ const placeCalloutInline = (startIdx, plan) => {
633
+ const style = pickCalloutStyle(resolved.calloutStyles, plan.attrs.type);
634
+ const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
635
+ const frameId = `block-${blockIdCounter++}`;
636
+ const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
637
+ const layoutAt = (width) => {
638
+ let n = 0;
639
+ return layoutCallout({
640
+ style,
641
+ attrs: plan.attrs,
642
+ children,
643
+ childStartIdx: startIdx + 1,
644
+ width,
645
+ ctx: measureCtx,
646
+ resolved,
647
+ containerId: plan.containerId,
648
+ frameId,
649
+ nextChildId: () => `${frameId}-c${n++}`,
650
+ paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
651
+ });
652
+ };
653
+ // Page-span boxes split a multi-column page into column bands (stage 1
654
+ // of span blocks). Floating placements keep the inline fallback.
655
+ {
656
+ const page = doc.pages[cursor.pageIndex];
657
+ if (span === 'page'
658
+ && placement === 'here'
659
+ && bandColumns(page, currentBand(page, cursor)).length > 1
660
+ && placeCalloutSpan(startIdx, plan, layoutAt)) {
661
+ return undefined;
662
+ }
663
+ }
664
+ let curCol = currentColumn(doc, cursor);
665
+ let result = layoutAt(curCol.bbox.width);
666
+ if (curCol.blocks.length > 0) {
667
+ const spacingBefore = Math.max(pendingSpacing, result.marginTopPx);
668
+ if (result.totalHeight > curCol.availableHeight - spacingBefore) {
669
+ // Keep-with-next: a run of headings at the column's tail travels
670
+ // with the box. Skipped when the column holds nothing else (rolling
671
+ // back again would loop) — the headings stay, orphaned.
672
+ let run = 0;
673
+ for (let j = curCol.blocks.length - 1; j >= 0; j--) {
674
+ if (isFreeHeading(curCol.blocks[j]))
675
+ run++;
676
+ else
677
+ break;
678
+ }
679
+ pendingSpacing = 0;
680
+ if (resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
681
+ const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
682
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
683
+ return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
684
+ }
685
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
686
+ curCol = currentColumn(doc, cursor);
687
+ // Columns of different widths (oneAndHalf): re-lay out for the new one.
688
+ if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
689
+ result = layoutAt(curCol.bbox.width);
690
+ }
691
+ }
692
+ }
693
+ // Floats first-referenced inside the box still enqueue in reading order
694
+ // (only once the box is committed, so a keep-with-next replay does not
695
+ // enqueue them twice).
696
+ for (let i = startIdx + 1; i <= plan.endIdx; i++) {
697
+ const fl = floatsByFirstBlock.get(i);
698
+ if (fl)
699
+ pendingFloats.push(...fl);
700
+ }
701
+ const frame = result.frame;
702
+ const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
703
+ enterBand(startIdx, 0);
704
+ placeAtomicBlock(frame, result.totalHeight, spacing, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
705
+ enterBand(startIdx, 0);
706
+ curCol = currentColumn(doc, cursor);
707
+ commitCallout(result, startIdx, plan, curCol);
708
+ // Snap the flow after the box to the baseline grid, baking in at least
709
+ // `marginBottom` (grid wins, margin is a minimum — the resource rule).
710
+ {
711
+ const usedHeight = curCol.bbox.height - curCol.availableHeight;
712
+ const naturalBottom = usedHeight + result.marginBottomPx;
713
+ const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
714
+ curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
715
+ }
716
+ pendingSpacing = 0;
717
+ return undefined;
718
+ };
356
719
  for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
357
720
  if (options?.shouldCancel?.())
358
721
  throw new BuildCancelledError();
@@ -381,6 +744,22 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
381
744
  }
382
745
  flushPendingNumberingAtBoundary();
383
746
  }
747
+ else if (name === 'columnbreak') {
748
+ // Explicit column break: end the current column here (its bottom
749
+ // gap is intentional, so balancing skips it) and continue in the
750
+ // next column. A no-op in an empty column, so it never opens a
751
+ // blank column or page.
752
+ pendingSpacing = 0;
753
+ const col = currentColumn(doc, cursor);
754
+ if (col.blocks.length > 0) {
755
+ col.forcedBreak = true;
756
+ const page = doc.pages[cursor.pageIndex];
757
+ if (cursor.columnIndex === page.columns.length - 1)
758
+ markForcedBreak();
759
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
760
+ flushPendingNumberingAtBoundary();
761
+ }
762
+ }
384
763
  else if (name === 'numbering') {
385
764
  const change = {};
386
765
  const fmt = attrs.format;
@@ -396,6 +775,92 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
396
775
  }
397
776
  continue;
398
777
  }
778
+ // --- Container markers ---------------------------------------------
779
+ // `:::paragraphs` applies its style's top margin on entry through the
780
+ // pending-spacing mechanism (collapses like any margin, vanishes at a
781
+ // column top). Its bottom margin is normally baked into the last
782
+ // paragraph's grid snap; the pending-spacing fallback covers containers
783
+ // that end with a non-paragraph block. Replaying a marker after a keep-with-next rewind is
784
+ // harmless: the container plan is index-based, and `max` is idempotent.
785
+ // `:::part`: the opener lives on a dedicated single-column page. On
786
+ // entry, break to a fresh page of the configured parity and convert it
787
+ // into a part page; on exit, break again so the following content (and
788
+ // the next chapter's own parity rule) starts clean. A part page counts
789
+ // as content even with an empty body — its opener design fills it — so
790
+ // consecutive parts never share a page.
791
+ if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'part') {
792
+ const plan = partPlan.byStart.get(blockIdx);
793
+ if (plan) {
794
+ pendingSpacing = 0;
795
+ markForcedBreak();
796
+ leaveCurrentPage();
797
+ enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, resolved.parts.breakBefore.parity);
798
+ cursor.columnIndex = 0;
799
+ // Map the opener's title back to the `title="…"` attribute of the
800
+ // fence so the editor can place the cursor from a click on the band.
801
+ const fenceStart = rawBlock.sourceStart + bodyOffset;
802
+ const fenceText = markdownBody.slice(rawBlock.sourceStart, rawBlock.sourceEnd);
803
+ const titleAttr = /\btitle\s*=\s*(["'])/.exec(fenceText);
804
+ const titleSourceStart = titleAttr ? fenceStart + titleAttr.index + titleAttr[0].length : fenceStart;
805
+ const titleSourceEnd = titleAttr ? titleSourceStart + plan.title.length : rawBlock.sourceEnd + bodyOffset;
806
+ createPartPage(doc.pages[cursor.pageIndex], pageMetrics, resolved, {
807
+ number: plan.number,
808
+ title: plan.title,
809
+ titleSourceStart,
810
+ titleSourceEnd,
811
+ });
812
+ flushPendingNumberingAtBoundary();
813
+ continue;
814
+ }
815
+ }
816
+ if (rawBlock.type === 'containerEnd' && rawBlock.containerName === 'part') {
817
+ const plan = partPlan.byEnd.get(blockIdx);
818
+ if (plan) {
819
+ pendingSpacing = 0;
820
+ // Deferred until the next placed block so a part that closes the
821
+ // document leaves no trailing empty page behind.
822
+ if (resolved.parts.breakAfter.enabled)
823
+ pendingPartBreak = resolved.parts.breakAfter.parity;
824
+ continue;
825
+ }
826
+ }
827
+ if (pendingPartBreak !== null) {
828
+ const parity = pendingPartBreak;
829
+ pendingPartBreak = null;
830
+ pendingSpacing = 0;
831
+ markForcedBreak();
832
+ leaveCurrentPage();
833
+ enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
834
+ flushPendingNumberingAtBoundary();
835
+ }
836
+ if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'callout') {
837
+ const plan = calloutPlan.get(blockIdx);
838
+ if (plan && pickCalloutStyle(resolved.calloutStyles, plan.attrs.type)) {
839
+ // `span: 'page'` boxes in multi-column layouts branch to the
840
+ // span-block path inside; `placement: 'top' | 'bottom'` still
841
+ // falls back to inline placement (floating boxes pending).
842
+ const rewind = placeCalloutInline(blockIdx, plan);
843
+ // Children were laid out inside the box — skip them in the main loop
844
+ // (the for-loop's `++` lands just past the closing marker).
845
+ blockIdx = rewind !== undefined ? rewind : plan.endIdx;
846
+ flushPendingNumberingAtBoundary();
847
+ continue;
848
+ }
849
+ }
850
+ if (rawBlock.type === 'containerStart' || rawBlock.type === 'containerEnd') {
851
+ const pc = rawBlock.containerId !== undefined
852
+ ? paragraphContainers.byId.get(rawBlock.containerId)
853
+ : undefined;
854
+ if (pc) {
855
+ if (rawBlock.type === 'containerStart') {
856
+ pendingSpacing = Math.max(pendingSpacing, pc.marginTopPx);
857
+ }
858
+ else if (contentBlocks[blockIdx - 1]?.type !== 'paragraph') {
859
+ pendingSpacing = Math.max(pendingSpacing, pc.marginBottomPx);
860
+ }
861
+ }
862
+ continue;
863
+ }
399
864
  // --- Heading `breakBefore` ----------------------------------------
400
865
  if (rawBlock.type === 'heading' && rawBlock.level) {
401
866
  const level = headingLevelByNumber.get(rawBlock.level);
@@ -422,59 +887,34 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
422
887
  }
423
888
  }
424
889
  const id = `block-${blockIdCounter++}`;
425
- const kind = resolveBlockKind(rawBlock, {
426
- resolved,
427
- bodyStyle,
428
- blockquoteStyle,
429
- headingPrefixes,
430
- blockIdx,
431
- listLevelIndentsPx,
432
- orderedLevelIndentsPx,
433
- orderedMetrics,
434
- resourceById,
435
- resourceTypeById,
436
- resourceNumberById,
890
+ // Enclosing `:::paragraphs` container (if any). Its last paragraph — the
891
+ // one directly before the closing marker — carries the tail style and
892
+ // snaps the flow back onto the baseline grid.
893
+ const paragraphContainer = paragraphContainers.byBlock[blockIdx];
894
+ const nextRaw = contentBlocks[blockIdx + 1];
895
+ const isContainerTail = paragraphContainer !== undefined
896
+ && nextRaw?.type === 'containerEnd'
897
+ && nextRaw.containerId === paragraphContainer.id;
898
+ // Measure against the current column width. `null` means there is nothing
899
+ // to place inline (empty text, unknown resource id, floated resource).
900
+ const col = currentColumn(doc, cursor);
901
+ const blockMeasureCtx = partPlan.byBlock[blockIdx] ? partMeasureCtx : measureCtx;
902
+ const measuredBlock = measureContentBlock(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, {
903
+ // Column balancing "run a paragraph long": stays undefined for the
904
+ // common case so existing measurement cache keys are preserved.
905
+ looseness: balanceLooseness?.get(blockIdx),
906
+ styleOverride: paragraphContainer
907
+ ? (isContainerTail ? paragraphContainer.tailStyle : paragraphContainer.style)
908
+ : undefined,
437
909
  });
910
+ if (!measuredBlock)
911
+ continue;
912
+ const { kind, contentBlock, measured, prefixLen, absoluteSourceMap, mathDisplayRender } = measuredBlock;
438
913
  const { style, vdtType, headingLevel, numberPrefix, listBullet, listDepth, listKind, bulletXOffsetInColumn, strikethroughText } = kind;
439
- let contentBlock = kind.contentBlock;
440
914
  // --- Resource blocks (image / svg / table + caption) -----------------
441
- // Measured and placed atomically (kept-together) — no mid-content split
442
- // for v1. An unknown resource id produces no output (warnings handle it).
915
+ // Placed atomically (kept-together) — no mid-content split for v1.
443
916
  if (vdtType === 'resource') {
444
- if (!kind.resource) {
445
- flushPendingNumberingAtBoundary();
446
- continue;
447
- }
448
- // Floated resources are not placed inline at their `::resource`
449
- // directive — the directive is just an anchor (already enqueued above);
450
- // the float lands in a page band. Only `position: 'here'` resources fall
451
- // through to inline placement.
452
- if (floatedIds.has(kind.resource.id)) {
453
- flushPendingNumberingAtBoundary();
454
- continue;
455
- }
456
- const rCol = currentColumn(doc, cursor);
457
- const { resourceBlock, measured } = runMeasurement({
458
- vdtType,
459
- rawBlock,
460
- contentBlock,
461
- style,
462
- measureMaxWidth: rCol.bbox.width,
463
- measureOptions: { textAlign: style.textAlign },
464
- mathEnabled: resolved.math.enabled,
465
- useRich: false,
466
- resolved,
467
- resources,
468
- resourceTypes,
469
- resourceNumbering,
470
- resource: kind.resource,
471
- resourceType: kind.resourceType,
472
- resourceNumber: kind.resourceNumber,
473
- });
474
- if (!resourceBlock) {
475
- flushPendingNumberingAtBoundary();
476
- continue;
477
- }
917
+ const resourceBlock = measuredBlock.resourceBlock;
478
918
  const groupHeight = measured.totalHeight;
479
919
  const blk = createVDTBlock(id, 'resource', style.fontString, style.color, style.textAlign);
480
920
  blk.contentIndex = blockIdx;
@@ -494,8 +934,10 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
494
934
  isLastLine: true,
495
935
  }];
496
936
  const spacingBefore = pendingSpacing;
497
- placeResourceBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
498
- // `placeBlockInColumn` (inside placeResourceBlock) shifts `blk.lines`; the
937
+ enterBand(blockIdx, 0);
938
+ placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
939
+ enterBand(blockIdx, 0);
940
+ // `placeBlockInColumn` (inside placeAtomicBlock) shifts `blk.lines`; the
499
941
  // resource's own caption/table lines live on `resourceBlock` and must be
500
942
  // offset to absolute page coordinates here using the placed bbox origin.
501
943
  offsetResourceBlockToAbsolute(resourceBlock, blk.bbox.x, blk.bbox.y);
@@ -515,75 +957,6 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
515
957
  flushPendingNumberingAtBoundary();
516
958
  continue;
517
959
  }
518
- // Measure text — use rich measurement for blocks with bold spans
519
- const col = currentColumn(doc, cursor);
520
- // Resolve inline math on spans (no-op when the block has no math).
521
- const mathEnabled = resolved.math.enabled;
522
- contentBlock = enrichMathSpans(contentBlock, style, resolved);
523
- // Resolve inline `:ref{…}` spans to their computed label so references
524
- // print their number in the running text. Each label becomes one atomic,
525
- // non-breaking token tagged with its `refResourceId` (handled by the
526
- // rich-text measurer), so we always take the rich path for ref blocks.
527
- if (contentBlock.spans.some((s) => s.ref)) {
528
- contentBlock = {
529
- ...contentBlock,
530
- spans: resolveRefSpans(contentBlock.spans, resourceNumbering, resourceTypes, resources, {
531
- bold: bodyStyle.referenceBold ?? true,
532
- italic: bodyStyle.referenceItalic ?? false,
533
- }),
534
- };
535
- }
536
- const hasRichSpans = contentBlock.spans.some((s) => s.bold || s.italic || s.mathRender || s.ref);
537
- // List items reserve horizontal space for indent + bullet + gap.
538
- const { measureMaxWidth, lineXShift, measureFirstLineIndent, measureHangingIndent, } = computeMeasureViewport(col.bbox.width, style, listBullet);
539
- // First-paragraph-after-heading: typographic convention used in many
540
- // scientific publications and book styles where the paragraph that
541
- // immediately follows a heading is rendered without first-line indent.
542
- // Only applies to regular paragraphs without hanging indent; list items
543
- // and hanging-indent paragraphs are unaffected.
544
- let effectiveFirstLineIndent = measureFirstLineIndent;
545
- if (vdtType === 'paragraph'
546
- && !resolved.bodyText.indentAfterHeading
547
- && !resolved.bodyText.hangingIndent
548
- && blockIdx > 0) {
549
- let prevIdx = blockIdx - 1;
550
- while (prevIdx >= 0 && contentBlocks[prevIdx].type === 'directive')
551
- prevIdx--;
552
- if (prevIdx >= 0 && contentBlocks[prevIdx].type === 'heading') {
553
- effectiveFirstLineIndent = 0;
554
- }
555
- }
556
- const runtActive = resolved.bodyText.avoidRunts
557
- && (vdtType === 'paragraph'
558
- || (vdtType === 'listItem' && resolved.bodyText.avoidRuntsInLists));
559
- const measureOptions = {
560
- textAlign: style.textAlign,
561
- hyphenate: style.hyphenate,
562
- firstLineIndentPx: effectiveFirstLineIndent,
563
- hangingIndent: measureHangingIndent,
564
- optimal: resolved.bodyText.optimalLineBreaking,
565
- maxStretchRatio: resolved.bodyText.maxWordSpacing,
566
- minShrinkRatio: resolved.bodyText.minWordSpacing,
567
- runtPenalty: runtActive ? resolved.bodyText.runtPenalty : 0,
568
- runtMinCharacters: runtActive ? resolved.bodyText.runtMinCharacters : 0,
569
- // Column balancing "run a paragraph long": stays undefined for the
570
- // common case so existing measurement cache keys are preserved.
571
- looseness: balanceLooseness?.get(blockIdx),
572
- };
573
- const useRich = !!(hasRichSpans && style.boldFontString && style.italicFontString && style.boldItalicFontString);
574
- const { measured, mathDisplayRender } = runMeasurement({
575
- vdtType, rawBlock, contentBlock, style, measureMaxWidth, measureOptions, mathEnabled, useRich, cache,
576
- });
577
- if (measured.lines.length === 0)
578
- continue;
579
- if (lineXShift > 0) {
580
- for (const line of measured.lines) {
581
- line.bbox.x += lineXShift;
582
- }
583
- }
584
- // Per-line source-range mapping using the block's plain→source map.
585
- // Accounts for heading numbering prefix which prepends chars with no source.
586
- const { prefixLen, absoluteSourceMap } = stampSourceRanges(measured, rawBlock, contentBlock, bodyOffset);
587
960
  const finalizeListItem = (blk, isFirstPart) => {
588
961
  if (!listBullet)
589
962
  return;
@@ -600,6 +973,12 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
600
973
  // `indentPx + (maxNumberWidth - thisNumberWidth)` for ordered — giving
601
974
  // the right-aligned separator.
602
975
  blk.bulletOffsetX = blk.bbox.x + bulletXOffsetInColumn;
976
+ if (listBullet.separatorText !== undefined) {
977
+ blk.separatorText = listBullet.separatorText;
978
+ blk.separatorFontString = listBullet.separatorFontString;
979
+ blk.separatorColor = listBullet.separatorColor;
980
+ blk.separatorX = blk.bulletOffsetX + (listBullet.separatorOffsetPx ?? 0);
981
+ }
603
982
  if (strikethroughText)
604
983
  blk.strikethroughText = true;
605
984
  // Bullet Y = x-height midpoint of the item's first text line.
@@ -610,17 +989,21 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
610
989
  blk.bulletY = firstLine.baseline - listBullet.textFontSizePx * 0.3 + listBullet.verticalOffsetPx;
611
990
  }
612
991
  };
613
- const nextIsListItem = blockIdx + 1 < contentBlocks.length && contentBlocks[blockIdx + 1].type === 'listItem';
992
+ // Neighbour lookaheads see through container markers.
993
+ const nextBlock = nextNonMarkerBlock(contentBlocks, blockIdx) ?? null;
994
+ const nextIsListItem = nextBlock?.type === 'listItem';
614
995
  // For headings, only snap to baseline grid if the next block is NOT a heading.
615
996
  // Consecutive headings flow without grid snapping; the last heading in the
616
997
  // group snaps so that the following body text realigns with the grid.
617
998
  // Same rule for list items: the LAST item of a list snaps so that text
618
999
  // after the list realigns with the baseline grid, even when non-grid
619
- // spacings (itemSpacing, marginTop/Bottom) were chosen.
620
- const nextBlock = blockIdx + 1 < contentBlocks.length ? contentBlocks[blockIdx + 1] : null;
1000
+ // spacings (itemSpacing, marginTop/Bottom) were chosen. And for the last
1001
+ // paragraph of a `:::paragraphs` container, whose leading and spacing
1002
+ // are off-grid by design.
621
1003
  const nextIsHeading = nextBlock?.type === 'heading';
622
1004
  const shouldSnapToGrid = (vdtType === 'heading' && !nextIsHeading) ||
623
1005
  (vdtType === 'listItem' && !nextIsListItem) ||
1006
+ (vdtType === 'paragraph' && isContainerTail) ||
624
1007
  vdtType === 'mathDisplay';
625
1008
  // Place block, splitting across columns/pages if needed.
626
1009
  // List items may split too — orphan/widow protection per-list is gated by
@@ -638,6 +1021,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
638
1021
  && nextIsListItem
639
1022
  && /:\s*$/.test(contentBlock.text);
640
1023
  while (remainingLines.length > 0) {
1024
+ enterBand(blockIdx, partIndex);
641
1025
  const curCol = currentColumn(doc, cursor);
642
1026
  const isFirstInColumn = curCol.blocks.length === 0;
643
1027
  // Compute spacing before this block — margin collapsing between
@@ -660,7 +1044,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
660
1044
  }
661
1045
  }
662
1046
  else if (vdtType === 'listItem') {
663
- const prevWasList = blockIdx > 0 && contentBlocks[blockIdx - 1].type === 'listItem';
1047
+ const prevWasList = prevNonMarkerBlock(contentBlocks, blockIdx)?.type === 'listItem';
664
1048
  if (!prevWasList) {
665
1049
  spacingBefore = Math.max(spacingBefore, style.marginTopPx);
666
1050
  }
@@ -693,11 +1077,15 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
693
1077
  .map((ln) => (ln.segments ?? []).map((s) => s.text).join(''))
694
1078
  .join(' ');
695
1079
  const pref = numberPrefix ?? '';
696
- const title = pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full;
1080
+ const title = applyTitleBreaks(pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full, rawBlock.titleBreaks, rawBlock.text.length);
697
1081
  // Span-page openers lay out across the full content area (both
698
1082
  // columns); in-column headings use just the column width.
699
- const measureWidth = lvl.span === 'page' ? contentArea.width : curCol.bbox.width;
700
- const designBottom = measureHeadingAdvancedDesignHeight(lvl, { titleText: title, formattedNumber: pref, chapterNumber: pref }, measureWidth, resolved.page.dpi, doc.metadata, cursor.pageIndex);
1083
+ const pageArea = doc.pages[cursor.pageIndex].contentArea;
1084
+ const measureWidth = lvl.span === 'page' ? pageArea.width : curCol.bbox.width;
1085
+ const designBottom = measureHeadingAdvancedDesignHeight(lvl, { titleText: title, formattedNumber: pref, chapterNumber: pref, attrs: rawBlock.attrs }, measureWidth, resolved.page.dpi, doc.metadata, cursor.pageIndex, designFrames, {
1086
+ x: lvl.span === 'page' ? pageArea.x : curCol.bbox.x,
1087
+ y: curCol.bbox.y + (curCol.bbox.height - curCol.availableHeight) + spacingBefore,
1088
+ });
701
1089
  if (designBottom > effectiveRemainHeight)
702
1090
  effectiveRemainHeight = designBottom;
703
1091
  }
@@ -762,7 +1150,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
762
1150
  let headingRunCount = 0;
763
1151
  if (resolved.headings.keepWithNext) {
764
1152
  for (let j = curCol.blocks.length - 1; j >= 0; j--) {
765
- if (curCol.blocks[j].type === 'heading')
1153
+ if (curCol.blocks[j].type === 'heading' && curCol.blocks[j].containerId === undefined)
766
1154
  headingRunCount++;
767
1155
  else
768
1156
  break;
@@ -776,7 +1164,9 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
776
1164
  doc.blocks.splice(idx, 1);
777
1165
  curCol.availableHeight += p.bbox.height;
778
1166
  }
779
- blockIdx -= headingRunCount + 1;
1167
+ // Rewind so the for-loop's blockIdx++ lands on the first
1168
+ // rolled-back heading (marker blocks in between are replayed).
1169
+ blockIdx = (popped[0].contentIndex ?? blockIdx - headingRunCount) - 1;
780
1170
  pendingSpacing = 0;
781
1171
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
782
1172
  break;
@@ -819,11 +1209,11 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
819
1209
  if (remainAfterHeading < minSpaceAfter) {
820
1210
  // Roll back any immediately-preceding heading blocks in this
821
1211
  // column so they travel with this one.
822
- const rollbackCount = rollbackTrailingBlocks(curCol, doc.blocks, (b) => b.type === 'heading');
823
- if (rollbackCount > 0) {
1212
+ const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
1213
+ if (rolledBack.length > 0) {
824
1214
  // Rewind so the for-loop's blockIdx++ lands on the first
825
- // rolled-back heading.
826
- blockIdx -= rollbackCount + 1;
1215
+ // rolled-back heading (marker blocks in between are replayed).
1216
+ blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
827
1217
  pendingSpacing = 0;
828
1218
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
829
1219
  break;
@@ -846,6 +1236,20 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
846
1236
  if (numberPrefix)
847
1237
  blk.numberPrefix = numberPrefix;
848
1238
  }
1239
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrs)
1240
+ blk.attrs = rawBlock.attrs;
1241
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrSources) {
1242
+ // Parser ranges are body-relative; VDT source offsets are absolute.
1243
+ blk.attrSources = Object.fromEntries(Object.entries(rawBlock.attrSources).map(([k, r]) => [k, { start: r.start + bodyOffset, end: r.end + bodyOffset }]));
1244
+ }
1245
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
1246
+ blk.titleBreaks = rawBlock.titleBreaks;
1247
+ blk.titleLength = rawBlock.text.length;
1248
+ }
1249
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
1250
+ blk.titleBreaks = rawBlock.titleBreaks;
1251
+ blk.titleLength = rawBlock.text.length;
1252
+ }
849
1253
  if (vdtType === 'mathDisplay' && mathDisplayRender) {
850
1254
  blk.mathRender = mathDisplayRender;
851
1255
  blk.tex = rawBlock.tex;
@@ -969,9 +1373,9 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
969
1373
  // pull those headings along so they don't remain stranded as orphans
970
1374
  // at the column's bottom. Mirrors the rollback inside the "fits" path.
971
1375
  if (vdtType === 'heading' && resolved.headings.keepWithNext) {
972
- const rollbackCount = rollbackTrailingBlocks(curCol, doc.blocks, (b) => b.type === 'heading');
973
- if (rollbackCount > 0) {
974
- blockIdx -= rollbackCount + 1;
1376
+ const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
1377
+ if (rolledBack.length > 0) {
1378
+ blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
975
1379
  pendingSpacing = 0;
976
1380
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
977
1381
  break;
@@ -988,6 +1392,16 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
988
1392
  blk.contentIndex = blockIdx;
989
1393
  if (partIndex === 0)
990
1394
  blk.headingLevel = headingLevel;
1395
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrs)
1396
+ blk.attrs = rawBlock.attrs;
1397
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrSources) {
1398
+ // Parser ranges are body-relative; VDT source offsets are absolute.
1399
+ blk.attrSources = Object.fromEntries(Object.entries(rawBlock.attrSources).map(([k, r]) => [k, { start: r.start + bodyOffset, end: r.end + bodyOffset }]));
1400
+ }
1401
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
1402
+ blk.titleBreaks = rawBlock.titleBreaks;
1403
+ blk.titleLength = rawBlock.text.length;
1404
+ }
991
1405
  blk.lines = resetLinePositions(remainingLines, style.lineHeightPx);
992
1406
  blk.dirty = false;
993
1407
  blk.snappedToGrid = false;
@@ -1027,10 +1441,19 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
1027
1441
  buildHeadersAndFooters(doc);
1028
1442
  doc.converged = true;
1029
1443
  doc.iterationCount = 1;
1030
- return { doc, forcedBreakPages };
1444
+ return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied };
1031
1445
  }
1032
1446
  export function buildDocument(content, config, cache, options) {
1033
- let best = buildDocumentPass(content, config, cache, options);
1447
+ // --- Band caps (page-span blocks mid-page) -----------------------------
1448
+ // A span block that arrived in an uneven band proposes a cap; the driver
1449
+ // re-places the document with it (and grows / drops caps whose band
1450
+ // overflowed) before balancing runs. Documents without such blocks get
1451
+ // their first pass back untouched — no extra pass.
1452
+ const bands = resolveBandCaps(buildDocumentPass(content, config, cache, options), (bandCaps) => buildDocumentPass(content, config, cache, options, { bandCaps }));
1453
+ let best = bands.result;
1454
+ const bandCaps = bands.bandCaps;
1455
+ let passCount = bands.passCount;
1456
+ best.doc.iterationCount = passCount;
1034
1457
  // --- Column balancing (vertical justification) ------------------------
1035
1458
  // Iteratively re-place the document with extra grid lines above headings
1036
1459
  // until every balanceable column ends flush with the page bottom (or no
@@ -1045,7 +1468,6 @@ export function buildDocument(content, config, cache, options) {
1045
1468
  let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
1046
1469
  let applied = { lines: new Map(), loose: new Map() };
1047
1470
  const failedLoose = new Set();
1048
- let passCount = 1;
1049
1471
  let converged = bestScore === 0;
1050
1472
  while (!converged && passCount < MAX_BALANCING_PASSES) {
1051
1473
  const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
@@ -1064,9 +1486,17 @@ export function buildDocument(content, config, cache, options) {
1064
1486
  const extraPx = new Map();
1065
1487
  for (const [idx, n] of proposal.lines)
1066
1488
  extraPx.set(idx, n * best.doc.baselineGrid);
1067
- const next = buildDocumentPass(content, config, cache, options, extraPx, proposal.loose);
1489
+ const next = buildDocumentPass(content, config, cache, options, {
1490
+ balanceExtraPx: extraPx,
1491
+ balanceLooseness: proposal.loose,
1492
+ bandCaps,
1493
+ });
1068
1494
  passCount++;
1069
- const score = totalGapLines(next.doc, next.forcedBreakPages);
1495
+ // Band caps ride along unchanged; a retry that unsettles one (its span
1496
+ // block no longer lands in the capped band) counts as a regression —
1497
+ // capped columns without their box are not a layout we may keep.
1498
+ const capsDelivered = [...bandCaps.keys()].every((i) => next.spanPlacedInBand.has(i));
1499
+ const score = capsDelivered ? totalGapLines(next.doc, next.forcedBreakPages) : Infinity;
1070
1500
  if (score < bestScore) {
1071
1501
  best = next;
1072
1502
  bestScore = score;