postext 0.3.19 → 0.3.21

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 (297) hide show
  1. package/dist/__tests__/columnBalancing.test.js +36 -2
  2. package/dist/__tests__/columnBalancing.test.js.map +1 -1
  3. package/dist/__tests__/defaults/calloutStyles.test.d.ts +2 -0
  4. package/dist/__tests__/defaults/calloutStyles.test.d.ts.map +1 -0
  5. package/dist/__tests__/defaults/calloutStyles.test.js +201 -0
  6. package/dist/__tests__/defaults/calloutStyles.test.js.map +1 -0
  7. package/dist/__tests__/defaults/captionStyle.test.d.ts +2 -0
  8. package/dist/__tests__/defaults/captionStyle.test.d.ts.map +1 -0
  9. package/dist/__tests__/defaults/captionStyle.test.js +87 -0
  10. package/dist/__tests__/defaults/captionStyle.test.js.map +1 -0
  11. package/dist/__tests__/defaults/pageSizePreset.test.d.ts +2 -0
  12. package/dist/__tests__/defaults/pageSizePreset.test.d.ts.map +1 -0
  13. package/dist/__tests__/defaults/pageSizePreset.test.js +24 -0
  14. package/dist/__tests__/defaults/pageSizePreset.test.js.map +1 -0
  15. package/dist/__tests__/defaults/paragraphStyles.test.d.ts +2 -0
  16. package/dist/__tests__/defaults/paragraphStyles.test.d.ts.map +1 -0
  17. package/dist/__tests__/defaults/paragraphStyles.test.js +93 -0
  18. package/dist/__tests__/defaults/paragraphStyles.test.js.map +1 -0
  19. package/dist/__tests__/design/pageAnchors.test.d.ts +2 -0
  20. package/dist/__tests__/design/pageAnchors.test.d.ts.map +1 -0
  21. package/dist/__tests__/design/pageAnchors.test.js +140 -0
  22. package/dist/__tests__/design/pageAnchors.test.js.map +1 -0
  23. package/dist/__tests__/directives.test.js +58 -0
  24. package/dist/__tests__/directives.test.js.map +1 -1
  25. package/dist/__tests__/exports.test.js +19 -0
  26. package/dist/__tests__/exports.test.js.map +1 -1
  27. package/dist/__tests__/mirroredMargins.test.d.ts +2 -0
  28. package/dist/__tests__/mirroredMargins.test.d.ts.map +1 -0
  29. package/dist/__tests__/mirroredMargins.test.js +136 -0
  30. package/dist/__tests__/mirroredMargins.test.js.map +1 -0
  31. package/dist/__tests__/numbering.test.js +54 -0
  32. package/dist/__tests__/numbering.test.js.map +1 -1
  33. package/dist/__tests__/openerTitleSource.test.d.ts +2 -0
  34. package/dist/__tests__/openerTitleSource.test.d.ts.map +1 -0
  35. package/dist/__tests__/openerTitleSource.test.js +108 -0
  36. package/dist/__tests__/openerTitleSource.test.js.map +1 -0
  37. package/dist/__tests__/overStretch.test.d.ts +2 -0
  38. package/dist/__tests__/overStretch.test.d.ts.map +1 -0
  39. package/dist/__tests__/overStretch.test.js +35 -0
  40. package/dist/__tests__/overStretch.test.js.map +1 -0
  41. package/dist/__tests__/pageRoles.test.d.ts +2 -0
  42. package/dist/__tests__/pageRoles.test.d.ts.map +1 -0
  43. package/dist/__tests__/pageRoles.test.js +95 -0
  44. package/dist/__tests__/pageRoles.test.js.map +1 -0
  45. package/dist/__tests__/parse/containers.test.d.ts +2 -0
  46. package/dist/__tests__/parse/containers.test.d.ts.map +1 -0
  47. package/dist/__tests__/parse/containers.test.js +204 -0
  48. package/dist/__tests__/parse/containers.test.js.map +1 -0
  49. package/dist/__tests__/parse/headingAttrs.test.d.ts +2 -0
  50. package/dist/__tests__/parse/headingAttrs.test.d.ts.map +1 -0
  51. package/dist/__tests__/parse/headingAttrs.test.js +62 -0
  52. package/dist/__tests__/parse/headingAttrs.test.js.map +1 -0
  53. package/dist/__tests__/parse/inlineRef.test.js +26 -0
  54. package/dist/__tests__/parse/inlineRef.test.js.map +1 -1
  55. package/dist/__tests__/parts.test.d.ts +2 -0
  56. package/dist/__tests__/parts.test.d.ts.map +1 -0
  57. package/dist/__tests__/parts.test.js +405 -0
  58. package/dist/__tests__/parts.test.js.map +1 -0
  59. package/dist/__tests__/pipeline/calloutLayout.test.d.ts +2 -0
  60. package/dist/__tests__/pipeline/calloutLayout.test.d.ts.map +1 -0
  61. package/dist/__tests__/pipeline/calloutLayout.test.js +273 -0
  62. package/dist/__tests__/pipeline/calloutLayout.test.js.map +1 -0
  63. package/dist/__tests__/pipeline/calloutPlacement.test.d.ts +2 -0
  64. package/dist/__tests__/pipeline/calloutPlacement.test.d.ts.map +1 -0
  65. package/dist/__tests__/pipeline/calloutPlacement.test.js +239 -0
  66. package/dist/__tests__/pipeline/calloutPlacement.test.js.map +1 -0
  67. package/dist/__tests__/pipeline/captionLayout.test.d.ts +2 -0
  68. package/dist/__tests__/pipeline/captionLayout.test.d.ts.map +1 -0
  69. package/dist/__tests__/pipeline/captionLayout.test.js +269 -0
  70. package/dist/__tests__/pipeline/captionLayout.test.js.map +1 -0
  71. package/dist/__tests__/pipeline/inlineRefRender.test.js +53 -0
  72. package/dist/__tests__/pipeline/inlineRefRender.test.js.map +1 -1
  73. package/dist/__tests__/pipeline/orderedSeparator.test.d.ts +2 -0
  74. package/dist/__tests__/pipeline/orderedSeparator.test.d.ts.map +1 -0
  75. package/dist/__tests__/pipeline/orderedSeparator.test.js +196 -0
  76. package/dist/__tests__/pipeline/orderedSeparator.test.js.map +1 -0
  77. package/dist/__tests__/pipeline/paragraphStyles.test.d.ts +2 -0
  78. package/dist/__tests__/pipeline/paragraphStyles.test.d.ts.map +1 -0
  79. package/dist/__tests__/pipeline/paragraphStyles.test.js +172 -0
  80. package/dist/__tests__/pipeline/paragraphStyles.test.js.map +1 -0
  81. package/dist/__tests__/pipeline/spanBands.test.d.ts +2 -0
  82. package/dist/__tests__/pipeline/spanBands.test.d.ts.map +1 -0
  83. package/dist/__tests__/pipeline/spanBands.test.js +303 -0
  84. package/dist/__tests__/pipeline/spanBands.test.js.map +1 -0
  85. package/dist/__tests__/pipeline/spanBlocks.test.d.ts +2 -0
  86. package/dist/__tests__/pipeline/spanBlocks.test.d.ts.map +1 -0
  87. package/dist/__tests__/pipeline/spanBlocks.test.js +407 -0
  88. package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -0
  89. package/dist/__tests__/placeholders.test.d.ts +2 -0
  90. package/dist/__tests__/placeholders.test.d.ts.map +1 -0
  91. package/dist/__tests__/placeholders.test.js +130 -0
  92. package/dist/__tests__/placeholders.test.js.map +1 -0
  93. package/dist/__tests__/table/model.test.js +24 -0
  94. package/dist/__tests__/table/model.test.js.map +1 -1
  95. package/dist/__tests__/tracking.test.d.ts +2 -0
  96. package/dist/__tests__/tracking.test.d.ts.map +1 -0
  97. package/dist/__tests__/tracking.test.js +37 -0
  98. package/dist/__tests__/tracking.test.js.map +1 -0
  99. package/dist/__tests__/urlBreaks.test.d.ts +2 -0
  100. package/dist/__tests__/urlBreaks.test.d.ts.map +1 -0
  101. package/dist/__tests__/urlBreaks.test.js +57 -0
  102. package/dist/__tests__/urlBreaks.test.js.map +1 -0
  103. package/dist/canvas-backend/blockRender.d.ts.map +1 -1
  104. package/dist/canvas-backend/blockRender.js +20 -1
  105. package/dist/canvas-backend/blockRender.js.map +1 -1
  106. package/dist/canvas-backend/decorations.d.ts +3 -0
  107. package/dist/canvas-backend/decorations.d.ts.map +1 -1
  108. package/dist/canvas-backend/decorations.js +14 -8
  109. package/dist/canvas-backend/decorations.js.map +1 -1
  110. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  111. package/dist/canvas-backend/headerFooter.js +23 -0
  112. package/dist/canvas-backend/headerFooter.js.map +1 -1
  113. package/dist/canvas-backend/index.d.ts.map +1 -1
  114. package/dist/canvas-backend/index.js +4 -2
  115. package/dist/canvas-backend/index.js.map +1 -1
  116. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
  117. package/dist/canvas-backend/renderResourceBlock.js +33 -5
  118. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  119. package/dist/columnRule.d.ts +19 -0
  120. package/dist/columnRule.d.ts.map +1 -0
  121. package/dist/columnRule.js +36 -0
  122. package/dist/columnRule.js.map +1 -0
  123. package/dist/defaults/bodyText.js +1 -1
  124. package/dist/defaults/bodyText.js.map +1 -1
  125. package/dist/defaults/calloutStyles.d.ts +61 -0
  126. package/dist/defaults/calloutStyles.d.ts.map +1 -0
  127. package/dist/defaults/calloutStyles.js +292 -0
  128. package/dist/defaults/calloutStyles.js.map +1 -0
  129. package/dist/defaults/captionStyle.d.ts +11 -1
  130. package/dist/defaults/captionStyle.d.ts.map +1 -1
  131. package/dist/defaults/captionStyle.js +127 -2
  132. package/dist/defaults/captionStyle.js.map +1 -1
  133. package/dist/defaults/headerFooter.d.ts.map +1 -1
  134. package/dist/defaults/headerFooter.js +3 -0
  135. package/dist/defaults/headerFooter.js.map +1 -1
  136. package/dist/defaults/headings.d.ts +3 -0
  137. package/dist/defaults/headings.d.ts.map +1 -1
  138. package/dist/defaults/headings.js +33 -6
  139. package/dist/defaults/headings.js.map +1 -1
  140. package/dist/defaults/index.d.ts +4 -1
  141. package/dist/defaults/index.d.ts.map +1 -1
  142. package/dist/defaults/index.js +28 -1
  143. package/dist/defaults/index.js.map +1 -1
  144. package/dist/defaults/orderedLists.d.ts +9 -8
  145. package/dist/defaults/orderedLists.d.ts.map +1 -1
  146. package/dist/defaults/orderedLists.js +66 -4
  147. package/dist/defaults/orderedLists.js.map +1 -1
  148. package/dist/defaults/page.d.ts.map +1 -1
  149. package/dist/defaults/page.js +14 -3
  150. package/dist/defaults/page.js.map +1 -1
  151. package/dist/defaults/paragraphStyles.d.ts +10 -0
  152. package/dist/defaults/paragraphStyles.d.ts.map +1 -0
  153. package/dist/defaults/paragraphStyles.js +67 -0
  154. package/dist/defaults/paragraphStyles.js.map +1 -0
  155. package/dist/defaults/parts.d.ts +22 -0
  156. package/dist/defaults/parts.d.ts.map +1 -0
  157. package/dist/defaults/parts.js +139 -0
  158. package/dist/defaults/parts.js.map +1 -0
  159. package/dist/defaults/shared.d.ts.map +1 -1
  160. package/dist/defaults/shared.js +110 -17
  161. package/dist/defaults/shared.js.map +1 -1
  162. package/dist/defaults/tableStyle.d.ts.map +1 -1
  163. package/dist/defaults/tableStyle.js +6 -0
  164. package/dist/defaults/tableStyle.js.map +1 -1
  165. package/dist/design/layout.d.ts +27 -1
  166. package/dist/design/layout.d.ts.map +1 -1
  167. package/dist/design/layout.js +39 -8
  168. package/dist/design/layout.js.map +1 -1
  169. package/dist/design/placeholders.d.ts +21 -4
  170. package/dist/design/placeholders.d.ts.map +1 -1
  171. package/dist/design/placeholders.js +38 -7
  172. package/dist/design/placeholders.js.map +1 -1
  173. package/dist/html-backend.d.ts.map +1 -1
  174. package/dist/html-backend.js +75 -16
  175. package/dist/html-backend.js.map +1 -1
  176. package/dist/index.d.ts +13 -9
  177. package/dist/index.d.ts.map +1 -1
  178. package/dist/index.js +7 -4
  179. package/dist/index.js.map +1 -1
  180. package/dist/knuthPlass/breakpoints.d.ts.map +1 -1
  181. package/dist/knuthPlass/breakpoints.js +4 -2
  182. package/dist/knuthPlass/breakpoints.js.map +1 -1
  183. package/dist/knuthPlass/constants.d.ts +7 -0
  184. package/dist/knuthPlass/constants.d.ts.map +1 -1
  185. package/dist/knuthPlass/constants.js +7 -0
  186. package/dist/knuthPlass/constants.js.map +1 -1
  187. package/dist/knuthPlass/richAdapter.d.ts +2 -0
  188. package/dist/knuthPlass/richAdapter.d.ts.map +1 -1
  189. package/dist/knuthPlass/richAdapter.js +6 -6
  190. package/dist/knuthPlass/richAdapter.js.map +1 -1
  191. package/dist/measure/cache.js +2 -2
  192. package/dist/measure/cache.js.map +1 -1
  193. package/dist/measure/rich.d.ts +8 -0
  194. package/dist/measure/rich.d.ts.map +1 -1
  195. package/dist/measure/rich.js +54 -10
  196. package/dist/measure/rich.js.map +1 -1
  197. package/dist/measure/types.d.ts +4 -0
  198. package/dist/measure/types.d.ts.map +1 -1
  199. package/dist/measure/types.js.map +1 -1
  200. package/dist/parse/attrs.d.ts +10 -0
  201. package/dist/parse/attrs.d.ts.map +1 -0
  202. package/dist/parse/attrs.js +20 -0
  203. package/dist/parse/attrs.js.map +1 -0
  204. package/dist/parse/blockParser.d.ts +8 -4
  205. package/dist/parse/blockParser.d.ts.map +1 -1
  206. package/dist/parse/blockParser.js +134 -20
  207. package/dist/parse/blockParser.js.map +1 -1
  208. package/dist/parse/index.d.ts +2 -2
  209. package/dist/parse/index.d.ts.map +1 -1
  210. package/dist/parse/index.js +1 -1
  211. package/dist/parse/index.js.map +1 -1
  212. package/dist/parse/inlineFormatting.d.ts +17 -1
  213. package/dist/parse/inlineFormatting.d.ts.map +1 -1
  214. package/dist/parse/inlineFormatting.js +56 -7
  215. package/dist/parse/inlineFormatting.js.map +1 -1
  216. package/dist/parse/sourceMapping.d.ts.map +1 -1
  217. package/dist/parse/sourceMapping.js +13 -1
  218. package/dist/parse/sourceMapping.js.map +1 -1
  219. package/dist/parse/types.d.ts +55 -5
  220. package/dist/parse/types.d.ts.map +1 -1
  221. package/dist/pipeline/bandCaps.d.ts +86 -0
  222. package/dist/pipeline/bandCaps.d.ts.map +1 -0
  223. package/dist/pipeline/bandCaps.js +142 -0
  224. package/dist/pipeline/bandCaps.js.map +1 -0
  225. package/dist/pipeline/build.d.ts +41 -0
  226. package/dist/pipeline/build.d.ts.map +1 -1
  227. package/dist/pipeline/build.js +675 -168
  228. package/dist/pipeline/build.js.map +1 -1
  229. package/dist/pipeline/buildBlockKind.d.ts +8 -0
  230. package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
  231. package/dist/pipeline/buildBlockKind.js +40 -11
  232. package/dist/pipeline/buildBlockKind.js.map +1 -1
  233. package/dist/pipeline/buildHelpers.d.ts +23 -1
  234. package/dist/pipeline/buildHelpers.d.ts.map +1 -1
  235. package/dist/pipeline/buildHelpers.js +50 -6
  236. package/dist/pipeline/buildHelpers.js.map +1 -1
  237. package/dist/pipeline/calloutLayout.d.ts +107 -0
  238. package/dist/pipeline/calloutLayout.d.ts.map +1 -0
  239. package/dist/pipeline/calloutLayout.js +498 -0
  240. package/dist/pipeline/calloutLayout.js.map +1 -0
  241. package/dist/pipeline/columnBalancing.d.ts +26 -8
  242. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  243. package/dist/pipeline/columnBalancing.js +50 -18
  244. package/dist/pipeline/columnBalancing.js.map +1 -1
  245. package/dist/pipeline/config.d.ts.map +1 -1
  246. package/dist/pipeline/config.js +17 -5
  247. package/dist/pipeline/config.js.map +1 -1
  248. package/dist/pipeline/headerFooter.d.ts +37 -2
  249. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  250. package/dist/pipeline/headerFooter.js +231 -15
  251. package/dist/pipeline/headerFooter.js.map +1 -1
  252. package/dist/pipeline/lists.d.ts +15 -0
  253. package/dist/pipeline/lists.d.ts.map +1 -1
  254. package/dist/pipeline/lists.js +73 -16
  255. package/dist/pipeline/lists.js.map +1 -1
  256. package/dist/pipeline/measureContentBlock.d.ts +65 -0
  257. package/dist/pipeline/measureContentBlock.d.ts.map +1 -0
  258. package/dist/pipeline/measureContentBlock.js +134 -0
  259. package/dist/pipeline/measureContentBlock.js.map +1 -0
  260. package/dist/pipeline/pageRoles.d.ts +23 -0
  261. package/dist/pipeline/pageRoles.d.ts.map +1 -0
  262. package/dist/pipeline/pageRoles.js +54 -0
  263. package/dist/pipeline/pageRoles.js.map +1 -0
  264. package/dist/pipeline/paragraphContainers.d.ts +32 -0
  265. package/dist/pipeline/paragraphContainers.d.ts.map +1 -0
  266. package/dist/pipeline/paragraphContainers.js +61 -0
  267. package/dist/pipeline/paragraphContainers.js.map +1 -0
  268. package/dist/pipeline/parts.d.ts +47 -0
  269. package/dist/pipeline/parts.d.ts.map +1 -0
  270. package/dist/pipeline/parts.js +230 -0
  271. package/dist/pipeline/parts.js.map +1 -0
  272. package/dist/pipeline/placeholders.d.ts +46 -1
  273. package/dist/pipeline/placeholders.d.ts.map +1 -1
  274. package/dist/pipeline/placeholders.js +93 -11
  275. package/dist/pipeline/placeholders.js.map +1 -1
  276. package/dist/pipeline/placement.d.ts +61 -7
  277. package/dist/pipeline/placement.d.ts.map +1 -1
  278. package/dist/pipeline/placement.js +151 -12
  279. package/dist/pipeline/placement.js.map +1 -1
  280. package/dist/pipeline/resourceLayout.d.ts +18 -5
  281. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  282. package/dist/pipeline/resourceLayout.js +145 -23
  283. package/dist/pipeline/resourceLayout.js.map +1 -1
  284. package/dist/pipeline/styles.d.ts +9 -1
  285. package/dist/pipeline/styles.d.ts.map +1 -1
  286. package/dist/pipeline/styles.js +50 -0
  287. package/dist/pipeline/styles.js.map +1 -1
  288. package/dist/table/model.d.ts.map +1 -1
  289. package/dist/table/model.js +16 -2
  290. package/dist/table/model.js.map +1 -1
  291. package/dist/types.d.ts +468 -2
  292. package/dist/types.d.ts.map +1 -1
  293. package/dist/vdt.d.ts +132 -4
  294. package/dist/vdt.d.ts.map +1 -1
  295. package/dist/vdt.js +2 -1
  296. package/dist/vdt.js.map +1 -1
  297. 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,77 @@ 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
