postext 0.3.16 → 0.3.18

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 (228) hide show
  1. package/dist/__tests__/columnBalancing.test.d.ts +2 -0
  2. package/dist/__tests__/columnBalancing.test.d.ts.map +1 -0
  3. package/dist/__tests__/columnBalancing.test.js +194 -0
  4. package/dist/__tests__/columnBalancing.test.js.map +1 -0
  5. package/dist/__tests__/createLayout.test.js +12 -13
  6. package/dist/__tests__/createLayout.test.js.map +1 -1
  7. package/dist/__tests__/defaults/resourceTypes.test.d.ts +2 -0
  8. package/dist/__tests__/defaults/resourceTypes.test.d.ts.map +1 -0
  9. package/dist/__tests__/defaults/resourceTypes.test.js +69 -0
  10. package/dist/__tests__/defaults/resourceTypes.test.js.map +1 -0
  11. package/dist/__tests__/exports.test.js +60 -0
  12. package/dist/__tests__/exports.test.js.map +1 -1
  13. package/dist/__tests__/parse/inlineRef.test.d.ts +2 -0
  14. package/dist/__tests__/parse/inlineRef.test.d.ts.map +1 -0
  15. package/dist/__tests__/parse/inlineRef.test.js +83 -0
  16. package/dist/__tests__/parse/inlineRef.test.js.map +1 -0
  17. package/dist/__tests__/parse/resourceDirective.test.d.ts +2 -0
  18. package/dist/__tests__/parse/resourceDirective.test.d.ts.map +1 -0
  19. package/dist/__tests__/parse/resourceDirective.test.js +55 -0
  20. package/dist/__tests__/parse/resourceDirective.test.js.map +1 -0
  21. package/dist/__tests__/pipeline/floatPlacement.test.d.ts +2 -0
  22. package/dist/__tests__/pipeline/floatPlacement.test.d.ts.map +1 -0
  23. package/dist/__tests__/pipeline/floatPlacement.test.js +262 -0
  24. package/dist/__tests__/pipeline/floatPlacement.test.js.map +1 -0
  25. package/dist/__tests__/pipeline/inlineRefRender.test.d.ts +2 -0
  26. package/dist/__tests__/pipeline/inlineRefRender.test.d.ts.map +1 -0
  27. package/dist/__tests__/pipeline/inlineRefRender.test.js +107 -0
  28. package/dist/__tests__/pipeline/inlineRefRender.test.js.map +1 -0
  29. package/dist/__tests__/pipeline/resourceNumbering.test.d.ts +2 -0
  30. package/dist/__tests__/pipeline/resourceNumbering.test.d.ts.map +1 -0
  31. package/dist/__tests__/pipeline/resourceNumbering.test.js +186 -0
  32. package/dist/__tests__/pipeline/resourceNumbering.test.js.map +1 -0
  33. package/dist/__tests__/singleInk.test.d.ts +2 -0
  34. package/dist/__tests__/singleInk.test.d.ts.map +1 -0
  35. package/dist/__tests__/singleInk.test.js +42 -0
  36. package/dist/__tests__/singleInk.test.js.map +1 -0
  37. package/dist/__tests__/table/model.test.d.ts +2 -0
  38. package/dist/__tests__/table/model.test.d.ts.map +1 -0
  39. package/dist/__tests__/table/model.test.js +187 -0
  40. package/dist/__tests__/table/model.test.js.map +1 -0
  41. package/dist/canvas-backend/blockRender.d.ts.map +1 -1
  42. package/dist/canvas-backend/blockRender.js +103 -66
  43. package/dist/canvas-backend/blockRender.js.map +1 -1
  44. package/dist/canvas-backend/decorations.d.ts +4 -1
  45. package/dist/canvas-backend/decorations.d.ts.map +1 -1
  46. package/dist/canvas-backend/decorations.js +14 -6
  47. package/dist/canvas-backend/decorations.js.map +1 -1
  48. package/dist/canvas-backend/headerFooter.d.ts +2 -2
  49. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  50. package/dist/canvas-backend/headerFooter.js +62 -2
  51. package/dist/canvas-backend/headerFooter.js.map +1 -1
  52. package/dist/canvas-backend/index.d.ts +2 -0
  53. package/dist/canvas-backend/index.d.ts.map +1 -1
  54. package/dist/canvas-backend/index.js +20 -10
  55. package/dist/canvas-backend/index.js.map +1 -1
  56. package/dist/canvas-backend/renderResourceBlock.d.ts +28 -0
  57. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -0
  58. package/dist/canvas-backend/renderResourceBlock.js +146 -0
  59. package/dist/canvas-backend/renderResourceBlock.js.map +1 -0
  60. package/dist/defaults/bodyText.d.ts.map +1 -1
  61. package/dist/defaults/bodyText.js +30 -0
  62. package/dist/defaults/bodyText.js.map +1 -1
  63. package/dist/defaults/captionStyle.d.ts +9 -0
  64. package/dist/defaults/captionStyle.d.ts.map +1 -0
  65. package/dist/defaults/captionStyle.js +74 -0
  66. package/dist/defaults/captionStyle.js.map +1 -0
  67. package/dist/defaults/debug.d.ts.map +1 -1
  68. package/dist/defaults/debug.js +6 -0
  69. package/dist/defaults/debug.js.map +1 -1
  70. package/dist/defaults/diagramStyle.d.ts +5 -0
  71. package/dist/defaults/diagramStyle.d.ts.map +1 -0
  72. package/dist/defaults/diagramStyle.js +29 -0
  73. package/dist/defaults/diagramStyle.js.map +1 -0
  74. package/dist/defaults/headerFooter.d.ts +20 -18
  75. package/dist/defaults/headerFooter.d.ts.map +1 -1
  76. package/dist/defaults/headerFooter.js +269 -165
  77. package/dist/defaults/headerFooter.js.map +1 -1
  78. package/dist/defaults/headings.d.ts +4 -0
  79. package/dist/defaults/headings.d.ts.map +1 -1
  80. package/dist/defaults/headings.js +65 -9
  81. package/dist/defaults/headings.js.map +1 -1
  82. package/dist/defaults/index.d.ts +5 -1
  83. package/dist/defaults/index.d.ts.map +1 -1
  84. package/dist/defaults/index.js +29 -1
  85. package/dist/defaults/index.js.map +1 -1
  86. package/dist/defaults/resourceTypes.d.ts +8 -0
  87. package/dist/defaults/resourceTypes.d.ts.map +1 -0
  88. package/dist/defaults/resourceTypes.js +43 -0
  89. package/dist/defaults/resourceTypes.js.map +1 -0
  90. package/dist/defaults/shared.d.ts.map +1 -1
  91. package/dist/defaults/shared.js +40 -0
  92. package/dist/defaults/shared.js.map +1 -1
  93. package/dist/defaults/tableStyle.d.ts +11 -0
  94. package/dist/defaults/tableStyle.d.ts.map +1 -0
  95. package/dist/defaults/tableStyle.js +114 -0
  96. package/dist/defaults/tableStyle.js.map +1 -0
  97. package/dist/design/layout.d.ts +94 -0
  98. package/dist/design/layout.d.ts.map +1 -0
  99. package/dist/design/layout.js +642 -0
  100. package/dist/design/layout.js.map +1 -0
  101. package/dist/design/placeholders.d.ts +29 -0
  102. package/dist/design/placeholders.d.ts.map +1 -0
  103. package/dist/design/placeholders.js +126 -0
  104. package/dist/design/placeholders.js.map +1 -0
  105. package/dist/html-backend.d.ts +6 -0
  106. package/dist/html-backend.d.ts.map +1 -1
  107. package/dist/html-backend.js +274 -10
  108. package/dist/html-backend.js.map +1 -1
  109. package/dist/index.d.ts +14 -5
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +8 -2
  112. package/dist/index.js.map +1 -1
  113. package/dist/knuthPlass/breakpoints.d.ts.map +1 -1
  114. package/dist/knuthPlass/breakpoints.js +41 -98
  115. package/dist/knuthPlass/breakpoints.js.map +1 -1
  116. package/dist/knuthPlass/richAdapter.d.ts +1 -0
  117. package/dist/knuthPlass/richAdapter.d.ts.map +1 -1
  118. package/dist/knuthPlass/richAdapter.js +2 -1
  119. package/dist/knuthPlass/richAdapter.js.map +1 -1
  120. package/dist/measure/cache.js +1 -1
  121. package/dist/measure/cache.js.map +1 -1
  122. package/dist/measure/canvas.d.ts +3 -0
  123. package/dist/measure/canvas.d.ts.map +1 -1
  124. package/dist/measure/canvas.js +40 -2
  125. package/dist/measure/canvas.js.map +1 -1
  126. package/dist/measure/font.d.ts +3 -2
  127. package/dist/measure/font.d.ts.map +1 -1
  128. package/dist/measure/font.js +5 -2
  129. package/dist/measure/font.js.map +1 -1
  130. package/dist/measure/plain.d.ts.map +1 -1
  131. package/dist/measure/plain.js +14 -7
  132. package/dist/measure/plain.js.map +1 -1
  133. package/dist/measure/rich.d.ts +7 -0
  134. package/dist/measure/rich.d.ts.map +1 -1
  135. package/dist/measure/rich.js +34 -7
  136. package/dist/measure/rich.js.map +1 -1
  137. package/dist/numbering.d.ts +16 -0
  138. package/dist/numbering.d.ts.map +1 -1
  139. package/dist/numbering.js +28 -18
  140. package/dist/numbering.js.map +1 -1
  141. package/dist/parse/blockParser.d.ts.map +1 -1
  142. package/dist/parse/blockParser.js +37 -9
  143. package/dist/parse/blockParser.js.map +1 -1
  144. package/dist/parse/injectSpans.d.ts +9 -0
  145. package/dist/parse/injectSpans.d.ts.map +1 -0
  146. package/dist/parse/injectSpans.js +35 -0
  147. package/dist/parse/injectSpans.js.map +1 -0
  148. package/dist/parse/inlineFormatting.d.ts +38 -0
  149. package/dist/parse/inlineFormatting.d.ts.map +1 -1
  150. package/dist/parse/inlineFormatting.js +58 -0
  151. package/dist/parse/inlineFormatting.js.map +1 -1
  152. package/dist/parse/inlineMath.d.ts.map +1 -1
  153. package/dist/parse/inlineMath.js +26 -43
  154. package/dist/parse/inlineMath.js.map +1 -1
  155. package/dist/parse/sourceMapping.d.ts.map +1 -1
  156. package/dist/parse/sourceMapping.js +34 -7
  157. package/dist/parse/sourceMapping.js.map +1 -1
  158. package/dist/parse/types.d.ts +20 -1
  159. package/dist/parse/types.d.ts.map +1 -1
  160. package/dist/pipeline/build.d.ts.map +1 -1
  161. package/dist/pipeline/build.js +521 -28
  162. package/dist/pipeline/build.js.map +1 -1
  163. package/dist/pipeline/buildBlockKind.d.ts +14 -0
  164. package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
  165. package/dist/pipeline/buildBlockKind.js +16 -1
  166. package/dist/pipeline/buildBlockKind.js.map +1 -1
  167. package/dist/pipeline/buildHelpers.d.ts.map +1 -1
  168. package/dist/pipeline/buildHelpers.js +7 -1
  169. package/dist/pipeline/buildHelpers.js.map +1 -1
  170. package/dist/pipeline/buildMeasurement.d.ts +17 -1
  171. package/dist/pipeline/buildMeasurement.d.ts.map +1 -1
  172. package/dist/pipeline/buildMeasurement.js +32 -0
  173. package/dist/pipeline/buildMeasurement.js.map +1 -1
  174. package/dist/pipeline/columnBalancing.d.ts +75 -0
  175. package/dist/pipeline/columnBalancing.d.ts.map +1 -0
  176. package/dist/pipeline/columnBalancing.js +125 -0
  177. package/dist/pipeline/columnBalancing.js.map +1 -0
  178. package/dist/pipeline/config.d.ts +4 -1
  179. package/dist/pipeline/config.d.ts.map +1 -1
  180. package/dist/pipeline/config.js +12 -1
  181. package/dist/pipeline/config.js.map +1 -1
  182. package/dist/pipeline/floatPlacement.d.ts +45 -0
  183. package/dist/pipeline/floatPlacement.d.ts.map +1 -0
  184. package/dist/pipeline/floatPlacement.js +68 -0
  185. package/dist/pipeline/floatPlacement.js.map +1 -0
  186. package/dist/pipeline/headerFooter.d.ts +23 -7
  187. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  188. package/dist/pipeline/headerFooter.js +260 -100
  189. package/dist/pipeline/headerFooter.js.map +1 -1
  190. package/dist/pipeline/lists.d.ts +4 -9
  191. package/dist/pipeline/lists.d.ts.map +1 -1
  192. package/dist/pipeline/lists.js +24 -42
  193. package/dist/pipeline/lists.js.map +1 -1
  194. package/dist/pipeline/placeholders.d.ts +6 -0
  195. package/dist/pipeline/placeholders.d.ts.map +1 -1
  196. package/dist/pipeline/placeholders.js +18 -5
  197. package/dist/pipeline/placeholders.js.map +1 -1
  198. package/dist/pipeline/placement.d.ts +15 -2
  199. package/dist/pipeline/placement.d.ts.map +1 -1
  200. package/dist/pipeline/placement.js +38 -3
  201. package/dist/pipeline/placement.js.map +1 -1
  202. package/dist/pipeline/resourceLayout.d.ts +58 -0
  203. package/dist/pipeline/resourceLayout.d.ts.map +1 -0
  204. package/dist/pipeline/resourceLayout.js +338 -0
  205. package/dist/pipeline/resourceLayout.js.map +1 -0
  206. package/dist/pipeline/resourceNumbering.d.ts +54 -0
  207. package/dist/pipeline/resourceNumbering.d.ts.map +1 -0
  208. package/dist/pipeline/resourceNumbering.js +218 -0
  209. package/dist/pipeline/resourceNumbering.js.map +1 -0
  210. package/dist/pipeline/styles.d.ts +6 -0
  211. package/dist/pipeline/styles.d.ts.map +1 -1
  212. package/dist/pipeline/styles.js +1 -1
  213. package/dist/pipeline/styles.js.map +1 -1
  214. package/dist/svg/singleInk.d.ts +10 -0
  215. package/dist/svg/singleInk.d.ts.map +1 -0
  216. package/dist/svg/singleInk.js +86 -0
  217. package/dist/svg/singleInk.js.map +1 -0
  218. package/dist/table/model.d.ts +53 -0
  219. package/dist/table/model.d.ts.map +1 -0
  220. package/dist/table/model.js +253 -0
  221. package/dist/table/model.js.map +1 -0
  222. package/dist/types.d.ts +429 -41
  223. package/dist/types.d.ts.map +1 -1
  224. package/dist/vdt.d.ts +181 -18
  225. package/dist/vdt.d.ts.map +1 -1
  226. package/dist/vdt.js +34 -0
  227. package/dist/vdt.js.map +1 -1
  228. package/package.json +6 -6