+ * Column balancing: measure a paragraph asked to run `extraLines` long.
41
+ * Walks the tracking ladder no tracking first, then a little positive
42
+ * tracking up to `maxTracking` and keeps the first measurement that gains
43
+ * exactly the requested lines within the word-spacing limit (a rung that
44
+ * gains the line on its own, without needing the looseness target, counts
45
+ * too). Falls back to the plain measurement when no rung works, recording
46
+ * the outcome either way.
41
47
  */
42
- function buildDocumentPass(content, config, cache, options, balanceExtraPx, balanceLooseness) {
48
+ function measureLooseParagraph(rawBlock, blockIdx, columnWidth, ctx, styleOverride, extraLines, trackingLadder, looseOutcome) {
49
+ const base = measureContentBlock(rawBlock, blockIdx, columnWidth, ctx, { styleOverride });
50
+ if (!base)
51
+ return null;
52
+ const target = base.measured.lines.length + extraLines;
53
+ for (const tracking of trackingLadder) {
54
+ const loose = measureContentBlock(rawBlock, blockIdx, columnWidth, ctx, {
55
+ styleOverride,
56
+ looseness: extraLines,
57
+ trackingEm: tracking > 0 ? tracking / 1000 : undefined,
58
+ });
59
+ if (loose && loose.measured.lines.length === target) {
60
+ looseOutcome.set(blockIdx, tracking);
61
+ return loose;
62
+ }
63
+ }
64
+ looseOutcome.set(blockIdx, null);
65
+ return base;
66
+ }
67
+ /**
68
+ * Single placement pass. `hints` carries the cross-pass adjustments: the
69
+ * column-balancing spacing / looseness and the band caps of page-span
70
+ * blocks. Besides the document it reports `forcedBreakPages` (for
71
+ * balancing) and the band-cap bookkeeping the driver in `buildDocument`
72
+ * needs — new cap proposals, the span blocks that landed in their capped
73
+ * band, and the caps that were actually applied.
74
+ *
75
+ * @internal Exposed for tests only; use `buildDocument`.
76
+ */
77
+ export function buildDocumentPass(content, config, cache, options, hints = {}) {
78
+ const { balanceExtraPx, balanceLooseness, balanceLooseBudget, bandCaps } = hints;
43
79
  const resolved = resolveAllConfig(config);
80
+ // Tracking rungs a loose paragraph may climb: none, then 5‰ steps up to
81
+ // the cap (`headings.balancing.maxTracking`, thousandths of an em), the
82
+ // cap itself always included. Only the smallest rung that gains the line
83
+ // is ever kept; each rung costs one cached paragraph measurement.
84
+ const balancingCfg = resolved.headings.balancing;
85
+ const trackingLadder = [0];
86
+ if (balancingCfg.trackParagraphs && balancingCfg.maxTracking > 0) {
87
+ for (let t = 5; t < balancingCfg.maxTracking; t += 5)
88
+ trackingLadder.push(t);
89
+ trackingLadder.push(balancingCfg.maxTracking);
90
+ }
91
+ const looseOutcome = new Map();
92
+ // Lines gained so far per column budget group (see `LooseBudget`).
93
+ const looseGained = new Map();
44
94
  const headingLevelByNumber = buildHeadingLevelMap(resolved);
45
95
  const dpi = resolved.page.dpi;
46
- // Initialize hyphenator if needed
47
- if (resolved.bodyText.hyphenation.enabled && resolved.bodyText.textAlign === 'justify') {
96
+ // Initialize hyphenator if needed (body text, or any justified paragraph
97
+ // style that hyphenates they share the document locale).
98
+ const needsHyphenator = (resolved.bodyText.hyphenation.enabled && resolved.bodyText.textAlign === 'justify')
99
+ || resolved.paragraphStyles.some((s) => s.hyphenation && s.textAlign === 'justify');
100
+ if (needsHyphenator) {
48
101
  initHyphenator(resolved.bodyText.hyphenation.locale);
49
102
  }
50
103
  // Compute baseline grid
51
104
  const baselineGrid = computeBaselineGrid(resolved);
52
105
  // Create document
53
106
  const doc = createVDTDocument(resolved, baselineGrid);
54
- const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = computePageMetrics(resolved);
107
+ const pageMetrics = computePageMetrics(resolved);
108
+ const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = pageMetrics;
109
+ // Page/bleed frames for design elements anchored to `'page'` / `'bleed'`.
110
+ const designFrames = { page: pageMetrics.trimBox, bleed: pageMetrics.bleedBox };
55
111
  doc.trimOffset = trimOffset;
56
112
  // Create first page
57
113
  const firstPage = createPageWithColumns(0, resolved, contentArea, pageWidthPx, pageHeightPx);
@@ -92,6 +148,12 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
92
148
  const listLevelIndentsPx = computeLevelIndentsPx(resolved, bodyStyle.fontSizePx);
93
149
  const orderedMetrics = computeOrderedListRunMetrics(contentBlocks, resolved, bodyStyle.fontSizePx);
94
150
  const orderedLevelIndentsPx = computeOrderedLevelIndentsPx(resolved, bodyStyle.fontSizePx, orderedMetrics.maxWidthByDepth);
151
+ // `:::paragraphs{style="…"}` containers, resolved per content-block index.
152
+ const paragraphContainers = planParagraphContainers(contentBlocks, resolved);
153
+ // `:::callout` ranges keyed by their start marker index (same rationale).
154
+ const calloutPlan = planCallouts(contentBlocks);
155
+ // `:::part` ranges: start/end marker indices and the enclosed blocks.
156
+ const partPlan = planParts(contentBlocks);
95
157
  // --- Float planning (issue #49 — resources float to page bands) ----------
96
158
  // A resource is incorporated by its first reference (an inline `:ref` or a
97
159
  // `::resource` directive, whichever comes first in reading order). Floated
@@ -122,6 +184,15 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
122
184
  ln.bbox.y += oy;
123
185
  ln.baseline += oy;
124
186
  }
187
+ for (const ln of rb.noteLines) {
188
+ ln.bbox.x += ox;
189
+ ln.bbox.y += oy;
190
+ ln.baseline += oy;
191
+ }
192
+ if (rb.captionBar) {
193
+ rb.captionBar.rect.x += ox;
194
+ rb.captionBar.rect.y += oy;
195
+ }
125
196
  if (rb.table) {
126
197
  for (const cell of rb.table.cells) {
127
198
  cell.rect.x += ox;
@@ -190,8 +261,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
190
261
  targetCols = [best];
191
262
  }
192
263
  const firstCol = page.columns[targetCols[0]];
193
- const width = pageSpan ? contentArea.width : firstCol.bbox.width;
194
- const xLeft = pageSpan ? contentArea.x : firstCol.bbox.x;
264
+ const width = pageSpan ? page.contentArea.width : firstCol.bbox.width;
265
+ const xLeft = pageSpan ? page.contentArea.x : firstCol.bbox.x;
195
266
  const built = buildFloatBlock(f.resourceId, xLeft, width);
196
267
  if (!built)
197
268
  return 'skip';
@@ -225,8 +296,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
225
296
  const cb = page.columns[c].bbox;
226
297
  return cb.y + cb.height;
227
298
  }));