@@ -1,26 +1,47 @@
1
1
  import { dimensionToPx } from '../units';
2
- import { createVDTDocument, createVDTBlock, } from '../vdt';
2
+ import { createVDTDocument, createVDTBlock, createBoundingBox, } from '../vdt';
3
3
  import { parseMarkdownMemo } from '../parse';
4
4
  import { buildPageLabels, computeHeadingNumbers, } from '../numbering';
5
5
  import { extractFrontmatter } from '../frontmatter';
6
6
  import { initHyphenator } from '../measure';
7
- import { resolveAllConfig, computeBaselineGrid } from './config';
7
+ import { resolveAllConfig, computeBaselineGrid, buildHeadingLevelMap } from './config';
8
8
  import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
9
9
  import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
10
- import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, } from './placement';
10
+ import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeResourceBlock, } from './placement';
11
11
  import { chooseParagraphSplit } from './orphanWidow';
12
12
  import { applyStyleAttrs, computeMeasureViewport, computePageMetrics, enrichMathSpans, rollbackTrailingBlocks, stampSourceRanges, } from './buildHelpers';
13
13
  import { resolveBlockKind } from './buildBlockKind';
14
14
  import { runMeasurement } from './buildMeasurement';
15
- import { buildHeadersAndFooters } from './headerFooter';
15
+ import { resolveRefSpans, layoutResourceBlock } from './resourceLayout';
16
+ import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
17
+ import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
18
+ import { defaultResourceTypes } from '../defaults/resourceTypes';
19
+ import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
20
+ import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES } from './columnBalancing';
16
21
  export class BuildCancelledError extends Error {
17
22
  constructor() {
18
23
  super('Build cancelled');
19
24
  this.name = 'BuildCancelledError';
20
25
  }
21
26
  }