228
- const gridAlignedBottom = contentArea.y
229
- + Math.floor((bottomLimit - contentArea.y + 0.01) / baselineGrid) * baselineGrid;
299
+ const gridAlignedBottom = page.contentArea.y
300
+ + Math.floor((bottomLimit - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
230
301
  const capLines = built.block.resourceBlock.captionLines;
231
302
  if (capLines.length > 0) {
232
303
  // Body baselines sit at 0.2 × grid above each slot bottom; anchor
@@ -313,6 +384,30 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
313
384
  /** Reserve floats on each freshly opened content page. Passed only to the
314
385
  * content-flow column advances — parity / force-blank pages never get it. */
315
386
  const onNewPage = (page) => flushFloatsIntoPage(page);
387
+ // Everything per-block measurement needs that is constant for this pass.
388
+ const measureCtx = {
389
+ resolved,
390
+ bodyStyle,
391
+ blockquoteStyle,
392
+ headingPrefixes,
393
+ listLevelIndentsPx,
394
+ orderedLevelIndentsPx,
395
+ orderedMetrics,
396
+ resourceById,
397
+ resourceTypeById,
398
+ resourceNumberById,
399
+ contentBlocks,
400
+ cache,
401
+ bodyOffset,
402
+ resources,
403
+ resourceTypes,
404
+ resourceNumbering,
405
+ floatedIds,
406
+ };
407
+ // Blocks inside a `:::part` measure with the part body typography.
408
+ const partMeasureCtx = partPlan.byStart.size > 0
409
+ ? derivePartMeasureContext(measureCtx)
410
+ : measureCtx;
316
411
  // Placement cursor
317
412
  const cursor = { pageIndex: 0, columnIndex: 0 };
318
413
  let blockIdCounter = 0;
@@ -327,6 +422,60 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
327
422
  forcedBreakPages.add(cursor.pageIndex);
328
423
  }
329
424
  };
425
+ // --- Column bands: opening block + band caps (span blocks, stage 2) ----
426
+ // The band the cursor last placed into, its opening block (content index
427
+ // + part: a paragraph split across pages opens the next page's band with
428
+ // its continuation), and the cap applied to it, if any. `enterBand` runs
429
+ // at every placement site right before a block is offered to the current
430
+ // column: the first block offered to a band is its opening block, and if
431
+ // a cap names it the band's columns are shortened to the cap BEFORE the
432
+ // block is placed, so the fit / split / keep-with-next rules see the
433
+ // capped height. Atomic placements that advance internally re-run it
434
+ // after landing (the cap then trims whatever the block left).
435
+ let registeredBand = null;
436
+ let bandStart = null;
437
+ let activeCap = null;
438
+ /** True bottoms of capped columns (restored when the span block cuts). */
439
+ const uncappedBottoms = new Map();
440
+ const bandCapProposals = new Map();
441
+ const spanPlacedInBand = new Set();
442
+ const bandCapsApplied = new Set();
443
+ const enterBand = (contentIndex, part) => {
444
+ const page = doc.pages[cursor.pageIndex];
445
+ const band = currentBand(page, cursor);
446
+ if (registeredBand && registeredBand.pageIndex === page.index && registeredBand.band === band)
447
+ return;
448
+ registeredBand = { pageIndex: page.index, band };
449
+ bandStart = { contentIndex, part };
450
+ activeCap = null;
451
+ if (!bandCaps)
452
+ return;
453
+ for (const [spanIndex, cap] of bandCaps) {
454
+ if (cap.startContentIndex !== contentIndex || cap.startPart !== part)
455
+ continue;
456
+ applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms);
457
+ activeCap = { spanIndex, pageIndex: page.index, band };
458
+ bandCapsApplied.add(spanIndex);
459
+ break;
460
+ }
461
+ };
462
+ /** Parity of the page break a closed `:::part` still owes (applied before
463
+ * the next placed block). */
464
+ let pendingPartBreak = null;
465
+ /** `advanceToNextPageBoundary`, except that an empty part page is left
466
+ * behind too (its opener design is content). No floats are reserved on
467
+ * the page opened this way — parity padding may still follow it. */
468
+ const leaveCurrentPage = () => {
469
+ const curPage = doc.pages[cursor.pageIndex];
470
+ if (curPage.partInfo && !pageHasContent(curPage)) {
471
+ const startPageIndex = cursor.pageIndex;
472
+ do {
473
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
474
+ } while (cursor.pageIndex === startPageIndex);
475
+ return;
476
+ }
477
+ advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
478
+ };
330
479
  // Page-numbering segments. The implicit first segment comes from