22
- export function buildDocument(content, config, cache, options) {
27
+ /** Page-number formats accepted by the `::numbering` directive. */
28
+ const ALLOWED_PAGE_FORMATS = new Set([
29
+ 'decimal',
30
+ 'lower-roman',
31
+ 'upper-roman',
32
+ 'lower-alpha',
33
+ 'upper-alpha',
34
+ ]);
35
+ /**
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.
41
+ */
42
+ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
23
43
  const resolved = resolveAllConfig(config);
44
+ const headingLevelByNumber = buildHeadingLevelMap(resolved);
24
45
  const dpi = resolved.page.dpi;
25
46
  // Initialize hyphenator if needed
26
47
  if (resolved.bodyText.hyphenation.enabled && resolved.bodyText.textAlign === 'justify') {
@@ -46,16 +67,266 @@ export function buildDocument(content, config, cache, options) {
46
67
  }
47
68
  }
48
69
  const headingPrefixes = computeHeadingNumbers(contentBlocks, headingTemplates);
70
+ // Resource numbering — computed up front (before the placement loop) so that
71
+ // captions and inline `:ref`s can resolve their rendered number strings
72
+ // before measurement. Numbering follows order of first reference in the
73
+ // document.
74
+ const resourceTypes = config?.resourceTypes ?? defaultResourceTypes();
75
+ const resources = content.resources ?? [];
76
+ const headingContext = computeHeadingContext(contentBlocks);
77
+ const resourceNumbering = computeResourceNumbering(contentBlocks, resourceTypes, resources, headingContext);
78
+ // Lookups threaded into block-kind resolution + measurement.
79
+ const resourceById = new Map();
80
+ for (const r of resources)
81
+ resourceById.set(r.id, r);
82
+ const resourceTypeById = new Map();
83
+ for (const t of resourceTypes)
84
+ resourceTypeById.set(t.id, t);
85
+ const resourceNumberById = new Map();
86
+ for (const [id, entry] of Object.entries(resourceNumbering)) {
87
+ resourceNumberById.set(id, entry.number);
88
+ }
49
89
  // Resolve styles
50
90
  const bodyStyle = resolveBodyStyle(resolved);
51
91
  const blockquoteStyle = resolveBlockquoteStyle(resolved);
52
92
  const listLevelIndentsPx = computeLevelIndentsPx(resolved, bodyStyle.fontSizePx);
53
93
  const orderedMetrics = computeOrderedListRunMetrics(contentBlocks, resolved, bodyStyle.fontSizePx);
54
94
  const orderedLevelIndentsPx = computeOrderedLevelIndentsPx(resolved, bodyStyle.fontSizePx, orderedMetrics.maxWidthByDepth);
95
+ // --- Float planning (issue #49 — resources float to page bands) ----------
96
+ // A resource is incorporated by its first reference (an inline `:ref` or a
97
+ // `::resource` directive, whichever comes first in reading order). Floated
98
+ // resources detach from the running text and reserve a band at the top or
99
+ // bottom of the next page opened after that reference; the text flows past
100
+ // the reference uninterrupted. `position: 'here'` resources keep inline
101
+ // `::resource` placement and are not floated.
102
+ const floatPlan = computeFloatPlan(contentBlocks, resources, resourceTypes);
103
+ const floatedIds = floatedResourceIds(floatPlan);
104
+ const floatsByFirstBlock = new Map();
105
+ for (const f of floatPlan) {
106
+ const list = floatsByFirstBlock.get(f.firstBlockIdx);
107
+ if (list)
108
+ list.push(f);
109
+ else
110
+ floatsByFirstBlock.set(f.firstBlockIdx, [f]);
111
+ }
112
+ // Floats whose first reference has been passed but which are not yet placed
113
+ // into a page band, in reading order.
114
+ const pendingFloats = [];
115
+ const floatGapPx = bodyStyle.lineHeightPx;
116
+ const minTextPx = bodyStyle.lineHeightPx * 3;
117
+ /** Offset a resolved resource block's caption/table geometry from
118
+ * block-relative to absolute page coordinates (mirrors inline placement). */
119
+ const offsetResourceBlockToAbsolute = (rb, ox, oy) => {
120
+ for (const ln of rb.captionLines) {
121
+ ln.bbox.x += ox;
122
+ ln.bbox.y += oy;
123
+ ln.baseline += oy;
124
+ }
125
+ if (rb.table) {
126
+ for (const cell of rb.table.cells) {
127
+ cell.rect.x += ox;
128
+ cell.rect.y += oy;
129
+ for (const cl of cell.lines) {
130
+ cl.bbox.x += ox;
131
+ cl.bbox.y += oy;
132
+ cl.baseline += oy;
133
+ }
134
+ }
135
+ }
136
+ };
137
+ /** Measure + build a float block at horizontal offset `x` (y = 0), or null
138
+ * when the resource id is unknown. Caller offsets it to its final `y`. */
139
+ const buildFloatBlock = (resourceId, x, width) => {
140
+ const resource = resourceById.get(resourceId);
141
+ if (!resource)
142
+ return null;
143
+ const { block: rb, totalHeight } = layoutResourceBlock({
144
+ resource,
145
+ resourceType: resourceTypeById.get(resource.typeId),
146
+ number: resourceNumberById.get(resourceId) ?? '',
147
+ resolved,
148
+ columnWidth: width,
149
+ resourceNumbering,
150
+ resourceTypes,
151
+ resources,
152
+ });
153
+ const blk = createVDTBlock(`float-${resourceId}`, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
154
+ blk.resourceBlock = rb;
155
+ blk.dirty = false;
156
+ blk.snappedToGrid = false;
157
+ blk.bbox = createBoundingBox(x, 0, width, totalHeight);
158
+ blk.lines = [];
159
+ offsetResourceBlockToAbsolute(rb, x, 0);
160
+ return { block: blk, height: totalHeight };
161
+ };
162
+ /** Reserve top/bottom bands on a freshly opened page and position as many
163
+ * pending floats as fit, shrinking the affected columns so body text flows
164
+ * around them. Preserves reading order: stops at the first float that does
165
+ * not fit (so figures never reorder relative to their references), except
166
+ * on a band that is still all-text, where a dominating/oversized float is
167
+ * force-placed so the queue always makes progress. */
168
+ const flushFloatsIntoPage = (page) => {
169
+ if (pendingFloats.length === 0)
170
+ return;
171
+ const topUsed = page.columns.map(() => 0);
172
+ const botUsed = page.columns.map(() => 0);
173
+ const floats = page.floats ?? [];
174
+ /** Try to place one float on this page. Returns whether it was placed,
175
+ * must be deferred (does not fit), or skipped (unknown id). Only mutates
176
+ * page geometry when it actually places. */
177
+ const attemptFloat = (f) => {
178
+ const pageSpan = f.span === 'page' && page.columns.length > 1;
179
+ let targetCols;
180
+ if (pageSpan) {
181
+ targetCols = page.columns.map((_, i) => i);
182
+ }
183
+ else {
184
+ // Single-column float: pick the column with the most room left.
185
+ let best = 0;
186
+ for (let i = 1; i < page.columns.length; i++) {
187
+ if (topUsed[i] + botUsed[i] < topUsed[best] + botUsed[best])
188
+ best = i;
189
+ }
190
+ targetCols = [best];
191
+ }
192
+ const firstCol = page.columns[targetCols[0]];
193
+ const width = pageSpan ? contentArea.width : firstCol.bbox.width;
194
+ const xLeft = pageSpan ? contentArea.x : firstCol.bbox.x;
195
+ const built = buildFloatBlock(f.resourceId, xLeft, width);
196
+ if (!built)
197
+ return 'skip';
198
+ let minAvail = Infinity;
199
+ let anyReserved = false;
200
+ for (const c of targetCols) {
201
+ minAvail = Math.min(minAvail, page.columns[c].availableHeight);
202
+ if (topUsed[c] > 0 || botUsed[c] > 0)
203
+ anyReserved = true;
204
+ }
205
+ // Both band kinds are corrected against the baseline grid so the text
206
+ // around them — and the facing page — stays on the global rhythm:
207
+ // - top: the band pushes the column start (`col.bbox.y`) downward, and
208
+ // all grid snapping inside the column is anchored at that start. The
209
+ // band height is rounded up to a grid multiple (growing the gap
210
+ // below the float) or every line in the displaced column would land
211
+ // off-grid, visibly misaligned with neighbouring columns.
212
+ // - bottom: the float is anchored so its visual bottom sits on the
213
+ // grid — the caption's last line shares its baseline with the last
214
+ // text line of the other columns (captionless content aligns its
215
+ // bottom edge to the last grid slot). Pages then end at the same
216
+ // height across columns and across facing pages.
217
+ let need;
218
+ let floatY = 0;
219
+ if (f.position === 'top') {
220
+ const rawNeed = built.height + floatGapPx;
221
+ need = Math.ceil((rawNeed - 0.01) / baselineGrid) * baselineGrid;
222
+ }
223
+ else {
224
+ const bottomLimit = Math.min(...targetCols.map((c) => {
225
+ const cb = page.columns[c].bbox;
226
+ return cb.y + cb.height;
227
+ }));
228
+ const gridAlignedBottom = contentArea.y
229
+ + Math.floor((bottomLimit - contentArea.y + 0.01) / baselineGrid) * baselineGrid;
230
+ const capLines = built.block.resourceBlock.captionLines;
231
+ if (capLines.length > 0) {
232
+ // Body baselines sit at 0.2 × grid above each slot bottom; anchor
233
+ // the caption's last baseline there.
234
+ const lastBaseline = capLines[capLines.length - 1].baseline; // block-relative
235
+ floatY = gridAlignedBottom - 0.2 * baselineGrid - lastBaseline;
236
+ }
237
+ else {
238
+ floatY = gridAlignedBottom - built.height;
239
+ }
240
+ need = 0;
241
+ for (const c of targetCols) {
242
+ const col = page.columns[c];
243
+ need = Math.max(need, col.bbox.height - (floatY - floatGapPx - col.bbox.y));
244
+ }
245
+ }
246
+ // Keep some text room, unless this band is still all-text (then a
247
+ // dominating / oversized float is force-placed so the queue progresses).
248
+ if (need > minAvail - minTextPx && anyReserved)
249
+ return 'defer';
250
+ let y = 0;
251
+ for (const c of targetCols) {
252
+ const col = page.columns[c];
253
+ if (f.position === 'top') {
254
+ y = col.bbox.y; // float sits at the current top edge
255
+ col.bbox.y += need; // push column content below the band
256
+ col.bbox.height = Math.max(0, col.bbox.height - need);
257
+ col.availableHeight = Math.max(0, col.availableHeight - need);
258
+ topUsed[c] += need;
259
+ }
260
+ else {
261
+ y = floatY;
262
+ const newHeight = Math.max(0, floatY - floatGapPx - col.bbox.y);
263
+ const reserved = col.bbox.height - newHeight;
264
+ col.bbox.height = newHeight;
265
+ col.availableHeight = Math.max(0, col.availableHeight - reserved);
266
+ botUsed[c] += reserved;
267
+ }
268
+ }
269
+ offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, y);
270
+ built.block.bbox = createBoundingBox(xLeft, y, width, built.height);
271
+ built.block.pageIndex = page.index;
272
+ built.block.columnIndex = targetCols[0];
273
+ floats.push(built.block);
274
+ return 'placed';
275
+ };
276
+ // Full-width (page-span) floats reserve the outermost bands first, so a
277
+ // later single-column float nests inside the remaining column space rather
278
+ // than overlapping a full-width band. Within each pass, stop at the first
279
+ // float that does not fit to preserve reading order.
280
+ for (const pageSpanPass of [true, false]) {
281
+ let i = 0;
282
+ while (i < pendingFloats.length) {
283
+ const f = pendingFloats[i];
284
+ const isPageSpan = f.span === 'page' && page.columns.length > 1;
285
+ if (isPageSpan !== pageSpanPass) {
286
+ i++;
287
+ continue;
288
+ }
289
+ const r = attemptFloat(f);
290
+ if (r === 'placed' || r === 'skip')
291
+ pendingFloats.splice(i, 1);
292
+ else
293
+ break; // defer: leave this and the rest of the pass for a later page
294
+ }
295
+ }
296
+ if (floats.length > 0)
297
+ page.floats = floats;
298
+ };
299
+ /** Drain floats still pending after body placement (referenced on the last
300
+ * page, or never followed by a content-overflow page break) onto freshly
301
+ * appended pages. Each new page force-places at least one float. */
302
+ const finalizeFloats = () => {
303
+ let guard = 0;
304
+ while (pendingFloats.length > 0 && guard++ < 1000) {
305
+ const before = pendingFloats.length;
306
+ const page = createPageWithColumns(doc.pages.length, resolved, contentArea, pageWidthPx, pageHeightPx);
307
+ doc.pages.push(page);
308
+ flushFloatsIntoPage(page);
309
+ if (pendingFloats.length === before)
310
+ break; // safety: no progress
311
+ }
312
+ };
313
+ /** Reserve floats on each freshly opened content page. Passed only to the
314
+ * content-flow column advances — parity / force-blank pages never get it. */
315
+ const onNewPage = (page) => flushFloatsIntoPage(page);
55
316
  // Placement cursor
56
317
  const cursor = { pageIndex: 0, columnIndex: 0 };
57
318
  let blockIdCounter = 0;
58
319
  let pendingSpacing = 0;
320
+ // Pages whose break into the next page is explicit rather than natural
321
+ // overflow. Column balancing leaves their last column short (a chapter's
322
+ // closing page legitimately ends early).
323
+ const forcedBreakPages = new Set();
324
+ const markForcedBreak = () => {
325
+ const curPage = doc.pages[cursor.pageIndex];
326
+ if (curPage.columns.some((c) => c.blocks.length > 0)) {
327
+ forcedBreakPages.add(cursor.pageIndex);
328
+ }
329
+ };
59
330
  // Page-numbering segments. The implicit first segment comes from
60
331
  // `cfg.page.pageNumbering`; `:::numbering` directives append more,
61
332
  // each applied at the next page boundary.
@@ -82,23 +353,24 @@ export function buildDocument(content, config, cache, options) {
82
353
  lastSeenPageIndex = cursor.pageIndex;
83
354
  }
84
355
  };