331
480
  // `cfg.page.pageNumbering`; `:::numbering` directives append more,
332
481
  // each applied at the next page boundary.
@@ -353,6 +502,262 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
353
502
  lastSeenPageIndex = cursor.pageIndex;
354
503
  }
355
504
  };
505
+ /** Heading blocks that are not part of a callout — the only ones the
506
+ * keep-with-next rollbacks may pull along (a callout is one unbreakable
507
+ * unit; its children never leave it). */
508
+ const isFreeHeading = (b) => b.type === 'heading' && b.containerId === undefined;
509
+ /** Shared tail of callout placement: stamp the frame's source range,
510
+ * convert the laid-out box to absolute coordinates at the frame's placed
511
+ * origin, and push frame + children — in that order — to `doc.blocks`
512
+ * and to the column the frame landed in. */
513
+ const commitCallout = (result, startIdx, plan, col) => {
514
+ const frame = result.frame;
515
+ const startBlock = contentBlocks[startIdx];
516
+ const endBlock = contentBlocks[plan.endIdx];
517
+ frame.contentIndex = startIdx;
518
+ frame.sourceStart = startBlock.sourceStart + bodyOffset;
519
+ frame.sourceEnd = endBlock.sourceEnd + bodyOffset;
520
+ offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
521
+ doc.blocks.push(frame);
522
+ for (const child of result.children) {
523
+ child.pageIndex = frame.pageIndex;
524
+ child.columnIndex = frame.columnIndex;
525
+ col.blocks.push(child);
526
+ doc.blocks.push(child);
527
+ }
528
+ };
529
+ /**
530
+ * Place a `span: 'page'` `:::callout` in a multi-column layout as a span
531
+ * block (stage 1): the box is laid out at the page's content width and
532
+ * gets its own full-width `kind: 'span'` column; the text columns of the
533
+ * current band are closed at the cut line and a fresh band of text columns
534
+ * opens below the box, so the flow continues under it in every column.
535
+ *
536
+ * The band must be LEVEL — every column has consumed the same height:
537
+ * the page top, right after a `span: 'page'` opener heading, right after
538
+ * another span block, or right after a top float band — and leave room
539
+ * for the box plus at least the widow minimum of body lines below it.
540
+ * Otherwise the box moves to the top of the next page (a fresh page is
541
+ * trivially level) WITHOUT marking a forced break, so the page it left
542
+ * stays balanceable. Keep-with-next does not apply here: a heading right
543
+ * before a page-span box stays in its text column.
544
+ *
545
+ * Stage 2 — mid-page bands (`bandCaps.ts`): a box arriving in an UNEVEN
546
+ * band with room below proposes a band cap — the band's columns cut
547
+ * level at `ceil(Σ used / N / grid)` lines — and falls back to the next
548
+ * page for this pass; the driver re-runs the pass with the cap, the
549
+ * capped columns fill and overflow naturally (every placement rule still
550
+ * applies), and when the box arrives in the band its cap was applied to
551
+ * it cuts there: the columns above end level (the last one may keep up
552
+ * to a few lines of slack, which balancing absorbs), the box spans the
553
+ * page and the flow resumes in the band below. When the capped band
554
+ * overflowed instead (the box arrives elsewhere), the driver grows or
555
+ * drops the cap.
556
+ *
557
+ * Geometry stays on the baseline grid: the cut line is the band's used
558
+ * bottom snapped UP to the next grid line (anchored at the content-area
559
+ * top, like every column start), and the span column's height is the
560
+ * box plus its collapsed top spacing and `marginBottom`, rounded up to a
561
+ * grid multiple — so the new band's columns start on the grid. Floats
562
+ * stay in the outer page bands: the band inherits the float-reduced top
563
+ * / bottom of the page's columns, so a span column never overlaps a float.
564
+ */
565
+ const placeCalloutSpan = (startIdx, plan, layoutAt) => {
566
+ let page = doc.pages[cursor.pageIndex];
567
+ let result = layoutAt(page.contentArea.width);
568
+ const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
569
+ const minRoomPx = minLines * bodyStyle.lineHeightPx;
570
+ /** Where the box would cut the current band, and whether it fits (room
571
+ * is measured against the columns' TRUE bottoms — a capped band keeps
572
+ * its slack below the cap). Null when the band is not level (or the
573
+ * cursor sits on a span column with no band below it) and
574
+ * `requireLevel` is set. */
575
+ const measureBand = (requireLevel) => {
576
+ const cols = bandColumns(page, currentBand(page, cursor));
577
+ if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
578
+ return null;
579
+ const usedBottom = bandUsedBottom(cols);
580
+ const cutY = page.contentArea.y
581
+ + Math.ceil((usedBottom - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
582
+ const bandHasContent = cols.some((c) => c.blocks.length > 0);
583
+ const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
584
+ const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
585
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
586
+ const room = cutY + need + minRoomPx <= bandBottom + 0.01;
587
+ return { cols, cutY, need, spacing, room };
588
+ };
589
+ // Is this band capped for this very box? Then it cuts at the band's
590
+ // used bottom (at most the cap) even when the last column is short.
591
+ const cap = bandCaps?.get(startIdx);
592
+ const capActive = cap !== undefined
593
+ && activeCap !== null
594
+ && activeCap.spanIndex === startIdx
595
+ && activeCap.pageIndex === page.index
596
+ && activeCap.band === currentBand(page, cursor);
597
+ let fit = measureBand(!capActive);
598
+ if (fit?.room && capActive) {
599
+ uncapBand(fit.cols, uncappedBottoms);
600
+ spanPlacedInBand.add(startIdx);
601
+ }
602
+ if (!fit && cap === undefined && bandStart && registeredBand
603
+ && registeredBand.pageIndex === page.index
604
+ && registeredBand.band === currentBand(page, cursor)) {
605
+ // Uneven band, no cap yet: propose one when a level cut would leave
606
+ // room for the box plus the widow minimum of body lines below it.
607
+ const cols = bandColumns(page, currentBand(page, cursor));
608
+ const lines = bandCapLines(cols, baselineGrid);
609
+ const capBottom = Math.max(...cols.map((c) => c.bbox.y)) + lines * baselineGrid;
610
+ const spacing = Math.max(pendingSpacing, result.marginTopPx);
611
+ const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
612
+ const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
613
+ if (capBottom + need + minRoomPx <= bandBottom + 0.01) {
614
+ bandCapProposals.set(startIdx, {
615
+ startContentIndex: bandStart.contentIndex,
616
+ startPart: bandStart.part,
617
+ lines,
618
+ retries: 0,
619
+ });
620
+ }
621
+ }
622
+ if (!fit || !fit.room) {
623
+ // Open the next page (flushing pending floats into its bands). A page
624
+ // holding only floats counts as occupied here — its float band is what
625
+ // left no room — but a truly empty page is kept: the box then simply
626
+ // does not fit a page and is force-placed (overflowing, like inline).
627
+ const curPage = doc.pages[cursor.pageIndex];
628
+ if (pageHasContent(curPage) || (curPage.floats?.length ?? 0) > 0) {
629
+ pendingSpacing = 0;
630
+ const startPageIndex = cursor.pageIndex;
631
+ do {
632
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
633
+ } while (cursor.pageIndex === startPageIndex);
634
+ page = doc.pages[cursor.pageIndex];
635
+ if (Math.abs(page.contentArea.width - result.width) > 0.01) {
636
+ result = layoutAt(page.contentArea.width);
637
+ }
638
+ }
639
+ // A freshly opened page is level; force-place (overflow) when the box
640
+ // is taller than the page.
641
+ fit = measureBand(true) ?? measureBand(false);
642
+ if (!fit)
643
+ return false; // no text column to cut — leave it to the inline path
644
+ }
645
+ const spanCol = closeBandAndInsertSpan(page, fit.cols, fit.cutY, result.frame, fit.need, cursor, fit.spacing, result.totalHeight);
646
+ commitCallout(result, startIdx, plan, spanCol);
647
+ // Floats first-referenced inside the box enqueue once it is committed,
648
+ // in reading order (same as the inline path).
649
+ for (let i = startIdx + 1; i <= plan.endIdx; i++) {
650
+ const fl = floatsByFirstBlock.get(i);
651
+ if (fl)
652
+ pendingFloats.push(...fl);
653
+ }
654
+ // The new band starts on the grid right below the span column; nothing
655
+ // to snap — `need` already bakes in `marginBottom`.
656
+ pendingSpacing = 0;
657
+ return true;
658
+ };
659
+ /**
660
+ * Place a `:::callout` inline at the current column width as one atomic
661
+ * unit: the frame block followed by its children in the same column. The
662
+ * box's `marginTop` collapses with the pending spacing; `marginBottom` is
663
+ * baked into the post-box grid snap. A box that does not fit moves to the
664
+ * next column/page (like a resource), pulling a run of trailing headings
665
+ * along (keep-with-next); a box taller than an empty column is placed
666
+ * anyway and overflows (the sandbox warns). Returns the content index to
667
+ * rewind the main loop to when headings were rolled back, else `undefined`.
668
+ *
669
+ * `span: 'page'` boxes in multi-column layouts take the span-block path
670
+ * (`placeCalloutSpan`) instead; `placement: 'top' | 'bottom'` (floating
671
+ * boxes) still fall back to this inline placement for v1 — the frame's
672
+ * `callout.placement` records the request.
673
+ */
674
+ const placeCalloutInline = (startIdx, plan) => {
675
+ const style = pickCalloutStyle(resolved.calloutStyles, plan.attrs.type);
676
+ const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
677
+ const frameId = `block-${blockIdCounter++}`;
678
+ const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
679
+ const layoutAt = (width) => {
680
+ let n = 0;
681
+ return layoutCallout({
682
+ style,
683
+ attrs: plan.attrs,
684
+ children,
685
+ childStartIdx: startIdx + 1,
686
+ width,
687
+ ctx: measureCtx,
688
+ resolved,
689
+ containerId: plan.containerId,
690
+ frameId,
691
+ nextChildId: () => `${frameId}-c${n++}`,
692
+ paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
693
+ });
694
+ };
695
+ // Page-span boxes split a multi-column page into column bands (stage 1
696
+ // of span blocks). Floating placements keep the inline fallback.
697
+ {
698
+ const page = doc.pages[cursor.pageIndex];
699
+ if (span === 'page'
700
+ && placement === 'here'
701
+ && bandColumns(page, currentBand(page, cursor)).length > 1
702
+ && placeCalloutSpan(startIdx, plan, layoutAt)) {
703
+ return undefined;
704
+ }
705
+ }
706
+ let curCol = currentColumn(doc, cursor);
707
+ let result = layoutAt(curCol.bbox.width);
708
+ if (curCol.blocks.length > 0) {
709
+ const spacingBefore = Math.max(pendingSpacing, result.marginTopPx);
710
+ if (result.totalHeight > curCol.availableHeight - spacingBefore) {
711
+ // Keep-with-next: a run of headings at the column's tail travels
712
+ // with the box. Skipped when the column holds nothing else (rolling
713
+ // back again would loop) — the headings stay, orphaned.
714
+ let run = 0;
715
+ for (let j = curCol.blocks.length - 1; j >= 0; j--) {
716
+ if (isFreeHeading(curCol.blocks[j]))
717
+ run++;
718
+ else
719
+ break;
720
+ }
721
+ pendingSpacing = 0;
722
+ if (resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
723
+ const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
724
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
725
+ return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
726
+ }
727
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
728
+ curCol = currentColumn(doc, cursor);
729
+ // Columns of different widths (oneAndHalf): re-lay out for the new one.
730
+ if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
731
+ result = layoutAt(curCol.bbox.width);
732
+ }
733
+ }
734
+ }
735
+ // Floats first-referenced inside the box still enqueue in reading order
736
+ // (only once the box is committed, so a keep-with-next replay does not
737
+ // enqueue them twice).
738
+ for (let i = startIdx + 1; i <= plan.endIdx; i++) {
739
+ const fl = floatsByFirstBlock.get(i);
740
+ if (fl)
741
+ pendingFloats.push(...fl);
742
+ }
743
+ const frame = result.frame;
744
+ const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
745
+ enterBand(startIdx, 0);
746
+ placeAtomicBlock(frame, result.totalHeight, spacing, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
747
+ enterBand(startIdx, 0);
748
+ curCol = currentColumn(doc, cursor);
749
+ commitCallout(result, startIdx, plan, curCol);
750
+ // Snap the flow after the box to the baseline grid, baking in at least
751
+ // `marginBottom` (grid wins, margin is a minimum — the resource rule).
752
+ {
753
+ const usedHeight = curCol.bbox.height - curCol.availableHeight;
754
+ const naturalBottom = usedHeight + result.marginBottomPx;
755
+ const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
756
+ curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
757
+ }
758
+ pendingSpacing = 0;
759
+ return undefined;
760
+ };
356
761
  for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
357
762
  if (options?.shouldCancel?.())
358
763
  throw new BuildCancelledError();
@@ -381,6 +786,22 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
381
786
  }
382
787
  flushPendingNumberingAtBoundary();
383
788
  }
789
+ else if (name === 'columnbreak') {
790
+ // Explicit column break: end the current column here (its bottom
791
+ // gap is intentional, so balancing skips it) and continue in the
792
+ // next column. A no-op in an empty column, so it never opens a
793
+ // blank column or page.
794
+ pendingSpacing = 0;
795
+ const col = currentColumn(doc, cursor);
796
+ if (col.blocks.length > 0) {
797
+ col.forcedBreak = true;
798
+ const page = doc.pages[cursor.pageIndex];
799
+ if (cursor.columnIndex === page.columns.length - 1)
800
+ markForcedBreak();
801
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
802
+ flushPendingNumberingAtBoundary();
803
+ }
804
+ }
384
805
  else if (name === 'numbering') {
385
806
  const change = {};
386
807
  const fmt = attrs.format;
@@ -396,6 +817,92 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
396
817
  }
397
818
  continue;
398
819
  }
820
+ // --- Container markers ---------------------------------------------
821
+ // `:::paragraphs` applies its style's top margin on entry through the
822
+ // pending-spacing mechanism (collapses like any margin, vanishes at a
823
+ // column top). Its bottom margin is normally baked into the last
824
+ // paragraph's grid snap; the pending-spacing fallback covers containers
825
+ // that end with a non-paragraph block. Replaying a marker after a keep-with-next rewind is
826
+ // harmless: the container plan is index-based, and `max` is idempotent.
827
+ // `:::part`: the opener lives on a dedicated single-column page. On
828
+ // entry, break to a fresh page of the configured parity and convert it
829
+ // into a part page; on exit, break again so the following content (and
830
+ // the next chapter's own parity rule) starts clean. A part page counts
831
+ // as content even with an empty body — its opener design fills it — so
832
+ // consecutive parts never share a page.
833
+ if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'part') {
834
+ const plan = partPlan.byStart.get(blockIdx);
835
+ if (plan) {
836
+ pendingSpacing = 0;
837
+ markForcedBreak();
838
+ leaveCurrentPage();
839
+ enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, resolved.parts.breakBefore.parity);
840
+ cursor.columnIndex = 0;
841
+ // Map the opener's title back to the `title="…"` attribute of the
842
+ // fence so the editor can place the cursor from a click on the band.
843
+ const fenceStart = rawBlock.sourceStart + bodyOffset;
844
+ const fenceText = markdownBody.slice(rawBlock.sourceStart, rawBlock.sourceEnd);
845
+ const titleAttr = /\btitle\s*=\s*(["'])/.exec(fenceText);
846
+ const titleSourceStart = titleAttr ? fenceStart + titleAttr.index + titleAttr[0].length : fenceStart;
847
+ const titleSourceEnd = titleAttr ? titleSourceStart + plan.title.length : rawBlock.sourceEnd + bodyOffset;
848
+ createPartPage(doc.pages[cursor.pageIndex], pageMetrics, resolved, {
849
+ number: plan.number,
850
+ title: plan.title,
851
+ titleSourceStart,
852
+ titleSourceEnd,
853
+ });
854
+ flushPendingNumberingAtBoundary();
855
+ continue;
856
+ }
857
+ }
858
+ if (rawBlock.type === 'containerEnd' && rawBlock.containerName === 'part') {
859
+ const plan = partPlan.byEnd.get(blockIdx);
860
+ if (plan) {
861
+ pendingSpacing = 0;
862
+ // Deferred until the next placed block so a part that closes the
863
+ // document leaves no trailing empty page behind.
864
+ if (resolved.parts.breakAfter.enabled)
865
+ pendingPartBreak = resolved.parts.breakAfter.parity;
866
+ continue;
867
+ }
868
+ }
869
+ if (pendingPartBreak !== null) {
870
+ const parity = pendingPartBreak;
871
+ pendingPartBreak = null;
872
+ pendingSpacing = 0;
873
+ markForcedBreak();
874
+ leaveCurrentPage();
875
+ enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
876
+ flushPendingNumberingAtBoundary();
877
+ }
878
+ if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'callout') {
879
+ const plan = calloutPlan.get(blockIdx);
880
+ if (plan && pickCalloutStyle(resolved.calloutStyles, plan.attrs.type)) {
881
+ // `span: 'page'` boxes in multi-column layouts branch to the
882
+ // span-block path inside; `placement: 'top' | 'bottom'` still
883
+ // falls back to inline placement (floating boxes pending).
884
+ const rewind = placeCalloutInline(blockIdx, plan);
885
+ // Children were laid out inside the box — skip them in the main loop
886
+ // (the for-loop's `++` lands just past the closing marker).
887
+ blockIdx = rewind !== undefined ? rewind : plan.endIdx;
888
+ flushPendingNumberingAtBoundary();
889
+ continue;
890
+ }
891
+ }
892
+ if (rawBlock.type === 'containerStart' || rawBlock.type === 'containerEnd') {
893
+ const pc = rawBlock.containerId !== undefined
894
+ ? paragraphContainers.byId.get(rawBlock.containerId)
895
+ : undefined;
896
+ if (pc) {
897
+ if (rawBlock.type === 'containerStart') {
898
+ pendingSpacing = Math.max(pendingSpacing, pc.marginTopPx);
899
+ }
900
+ else if (contentBlocks[blockIdx - 1]?.type !== 'paragraph') {
901
+ pendingSpacing = Math.max(pendingSpacing, pc.marginBottomPx);
902
+ }
903
+ }
904
+ continue;
905
+ }
399
906
  // --- Heading `breakBefore` ----------------------------------------