85
- const ALLOWED_PAGE_FORMATS = new Set([
86
- 'decimal',
87
- 'lower-roman',
88
- 'upper-roman',
89
- 'lower-alpha',
90
- 'upper-alpha',
91
- ]);
92
356
  for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
93
357
  if (options?.shouldCancel?.())
94
358
  throw new BuildCancelledError();
95
359
  const rawBlock = contentBlocks[blockIdx];
360
+ // Enqueue floats first-referenced in this block so the next page opened
361
+ // while placing it (or any later block) reserves their band. Done before
362
+ // placement so a reference near a column/page boundary still floats onto
363
+ // the page that follows it.
364
+ const floatsHere = floatsByFirstBlock.get(blockIdx);
365
+ if (floatsHere)
366
+ pendingFloats.push(...floatsHere);
96
367
  // --- Directives ----------------------------------------------------
97
368
  if (rawBlock.type === 'directive') {
98
369
  const name = rawBlock.directiveName;
99
370
  const attrs = rawBlock.directiveAttrs ?? {};
100
371
  if (name === 'pagebreak') {
101
372
  pendingSpacing = 0;
373
+ markForcedBreak();
102
374
  advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
103
375
  const parity = attrs.parity;
104
376
  if (parity === 'odd'
@@ -126,16 +398,28 @@ export function buildDocument(content, config, cache, options) {
126
398
  }
127
399
  // --- Heading `breakBefore` ----------------------------------------
128
400
  if (rawBlock.type === 'heading' && rawBlock.level) {
129
- const level = resolved.headings.levels.find((l) => l.level === rawBlock.level);
401
+ const level = headingLevelByNumber.get(rawBlock.level);
130
402
  const bb = level?.breakBefore;
131
403
  if (bb && bb.enabled) {
132
404
  pendingSpacing = 0;
405
+ markForcedBreak();
133
406
  advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
134
407
  if (bb.parity !== 'any') {
135
408
  enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, bb.parity);
136
409
  }
137
410
  flushPendingNumberingAtBoundary();
138
411
  }
412
+ // `span: 'page'` headings open a chapter band across the full content
413
+ // width. Always start on a fresh page boundary so the band sits at the
414
+ // page top, and reset the cursor to column 0 so all other columns will
415
+ // have their availableHeight reduced symmetrically after placement.
416
+ if (level?.span === 'page') {
417
+ pendingSpacing = 0;
418
+ markForcedBreak();
419
+ advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
420
+ cursor.columnIndex = 0;
421
+ flushPendingNumberingAtBoundary();
422
+ }
139
423
  }
140
424
  const id = `block-${blockIdCounter++}`;
141
425
  const kind = resolveBlockKind(rawBlock, {
@@ -147,24 +431,135 @@ export function buildDocument(content, config, cache, options) {
147
431
  listLevelIndentsPx,
148
432
  orderedLevelIndentsPx,
149
433
  orderedMetrics,
434
+ resourceById,
435
+ resourceTypeById,
436
+ resourceNumberById,
150
437
  });
151
438
  const { style, vdtType, headingLevel, numberPrefix, listBullet, listDepth, listKind, bulletXOffsetInColumn, strikethroughText } = kind;
152
439
  let contentBlock = kind.contentBlock;
440
+ // --- 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).
443
+ 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
+ }
478
+ const groupHeight = measured.totalHeight;
479
+ const blk = createVDTBlock(id, 'resource', style.fontString, style.color, style.textAlign);
480
+ blk.contentIndex = blockIdx;
481
+ blk.resourceBlock = resourceBlock;
482
+ blk.dirty = false;
483
+ blk.snappedToGrid = false;
484
+ blk.sourceStart = rawBlock.sourceStart + bodyOffset;
485
+ blk.sourceEnd = rawBlock.sourceEnd + bodyOffset;
486
+ // The single placeholder line carries the group height; caption lines are
487
+ // carried on `resourceBlock` and offset to absolute coords below.
488
+ blk.lines = [{
489
+ text: '',
490
+ bbox: { x: 0, y: 0, width: resourceBlock.bodyRect.width, height: groupHeight },
491
+ baseline: 0,
492
+ hyphenated: false,
493
+ segments: [],
494
+ isLastLine: true,
495
+ }];
496
+ const spacingBefore = pendingSpacing;
497
+ placeResourceBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
498
+ // `placeBlockInColumn` (inside placeResourceBlock) shifts `blk.lines`; the
499
+ // resource's own caption/table lines live on `resourceBlock` and must be
500
+ // offset to absolute page coordinates here using the placed bbox origin.
501
+ offsetResourceBlockToAbsolute(resourceBlock, blk.bbox.x, blk.bbox.y);
502
+ doc.blocks.push(blk);
503
+ // Snap the flow position after the resource to the baseline grid (the
504
+ // group height is arbitrary), baking in at least marginBottom — same
505
+ // convention as snapped headings — so the following text lands back on
506
+ // the global grid instead of inheriting the resource's offset.
507
+ {
508
+ const rCol = currentColumn(doc, cursor);
509
+ const usedHeight = rCol.bbox.height - rCol.availableHeight;
510
+ const naturalBottom = usedHeight + style.marginBottomPx;
511
+ const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
512
+ rCol.availableHeight = Math.max(0, rCol.bbox.height - snappedBottom);
513
+ }
514
+ pendingSpacing = 0;
515
+ flushPendingNumberingAtBoundary();
516
+ continue;
517
+ }
153
518
  // Measure text — use rich measurement for blocks with bold spans
154
519
  const col = currentColumn(doc, cursor);
155
520
  // Resolve inline math on spans (no-op when the block has no math).
156
521
  const mathEnabled = resolved.math.enabled;
157
522
  contentBlock = enrichMathSpans(contentBlock, style, resolved);
158
- const hasRichSpans = contentBlock.spans.some((s) => s.bold || s.italic || s.mathRender);
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);
159
537
  // List items reserve horizontal space for indent + bullet + gap.