400
907
  if (rawBlock.type === 'heading' && rawBlock.level) {
401
908
  const level = headingLevelByNumber.get(rawBlock.level);
@@ -422,59 +929,48 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
422
929
  }
423
930
  }
424
931
  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,
437
- });
932
+ // Enclosing `:::paragraphs` container (if any). Its last paragraph — the
933
+ // one directly before the closing marker — carries the tail style and
934
+ // snaps the flow back onto the baseline grid.
935
+ const paragraphContainer = paragraphContainers.byBlock[blockIdx];
936
+ const nextRaw = contentBlocks[blockIdx + 1];
937
+ const isContainerTail = paragraphContainer !== undefined
938
+ && nextRaw?.type === 'containerEnd'
939
+ && nextRaw.containerId === paragraphContainer.id;
940
+ // Measure against the current column width. `null` means there is nothing
941
+ // to place inline (empty text, unknown resource id, floated resource).
942
+ const col = currentColumn(doc, cursor);
943
+ const blockMeasureCtx = partPlan.byBlock[blockIdx] ? partMeasureCtx : measureCtx;
944
+ const styleOverride = paragraphContainer
945
+ ? (isContainerTail ? paragraphContainer.tailStyle : paragraphContainer.style)
946
+ : undefined;
947
+ // Column balancing "run a paragraph long": the loose path is taken only
948
+ // for the paragraphs the driver asked for, so the common case keeps its
949
+ // existing measurement cache keys.
950
+ const looseLines = balanceLooseness?.get(blockIdx);
951
+ const budget = balanceLooseBudget?.get(blockIdx);
952
+ // A candidate already known to have gained its line (from an earlier
953
+ // measurement in this pass) keeps it; one whose column met its budget
954
+ // is left as it is. Others walk the ladder and report their outcome.
955
+ const knownOutcome = looseOutcome.get(blockIdx);
956
+ const budgetMet = budget !== undefined
957
+ && knownOutcome === undefined
958
+ && (looseGained.get(budget.group) ?? 0) >= budget.need;
959
+ const tryLoose = looseLines !== undefined && !budgetMet && knownOutcome !== null;
960
+ const measuredBlock = tryLoose
961
+ ? measureLooseParagraph(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, styleOverride, looseLines, trackingLadder, looseOutcome)
962
+ : measureContentBlock(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, { styleOverride });
963
+ if (tryLoose && budget !== undefined && knownOutcome === undefined && typeof looseOutcome.get(blockIdx) === 'number') {
964
+ looseGained.set(budget.group, (looseGained.get(budget.group) ?? 0) + 1);
965
+ }
966
+ if (!measuredBlock)
967
+ continue;
968
+ const { kind, contentBlock, measured, prefixLen, absoluteSourceMap, mathDisplayRender, letterSpacingPx } = measuredBlock;
438
969
  const { style, vdtType, headingLevel, numberPrefix, listBullet, listDepth, listKind, bulletXOffsetInColumn, strikethroughText } = kind;
439
- let contentBlock = kind.contentBlock;
440
970
  // --- 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).
971
+ // Placed atomically (kept-together) — no mid-content split for v1.
443
972
  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
- }
973
+ const resourceBlock = measuredBlock.resourceBlock;
478
974
  const groupHeight = measured.totalHeight;
479
975
  const blk = createVDTBlock(id, 'resource', style.fontString, style.color, style.textAlign);
480
976
  blk.contentIndex = blockIdx;
@@ -494,8 +990,10 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
494
990
  isLastLine: true,
495
991
  }];
496
992
  const spacingBefore = pendingSpacing;
497
- placeResourceBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
498
- // `placeBlockInColumn` (inside placeResourceBlock) shifts `blk.lines`; the
993
+ enterBand(blockIdx, 0);
994
+ placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
995
+ enterBand(blockIdx, 0);
996
+ // `placeBlockInColumn` (inside placeAtomicBlock) shifts `blk.lines`; the
499
997
  // resource's own caption/table lines live on `resourceBlock` and must be
500
998
  // offset to absolute page coordinates here using the placed bbox origin.
501
999
  offsetResourceBlockToAbsolute(resourceBlock, blk.bbox.x, blk.bbox.y);
@@ -515,75 +1013,6 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
515
1013
  flushPendingNumberingAtBoundary();
516
1014
  continue;
517
1015
  }
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
1016
  const finalizeListItem = (blk, isFirstPart) => {
588
1017
  if (!listBullet)
589
1018
  return;
@@ -600,6 +1029,12 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
600
1029
  // `indentPx + (maxNumberWidth - thisNumberWidth)` for ordered — giving
601
1030
  // the right-aligned separator.
602
1031
  blk.bulletOffsetX = blk.bbox.x + bulletXOffsetInColumn;
1032
+ if (listBullet.separatorText !== undefined) {
1033
+ blk.separatorText = listBullet.separatorText;
1034
+ blk.separatorFontString = listBullet.separatorFontString;
1035
+ blk.separatorColor = listBullet.separatorColor;
1036
+ blk.separatorX = blk.bulletOffsetX + (listBullet.separatorOffsetPx ?? 0);
1037
+ }
603
1038
  if (strikethroughText)
604
1039
  blk.strikethroughText = true;
605
1040
  // Bullet Y = x-height midpoint of the item's first text line.
@@ -610,17 +1045,21 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
610
1045
  blk.bulletY = firstLine.baseline - listBullet.textFontSizePx * 0.3 + listBullet.verticalOffsetPx;
611
1046
  }
612
1047
  };
613
- const nextIsListItem = blockIdx + 1 < contentBlocks.length && contentBlocks[blockIdx + 1].type === 'listItem';
1048
+ // Neighbour lookaheads see through container markers.
1049
+ const nextBlock = nextNonMarkerBlock(contentBlocks, blockIdx) ?? null;
1050
+ const nextIsListItem = nextBlock?.type === 'listItem';
614
1051
  // For headings, only snap to baseline grid if the next block is NOT a heading.
615
1052
  // Consecutive headings flow without grid snapping; the last heading in the
616
1053
  // group snaps so that the following body text realigns with the grid.
617
1054
  // Same rule for list items: the LAST item of a list snaps so that text
618
1055
  // 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;
1056
+ // spacings (itemSpacing, marginTop/Bottom) were chosen. And for the last
1057
+ // paragraph of a `:::paragraphs` container, whose leading and spacing
1058
+ // are off-grid by design.
621
1059
  const nextIsHeading = nextBlock?.type === 'heading';
622
1060
  const shouldSnapToGrid = (vdtType === 'heading' && !nextIsHeading) ||
623
1061
  (vdtType === 'listItem' && !nextIsListItem) ||
1062
+ (vdtType === 'paragraph' && isContainerTail) ||
624
1063
  vdtType === 'mathDisplay';
625
1064
  // Place block, splitting across columns/pages if needed.
626
1065
  // List items may split too — orphan/widow protection per-list is gated by
@@ -638,6 +1077,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
638
1077
  && nextIsListItem
639
1078
  && /:\s*$/.test(contentBlock.text);
640
1079
  while (remainingLines.length > 0) {
1080
+ enterBand(blockIdx, partIndex);
641
1081
  const curCol = currentColumn(doc, cursor);
642
1082
  const isFirstInColumn = curCol.blocks.length === 0;
643
1083
  // Compute spacing before this block — margin collapsing between
@@ -660,7 +1100,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
660
1100
  }
661
1101
  }
662
1102
  else if (vdtType === 'listItem') {
663
- const prevWasList = blockIdx > 0 && contentBlocks[blockIdx - 1].type === 'listItem';
1103
+ const prevWasList = prevNonMarkerBlock(contentBlocks, blockIdx)?.type === 'listItem';
664
1104
  if (!prevWasList) {
665
1105
  spacingBefore = Math.max(spacingBefore, style.marginTopPx);
666
1106
  }
@@ -693,11 +1133,15 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
693
1133
  .map((ln) => (ln.segments ?? []).map((s) => s.text).join(''))
694
1134
  .join(' ');
695
1135
  const pref = numberPrefix ?? '';
696
- const title = pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full;
1136
+ const title = applyTitleBreaks(pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full, rawBlock.titleBreaks, rawBlock.text.length);
697
1137
  // Span-page openers lay out across the full content area (both
698
1138
  // 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);
1139
+ const pageArea = doc.pages[cursor.pageIndex].contentArea;
1140
+ const measureWidth = lvl.span === 'page' ? pageArea.width : curCol.bbox.width;
1141
+ const designBottom = measureHeadingAdvancedDesignHeight(lvl, { titleText: title, formattedNumber: pref, chapterNumber: pref, attrs: rawBlock.attrs }, measureWidth, resolved.page.dpi, doc.metadata, cursor.pageIndex, designFrames, {
1142
+ x: lvl.span === 'page' ? pageArea.x : curCol.bbox.x,
1143
+ y: curCol.bbox.y + (curCol.bbox.height - curCol.availableHeight) + spacingBefore,
1144
+ });
701
1145
  if (designBottom > effectiveRemainHeight)
702
1146
  effectiveRemainHeight = designBottom;
703
1147
  }
@@ -732,6 +1176,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
732
1176
  const splitLines = remainingLines.slice(0, splitAt);
733
1177
  const blk = createVDTBlock(id, vdtType, style.fontString, style.color, style.textAlign);
734
1178
  applyStyleAttrs(blk, style);
1179
+ if (letterSpacingPx !== undefined)
1180
+ blk.letterSpacing = letterSpacingPx;
735
1181
  blk.contentIndex = blockIdx;
736
1182
  blk.headingLevel = headingLevel;
737
1183
  if (numberPrefix)
@@ -762,7 +1208,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
762
1208
  let headingRunCount = 0;