160
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
+ }
161
556
  const runtActive = resolved.bodyText.avoidRunts
162
557
  && (vdtType === 'paragraph'
163
558
  || (vdtType === 'listItem' && resolved.bodyText.avoidRuntsInLists));
164
559
  const measureOptions = {
165
560
  textAlign: style.textAlign,
166
561
  hyphenate: style.hyphenate,
167
- firstLineIndentPx: measureFirstLineIndent,
562
+ firstLineIndentPx: effectiveFirstLineIndent,
168
563
  hangingIndent: measureHangingIndent,
169
564
  optimal: resolved.bodyText.optimalLineBreaking,
170
565
  maxStretchRatio: resolved.bodyText.maxWordSpacing,
@@ -249,6 +644,17 @@ export function buildDocument(content, config, cache, options) {
249
644
  spacingBefore = pendingSpacing;
250
645
  if (vdtType === 'heading' || vdtType === 'mathDisplay') {
251
646
  spacingBefore = Math.max(spacingBefore, style.marginTopPx);
647
+ // Column balancing: extra whole grid lines above this heading so
648
+ // the column it sits in ends flush with the page bottom. Applied
649
+ // after margin collapsing — the heading's effective top margin
650
+ // grows by the assigned lines. Suppressed for first-in-column
651
+ // headings (no top margin there), keeping columns starting at the
652
+ // page top.
653
+ if (vdtType === 'heading') {
654
+ const extraPx = balanceExtraPx?.get(blockIdx);
655
+ if (extraPx)
656
+ spacingBefore += extraPx;
657
+ }
252
658
  }
253
659
  else if (vdtType === 'listItem') {
254
660
  const prevWasList = blockIdx > 0 && contentBlocks[blockIdx - 1].type === 'listItem';
@@ -264,6 +670,27 @@ export function buildDocument(content, config, cache, options) {
264
670
  const totalRemainHeight = vdtType === 'mathDisplay'
265
671
  ? (remainingLines[0]?.bbox.height ?? style.lineHeightPx)
266
672
  : remainingLines.length * style.lineHeightPx;
673
+ // For headings with an enabled advanced-design slot, the rendered
674
+ // overlay may extend below the natural text bottom. Measure the slot's
675
+ // actual content bottom so the reserved block height (and the
676
+ // subsequent marginBottom + grid snap) starts from there.
677
+ let effectiveRemainHeight = totalRemainHeight;
678
+ if (vdtType === 'heading' && headingLevel !== undefined && partIndex === 0) {
679
+ const lvl = headingLevelByNumber.get(headingLevel);
680
+ if (lvl) {
681
+ const full = remainingLines
682
+ .map((ln) => (ln.segments ?? []).map((s) => s.text).join(''))
683
+ .join(' ');
684
+ const pref = numberPrefix ?? '';
685
+ const title = pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full;
686
+ // Span-page openers lay out across the full content area (both
687
+ // columns); in-column headings use just the column width.
688
+ const measureWidth = lvl.span === 'page' ? contentArea.width : curCol.bbox.width;
689
+ const designBottom = measureHeadingAdvancedDesignHeight(lvl, { titleText: title, formattedNumber: pref, chapterNumber: pref }, measureWidth, resolved.page.dpi, doc.metadata, cursor.pageIndex);
690
+ if (designBottom > effectiveRemainHeight)
691
+ effectiveRemainHeight = designBottom;
692
+ }
693
+ }
267
694
  // Keep-with-list: if this colon-paragraph would fit but would leave no
268
695
  // room for the first list item, split off the colon line (or push the
269
696
  // whole paragraph when it is a single line or the split would leave a
@@ -294,6 +721,7 @@ export function buildDocument(content, config, cache, options) {
294
721
  const splitLines = remainingLines.slice(0, splitAt);
295
722
  const blk = createVDTBlock(id, vdtType, style.fontString, style.color, style.textAlign);
296
723
  applyStyleAttrs(blk, style);
724
+ blk.contentIndex = blockIdx;
297
725
  blk.headingLevel = headingLevel;
298
726
  if (numberPrefix)
299
727
  blk.numberPrefix = numberPrefix;
@@ -309,7 +737,7 @@ export function buildDocument(content, config, cache, options) {
309
737
  remainingLines = remainingLines.slice(splitAt);
310
738
  partIndex++;
311
739
  pendingSpacing = 0;
312
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
740
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
313
741
  continue;
314
742
  }
315
743
  // Can't cleanly split the colon line off — would create a widow.
@@ -339,19 +767,19 @@ export function buildDocument(content, config, cache, options) {
339
767
  }
340
768
  blockIdx -= headingRunCount + 1;
341
769
  pendingSpacing = 0;
342
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
770
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
343
771
  break;
344
772
  }
345
773
  if (headingRunCount === 0) {
346
774
  pendingSpacing = 0;
347
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
775
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
348
776
  continue;
349
777
  }
350
778
  // headingRunCount === curCol.blocks.length: fall through to place.
351
779
  }
352
780
  }
353
781
  // Block fits in current column
354
- if (totalRemainHeight <= effectiveAvailable) {
782
+ if (effectiveRemainHeight <= effectiveAvailable) {
355
783
  // Heading keep-with-next: never leave a heading as the last block of a
356
784
  // column. If the following (non-heading) block wouldn't have room to
357
785
  // place at least its widow-minimum number of lines after this heading,
@@ -368,7 +796,7 @@ export function buildDocument(content, config, cache, options) {
368
796
  && nextBlock !== null
369
797
  && curCol.blocks.length > 0) {
370
798
  const wouldUsedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
371
- const naturalBottom = wouldUsedHeight + totalRemainHeight + style.marginBottomPx;
799
+ const naturalBottom = wouldUsedHeight + effectiveRemainHeight + style.marginBottomPx;
372
800
  const snappedBottom = shouldSnapToGrid
373
801
  ? Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid
374
802
  : naturalBottom;
@@ -386,11 +814,11 @@ export function buildDocument(content, config, cache, options) {
386
814
  // rolled-back heading.
387
815
  blockIdx -= rollbackCount + 1;
388
816
  pendingSpacing = 0;
389
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
817
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
390
818
  break;
391
819
  }
392
820
  pendingSpacing = 0;
393
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
821
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
394
822
  continue;
395
823
  }
396
824
  }
@@ -401,6 +829,7 @@ export function buildDocument(content, config, cache, options) {
401
829
  const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
402
830
  const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
403
831
  applyStyleAttrs(blk, style);
832
+ blk.contentIndex = blockIdx;
404
833
  if (partIndex === 0) {
405
834
  blk.headingLevel = headingLevel;
406
835
  if (numberPrefix)
@@ -428,7 +857,7 @@ export function buildDocument(content, config, cache, options) {
428
857
  }
429
858
  blk.sourceMap = absoluteSourceMap;
430
859
  blk.plainPrefixLen = prefixLen;
431
- let h = totalRemainHeight;
860
+ let h = effectiveRemainHeight;
432
861
  if (shouldSnapToGrid && partIndex === 0) {
433
862
  // Snap using the absolute position in the column so that the block
434
863
  // bottom lands on a baseline grid line. This accounts for off-grid
@@ -436,7 +865,7 @@ export function buildDocument(content, config, cache, options) {
436
865
  // the minimum marginBottom — the grid always wins, but the margin
437
866
  // below is guaranteed to be at least marginBottomPx.
438
867
  const usedHeight = curCol.bbox.height - curCol.availableHeight;
439
- const naturalBottom = usedHeight + totalRemainHeight + style.marginBottomPx;
868
+ const naturalBottom = usedHeight + effectiveRemainHeight + style.marginBottomPx;
440
869
  // Tolerance guards against FP drift: if naturalBottom is already on
441
870
  // the grid (e.g. marginBottom is an exact multiple of baselineGrid),
442
871
  // don't round up to the next line.
@@ -446,6 +875,20 @@ export function buildDocument(content, config, cache, options) {
446
875
  placeBlockInColumn(blk, h, curCol, cursor);
447
876
  finalizeListItem(blk, partIndex === 0);
448
877
  doc.blocks.push(blk);
878
+ // Page-spanning heading: reserve the same vertical band in every
879
+ // other column on this page so body text under the opener band
880
+ // starts below it in ALL columns, not just the one it was placed in.
881
+ if (vdtType === 'heading' && headingLevel !== undefined) {
882
+ const lvl = headingLevelByNumber.get(headingLevel);
883
+ if (lvl?.span === 'page') {
884
+ const page = doc.pages[cursor.pageIndex];
885
+ for (const otherCol of page.columns) {
886
+ if (otherCol !== curCol) {
887
+ otherCol.availableHeight = Math.max(0, otherCol.availableHeight - h);
888
+ }
889
+ }
890
+ }
891
+ }
449
892
  // For snapped headings/list-tails the margin is baked into the snap;
450
893
  // for unsnapped ones (consecutive) track it for collapsing
451
894
  if (vdtType === 'listItem' && nextIsListItem) {
@@ -481,6 +924,7 @@ export function buildDocument(content, config, cache, options) {
481
924
  const splitLines = remainingLines.slice(0, choice.splitAt);
482
925
  const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
483
926
  applyStyleAttrs(blk, style);
927
+ blk.contentIndex = blockIdx;
484
928
  if (partIndex === 0) {
485
929
  blk.headingLevel = headingLevel;
486
930
  if (numberPrefix)
@@ -502,7 +946,7 @@ export function buildDocument(content, config, cache, options) {
502
946
  remainingLines = remainingLines.slice(choice.splitAt);
503
947
  partIndex++;
504
948
  pendingSpacing = 0;
505
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
949
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
506
950
  continue;
507
951
  }
508
952
  // choice.splitAt === 0: fall through to push whole paragraph to next column
@@ -518,18 +962,19 @@ export function buildDocument(content, config, cache, options) {
518
962
  if (rollbackCount > 0) {
519
963
  blockIdx -= rollbackCount + 1;
520
964
  pendingSpacing = 0;
521
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
965
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
522
966
  break;
523
967
  }
524
968
  }
525
969
  pendingSpacing = 0;
526
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
970
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
527
971
  continue;
528
972
  }
529
973
  // Empty column but block still doesn't fit (block taller than page) — place anyway
530
974
  const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
531
975
  const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
532
976
  applyStyleAttrs(blk, style);
977
+ blk.contentIndex = blockIdx;
533
978
  if (partIndex === 0)
534
979
  blk.headingLevel = headingLevel;
535
980
  blk.lines = resetLinePositions(remainingLines, style.lineHeightPx);
@@ -554,6 +999,9 @@ export function buildDocument(content, config, cache, options) {
554
999
  }
555
1000
  flushPendingNumberingAtBoundary();
556
1001
  }
1002
+ // Place any floats still pending (referenced on the last page, or never
1003
+ // followed by a content-overflow page break) onto freshly appended pages.
1004
+ finalizeFloats();
557
1005
  // Stamp page-number info onto every page (including blank parity pages).
558
1006
  const labels = buildPageLabels(doc.pages.length, pageNumberSegments);
559
1007
  for (let i = 0; i < doc.pages.length; i++) {
@@ -568,6 +1016,51 @@ export function buildDocument(content, config, cache, options) {
568
1016
  buildHeadersAndFooters(doc);
569
1017
  doc.converged = true;
570
1018
  doc.iterationCount = 1;
571
- return doc;
1019
+ return { doc, forcedBreakPages };
1020
+ }
1021
+ export function buildDocument(content, config, cache, options) {
1022
+ let best = buildDocumentPass(content, config, cache, options);
1023
+ // --- Column balancing (vertical justification) ------------------------
1024
+ // Iteratively re-place the document with extra grid lines above headings
1025
+ // until every balanceable column ends flush with the page bottom (or no
1026
+ // further adjustment is possible). Each retry recomputes the remaining
1027
+ // gaps on the freshly placed document, so split/keep-with-next decisions
1028
+ // that shift under the new spacing are accounted for. The best layout
1029
+ // (fewest leftover gap lines) always wins — a retry that regresses is
1030
+ // discarded.
1031
+ const balancing = best.doc.config.headings.balancing;
1032
+ if (!balancing.enabled)
1033
+ return best.doc;
1034
+ let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
1035
+ let appliedLines = new Map();
1036
+ let passCount = 1;
1037
+ let converged = bestScore === 0;
1038
+ while (!converged && passCount < MAX_BALANCING_PASSES) {
1039
+ const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, appliedLines, balancing.maxLinesPerHeading);
1040
+ if (!proposal.changed) {
1041
+ // No heading can absorb the remaining gaps — stable.
1042
+ converged = true;
1043
+ break;
1044
+ }
1045
+ const extraPx = new Map();
1046
+ for (const [idx, n] of proposal.lines)
1047
+ extraPx.set(idx, n * best.doc.baselineGrid);
1048
+ const next = buildDocumentPass(content, config, cache, options, extraPx);
1049
+ passCount++;
1050
+ const score = totalGapLines(next.doc, next.forcedBreakPages);
1051
+ if (score < bestScore) {
1052
+ best = next;
1053
+ bestScore = score;
1054
+ appliedLines = proposal.lines;
1055
+ converged = score === 0;
1056
+ }
1057
+ else {
1058
+ // Plateau or regression — keep the best layout found so far.
1059
+ break;
1060
+ }
1061
+ }
1062
+ best.doc.iterationCount = passCount;
1063
+ best.doc.converged = converged || bestScore === 0;
1064
+ return best.doc;
572
1065
  }
573
1066
  //# sourceMappingURL=build.js.map