763
1209
  if (resolved.headings.keepWithNext) {
764
1210
  for (let j = curCol.blocks.length - 1; j >= 0; j--) {
765
- if (curCol.blocks[j].type === 'heading')
1211
+ if (curCol.blocks[j].type === 'heading' && curCol.blocks[j].containerId === undefined)
766
1212
  headingRunCount++;
767
1213
  else
768
1214
  break;
@@ -776,7 +1222,9 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
776
1222
  doc.blocks.splice(idx, 1);
777
1223
  curCol.availableHeight += p.bbox.height;
778
1224
  }
779
- blockIdx -= headingRunCount + 1;
1225
+ // Rewind so the for-loop's blockIdx++ lands on the first
1226
+ // rolled-back heading (marker blocks in between are replayed).
1227
+ blockIdx = (popped[0].contentIndex ?? blockIdx - headingRunCount) - 1;
780
1228
  pendingSpacing = 0;
781
1229
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
782
1230
  break;
@@ -819,11 +1267,11 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
819
1267
  if (remainAfterHeading < minSpaceAfter) {
820
1268
  // Roll back any immediately-preceding heading blocks in this
821
1269
  // column so they travel with this one.
822
- const rollbackCount = rollbackTrailingBlocks(curCol, doc.blocks, (b) => b.type === 'heading');
823
- if (rollbackCount > 0) {
1270
+ const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
1271
+ if (rolledBack.length > 0) {
824
1272
  // Rewind so the for-loop's blockIdx++ lands on the first
825
- // rolled-back heading.
826
- blockIdx -= rollbackCount + 1;
1273
+ // rolled-back heading (marker blocks in between are replayed).
1274
+ blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
827
1275
  pendingSpacing = 0;
828
1276
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
829
1277
  break;
@@ -840,12 +1288,28 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
840
1288
  const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
841
1289
  const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
842
1290
  applyStyleAttrs(blk, style);
1291
+ if (letterSpacingPx !== undefined)
1292
+ blk.letterSpacing = letterSpacingPx;
843
1293
  blk.contentIndex = blockIdx;
844
1294
  if (partIndex === 0) {
845
1295
  blk.headingLevel = headingLevel;
846
1296
  if (numberPrefix)
847
1297
  blk.numberPrefix = numberPrefix;
848
1298
  }
1299
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrs)
1300
+ blk.attrs = rawBlock.attrs;
1301
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrSources) {
1302
+ // Parser ranges are body-relative; VDT source offsets are absolute.
1303
+ blk.attrSources = Object.fromEntries(Object.entries(rawBlock.attrSources).map(([k, r]) => [k, { start: r.start + bodyOffset, end: r.end + bodyOffset }]));
1304
+ }
1305
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
1306
+ blk.titleBreaks = rawBlock.titleBreaks;
1307
+ blk.titleLength = rawBlock.text.length;
1308
+ }
1309
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
1310
+ blk.titleBreaks = rawBlock.titleBreaks;
1311
+ blk.titleLength = rawBlock.text.length;
1312
+ }
849
1313
  if (vdtType === 'mathDisplay' && mathDisplayRender) {
850
1314
  blk.mathRender = mathDisplayRender;
851
1315
  blk.tex = rawBlock.tex;
@@ -935,6 +1399,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
935
1399
  const splitLines = remainingLines.slice(0, choice.splitAt);
936
1400
  const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
937
1401
  applyStyleAttrs(blk, style);
1402
+ if (letterSpacingPx !== undefined)
1403
+ blk.letterSpacing = letterSpacingPx;
938
1404
  blk.contentIndex = blockIdx;
939
1405
  if (partIndex === 0) {
940
1406
  blk.headingLevel = headingLevel;
@@ -969,9 +1435,9 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
969
1435
  // pull those headings along so they don't remain stranded as orphans
970
1436
  // at the column's bottom. Mirrors the rollback inside the "fits" path.
971
1437
  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;
1438
+ const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
1439
+ if (rolledBack.length > 0) {
1440
+ blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
975
1441
  pendingSpacing = 0;
976
1442
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
977
1443
  break;
@@ -985,9 +1451,21 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
985
1451
  const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
986
1452
  const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
987
1453
  applyStyleAttrs(blk, style);
1454
+ if (letterSpacingPx !== undefined)
1455
+ blk.letterSpacing = letterSpacingPx;
988
1456
  blk.contentIndex = blockIdx;
989
1457
  if (partIndex === 0)
990
1458
  blk.headingLevel = headingLevel;
1459
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrs)
1460
+ blk.attrs = rawBlock.attrs;
1461
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrSources) {
1462
+ // Parser ranges are body-relative; VDT source offsets are absolute.
1463
+ blk.attrSources = Object.fromEntries(Object.entries(rawBlock.attrSources).map(([k, r]) => [k, { start: r.start + bodyOffset, end: r.end + bodyOffset }]));
1464
+ }
1465
+ if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
1466
+ blk.titleBreaks = rawBlock.titleBreaks;
1467
+ blk.titleLength = rawBlock.text.length;
1468
+ }
991
1469
  blk.lines = resetLinePositions(remainingLines, style.lineHeightPx);
992
1470
  blk.dirty = false;
993
1471
  blk.snappedToGrid = false;
@@ -1027,10 +1505,19 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
1027
1505
  buildHeadersAndFooters(doc);
1028
1506
  doc.converged = true;
1029
1507
  doc.iterationCount = 1;
1030
- return { doc, forcedBreakPages };
1508
+ return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome };
1031
1509
  }
1032
1510
  export function buildDocument(content, config, cache, options) {
1033
- let best = buildDocumentPass(content, config, cache, options);
1511
+ // --- Band caps (page-span blocks mid-page) -----------------------------
1512
+ // A span block that arrived in an uneven band proposes a cap; the driver
1513
+ // re-places the document with it (and grows / drops caps whose band
1514
+ // overflowed) before balancing runs. Documents without such blocks get
1515
+ // their first pass back untouched — no extra pass.
1516
+ const bands = resolveBandCaps(buildDocumentPass(content, config, cache, options), (bandCaps) => buildDocumentPass(content, config, cache, options, { bandCaps }));
1517
+ let best = bands.result;
1518
+ const bandCaps = bands.bandCaps;
1519
+ let passCount = bands.passCount;
1520
+ best.doc.iterationCount = passCount;
1034
1521
  // --- Column balancing (vertical justification) ------------------------
1035
1522
  // Iteratively re-place the document with extra grid lines above headings
1036
1523
  // until every balanceable column ends flush with the page bottom (or no
@@ -1045,7 +1532,6 @@ export function buildDocument(content, config, cache, options) {
1045
1532
  let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
1046
1533
  let applied = { lines: new Map(), loose: new Map() };
1047
1534
  const failedLoose = new Set();
1048
- let passCount = 1;
1049
1535
  let converged = bestScore === 0;
1050
1536
  while (!converged && passCount < MAX_BALANCING_PASSES) {
1051
1537
  const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
@@ -1053,6 +1539,7 @@ export function buildDocument(content, config, cache, options) {
1053
1539
  stretchAfterLists: balancing.stretchAfterLists,
1054
1540
  maxLinesAfterList: balancing.maxLinesAfterList,
1055
1541
  looseParagraphs: balancing.looseParagraphs,
1542
+ maxLooseParagraphs: balancing.maxLooseParagraphs,
1056
1543
  optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
1057
1544
  failedLoose,
1058
1545
  });
@@ -1064,24 +1551,44 @@ export function buildDocument(content, config, cache, options) {
1064
1551
  const extraPx = new Map();
1065
1552
  for (const [idx, n] of proposal.lines)
1066
1553
  extraPx.set(idx, n * best.doc.baselineGrid);
1067
- const next = buildDocumentPass(content, config, cache, options, extraPx, proposal.loose);
1554
+ const next = buildDocumentPass(content, config, cache, options, {
1555
+ balanceExtraPx: extraPx,
1556
+ balanceLooseness: proposal.loose,
1557
+ balanceLooseBudget: proposal.looseBudget,
1558
+ bandCaps,
1559
+ });
1068
1560
  passCount++;
1069
- const score = totalGapLines(next.doc, next.forcedBreakPages);
1561
+ // Band caps ride along unchanged; a retry that unsettles one (its span
1562
+ // block no longer lands in the capped band) counts as a regression —
1563
+ // capped columns without their box are not a layout we may keep.
1564
+ const capsDelivered = [...bandCaps.keys()].every((i) => next.spanPlacedInBand.has(i));
1565
+ const score = capsDelivered ? totalGapLines(next.doc, next.forcedBreakPages) : Infinity;
1566
+ // Loose paragraphs that gained no line at any tracking rung are
1567
+ // blacklisted whatever the score did, and never counted as applied.
1568
+ // Candidates the pass never tried (their column's budget was met
1569
+ // first) stay eligible for a later proposal.
1570
+ const newlyLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
1571
+ const looseFailed = newlyLoose.filter((k) => next.looseOutcome.get(k) === null);
1572
+ for (const k of looseFailed)
1573
+ failedLoose.add(k);
1574
+ const looseWon = newlyLoose.filter((k) => typeof next.looseOutcome.get(k) === 'number');
1070
1575
  if (score < bestScore) {
1071
1576
  best = next;
1072
1577
  bestScore = score;
1073
- applied = { lines: proposal.lines, loose: proposal.loose };
1578
+ applied = {
1579
+ lines: proposal.lines,
1580
+ loose: new Map([...proposal.loose].filter(([k]) => applied.loose.has(k) || looseWon.includes(k))),
1581
+ };
1074
1582
  converged = score === 0;
1075
1583
  }
1076
1584
  else {
1077
- // Plateau or regression. When this attempt introduced NEW loose
1078
- // paragraphs, the K-P looseness fallback may simply have failed to
1079
- // gain a line within the stretch limit blacklist them and retry so
1080
- // the proposer falls through to the next candidate. A pure spacing
1585
+ // Plateau or regression. Retry when a loose candidate was just
1586
+ // blacklisted (the proposer falls through to the next one), or when
1587
+ // the new loose paragraphs gained their lines yet the layout did not
1588
+ // improve (the gain landed elsewhere drop them too). A pure spacing
1081
1589
  // plateau means we're done: keep the best layout found so far.
1082
- const newlyLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
1083
- if (newlyLoose.length > 0) {
1084
- for (const k of newlyLoose)
1590
+ if (looseFailed.length > 0 || looseWon.length > 0) {
1591
+ for (const k of looseWon)
1085
1592
  failedLoose.add(k);
1086
1593
  continue;
1087
1594
  }