postext 0.3.21 → 0.3.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/__tests__/columnBalancing.test.js +3 -2
  2. package/dist/__tests__/columnBalancing.test.js.map +1 -1
  3. package/dist/__tests__/defaults/htmlViewerOverrides.test.d.ts +2 -0
  4. package/dist/__tests__/defaults/htmlViewerOverrides.test.d.ts.map +1 -0
  5. package/dist/__tests__/defaults/htmlViewerOverrides.test.js +95 -0
  6. package/dist/__tests__/defaults/htmlViewerOverrides.test.js.map +1 -0
  7. package/dist/__tests__/design/runningHeads.test.d.ts +2 -0
  8. package/dist/__tests__/design/runningHeads.test.d.ts.map +1 -0
  9. package/dist/__tests__/design/runningHeads.test.js +94 -0
  10. package/dist/__tests__/design/runningHeads.test.js.map +1 -0
  11. package/dist/__tests__/exports.test.js +5 -0
  12. package/dist/__tests__/exports.test.js.map +1 -1
  13. package/dist/__tests__/html-backend.test.d.ts +2 -0
  14. package/dist/__tests__/html-backend.test.d.ts.map +1 -0
  15. package/dist/__tests__/html-backend.test.js +76 -0
  16. package/dist/__tests__/html-backend.test.js.map +1 -0
  17. package/dist/__tests__/parse/inlineSnippet.test.d.ts +2 -0
  18. package/dist/__tests__/parse/inlineSnippet.test.d.ts.map +1 -0
  19. package/dist/__tests__/parse/inlineSnippet.test.js +61 -0
  20. package/dist/__tests__/parse/inlineSnippet.test.js.map +1 -0
  21. package/dist/__tests__/pipeline/calloutFixed.test.d.ts +2 -0
  22. package/dist/__tests__/pipeline/calloutFixed.test.d.ts.map +1 -0
  23. package/dist/__tests__/pipeline/calloutFixed.test.js +99 -0
  24. package/dist/__tests__/pipeline/calloutFixed.test.js.map +1 -0
  25. package/dist/__tests__/pipeline/captionLayout.test.js +16 -0
  26. package/dist/__tests__/pipeline/captionLayout.test.js.map +1 -1
  27. package/dist/__tests__/pipeline/floatFirstSlot.test.d.ts +2 -0
  28. package/dist/__tests__/pipeline/floatFirstSlot.test.d.ts.map +1 -0
  29. package/dist/__tests__/pipeline/floatFirstSlot.test.js +180 -0
  30. package/dist/__tests__/pipeline/floatFirstSlot.test.js.map +1 -0
  31. package/dist/__tests__/pipeline/floatPlacement.test.js +1 -1
  32. package/dist/__tests__/pipeline/floatPlacement.test.js.map +1 -1
  33. package/dist/__tests__/pipeline/floatSlots.test.d.ts +2 -0
  34. package/dist/__tests__/pipeline/floatSlots.test.d.ts.map +1 -0
  35. package/dist/__tests__/pipeline/floatSlots.test.js +99 -0
  36. package/dist/__tests__/pipeline/floatSlots.test.js.map +1 -0
  37. package/dist/__tests__/pipeline/spanBands.test.js +5 -3
  38. package/dist/__tests__/pipeline/spanBands.test.js.map +1 -1
  39. package/dist/__tests__/pipeline/spanBlocks.test.js +23 -14
  40. package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
  41. package/dist/__tests__/pipeline/trailingBand.test.d.ts +2 -0
  42. package/dist/__tests__/pipeline/trailingBand.test.d.ts.map +1 -0
  43. package/dist/__tests__/pipeline/trailingBand.test.js +93 -0
  44. package/dist/__tests__/pipeline/trailingBand.test.js.map +1 -0
  45. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  46. package/dist/canvas-backend/headerFooter.js +13 -1
  47. package/dist/canvas-backend/headerFooter.js.map +1 -1
  48. package/dist/canvas-backend/index.d.ts.map +1 -1
  49. package/dist/canvas-backend/index.js +5 -5
  50. package/dist/canvas-backend/index.js.map +1 -1
  51. package/dist/defaults/calloutStyles.d.ts +9 -1
  52. package/dist/defaults/calloutStyles.d.ts.map +1 -1
  53. package/dist/defaults/calloutStyles.js +35 -0
  54. package/dist/defaults/calloutStyles.js.map +1 -1
  55. package/dist/defaults/headings.d.ts +1 -0
  56. package/dist/defaults/headings.d.ts.map +1 -1
  57. package/dist/defaults/headings.js +7 -0
  58. package/dist/defaults/headings.js.map +1 -1
  59. package/dist/defaults/htmlViewer.d.ts +9 -1
  60. package/dist/defaults/htmlViewer.d.ts.map +1 -1
  61. package/dist/defaults/htmlViewer.js +72 -0
  62. package/dist/defaults/htmlViewer.js.map +1 -1
  63. package/dist/defaults/index.d.ts +1 -1
  64. package/dist/defaults/index.d.ts.map +1 -1
  65. package/dist/defaults/index.js +1 -1
  66. package/dist/defaults/index.js.map +1 -1
  67. package/dist/design/layout.d.ts +18 -1
  68. package/dist/design/layout.d.ts.map +1 -1
  69. package/dist/design/layout.js +38 -28
  70. package/dist/design/layout.js.map +1 -1
  71. package/dist/design/placeholders.d.ts.map +1 -1
  72. package/dist/design/placeholders.js +1 -0
  73. package/dist/design/placeholders.js.map +1 -1
  74. package/dist/html-backend.d.ts +2 -0
  75. package/dist/html-backend.d.ts.map +1 -1
  76. package/dist/html-backend.js +12 -7
  77. package/dist/html-backend.js.map +1 -1
  78. package/dist/index.d.ts +4 -2
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +2 -1
  81. package/dist/index.js.map +1 -1
  82. package/dist/parse/index.d.ts +3 -0
  83. package/dist/parse/index.d.ts.map +1 -1
  84. package/dist/parse/index.js +2 -0
  85. package/dist/parse/index.js.map +1 -1
  86. package/dist/parse/inlineSnippet.d.ts +33 -0
  87. package/dist/parse/inlineSnippet.d.ts.map +1 -0
  88. package/dist/parse/inlineSnippet.js +30 -0
  89. package/dist/parse/inlineSnippet.js.map +1 -0
  90. package/dist/parse/sourceMapping.d.ts +8 -0
  91. package/dist/parse/sourceMapping.d.ts.map +1 -1
  92. package/dist/parse/sourceMapping.js +3 -2
  93. package/dist/parse/sourceMapping.js.map +1 -1
  94. package/dist/pipeline/bandCaps.d.ts +41 -9
  95. package/dist/pipeline/bandCaps.d.ts.map +1 -1
  96. package/dist/pipeline/bandCaps.js +99 -14
  97. package/dist/pipeline/bandCaps.js.map +1 -1
  98. package/dist/pipeline/build.d.ts.map +1 -1
  99. package/dist/pipeline/build.js +568 -215
  100. package/dist/pipeline/build.js.map +1 -1
  101. package/dist/pipeline/calloutLayout.js +1 -1
  102. package/dist/pipeline/calloutLayout.js.map +1 -1
  103. package/dist/pipeline/floatPlacement.d.ts +10 -6
  104. package/dist/pipeline/floatPlacement.d.ts.map +1 -1
  105. package/dist/pipeline/floatPlacement.js +7 -5
  106. package/dist/pipeline/floatPlacement.js.map +1 -1
  107. package/dist/pipeline/floatSlots.d.ts +61 -0
  108. package/dist/pipeline/floatSlots.d.ts.map +1 -0
  109. package/dist/pipeline/floatSlots.js +100 -0
  110. package/dist/pipeline/floatSlots.js.map +1 -0
  111. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  112. package/dist/pipeline/headerFooter.js +3 -0
  113. package/dist/pipeline/headerFooter.js.map +1 -1
  114. package/dist/pipeline/placeholders.d.ts.map +1 -1
  115. package/dist/pipeline/placeholders.js +20 -4
  116. package/dist/pipeline/placeholders.js.map +1 -1
  117. package/dist/pipeline/placement.d.ts +4 -0
  118. package/dist/pipeline/placement.d.ts.map +1 -1
  119. package/dist/pipeline/placement.js +26 -11
  120. package/dist/pipeline/placement.js.map +1 -1
  121. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  122. package/dist/pipeline/resourceLayout.js +9 -10
  123. package/dist/pipeline/resourceLayout.js.map +1 -1
  124. package/dist/types.d.ts +64 -10
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/vdt.d.ts +9 -5
  127. package/dist/vdt.d.ts.map +1 -1
  128. package/dist/vdt.js +3 -0
  129. package/dist/vdt.js.map +1 -1
  130. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { applyTitleBreaks } from '../parse/inlineFormatting';
2
2
  import { dimensionToPx } from '../units';
3
3
  import { createVDTDocument, createVDTBlock, createBoundingBox, } from '../vdt';
4
+ import { anchorBox } from '../design/layout';
4
5
  import { parseMarkdownMemo } from '../parse';
5
6
  import { buildPageLabels, computeHeadingNumbers, } from '../numbering';
6
7
  import { extractFrontmatter } from '../frontmatter';
@@ -8,7 +9,7 @@ import { initHyphenator } from '../measure';
8
9
  import { resolveAllConfig, computeBaselineGrid, buildHeadingLevelMap } from './config';
9
10
  import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
10
11
  import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
11
- import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
12
+ import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, pageIsOccupied, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
12
13
  import { chooseParagraphSplit } from './orphanWidow';
13
14
  import { applyStyleAttrs, computePageMetrics, nextNonMarkerBlock, prevNonMarkerBlock, rollbackTrailingBlocks, } from './buildHelpers';
14
15
  import { measureContentBlock } from './measureContentBlock';
@@ -17,11 +18,12 @@ import { planParts, derivePartMeasureContext } from './parts';
17
18
  import { layoutCallout, offsetCalloutToAbsolute, pickCalloutStyle, planCallouts, resolveCalloutAttrs, } from './calloutLayout';
18
19
  import { layoutResourceBlock } from './resourceLayout';
19
20
  import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
21
+ import { enumerateCurrentPageSlots, measureFloatBand, columnHasFloatBand, fitsStrict, trueBottom, } from './floatSlots';
20
22
  import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
21
23
  import { defaultResourceTypes } from '../defaults/resourceTypes';
22
24
  import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
23
25
  import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES, } from './columnBalancing';
24
- import { applyBandCap, uncapBand, columnBottom, bandCapLines, resolveBandCaps, } from './bandCaps';
26
+ import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, } from './bandCaps';
25
27
  export class BuildCancelledError extends Error {
26
28
  constructor() {
27
29
  super('Build cancelled');
@@ -157,9 +159,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
157
159
  // --- Float planning (issue #49 — resources float to page bands) ----------
158
160
  // A resource is incorporated by its first reference (an inline `:ref` or a
159
161
  // `::resource` directive, whichever comes first in reading order). Floated
160
- // resources detach from the running text and reserve a band at the top or
161
- // bottom of the next page opened after that reference; the text flows past
162
- // the reference uninterrupted. `position: 'here'` resources keep inline
162
+ // resources detach from the running text and take the first free slot
163
+ // after that reference (`floatSlots.ts`); the text flows past the
164
+ // reference uninterrupted. `position: 'here'` resources keep inline
163
165
  // `::resource` placement and are not floated.
164
166
  const floatPlan = computeFloatPlan(contentBlocks, resources, resourceTypes);
165
167
  const floatedIds = floatedResourceIds(floatPlan);
@@ -174,6 +176,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
174
176
  // Floats whose first reference has been passed but which are not yet placed
175
177
  // into a page band, in reading order.
176
178
  const pendingFloats = [];
179
+ /** Resource ids already enqueued in this pass — a keep-with-next rewind
180
+ * replays the loop top for the rolled-back blocks and must not enqueue
181
+ * (and later place) the same float twice. */
182
+ const enqueuedFloatIds = new Set();
183
+ const enqueueFloatsFor = (blockIdx) => {
184
+ const fl = floatsByFirstBlock.get(blockIdx);
185
+ if (!fl)
186
+ return;
187
+ for (const f of fl) {
188
+ if (enqueuedFloatIds.has(f.resourceId))
189
+ continue;
190
+ enqueuedFloatIds.add(f.resourceId);
191
+ pendingFloats.push(f);
192
+ }
193
+ };
177
194
  const floatGapPx = bodyStyle.lineHeightPx;
178
195
  const minTextPx = bodyStyle.lineHeightPx * 3;
179
196
  /** Offset a resolved resource block's caption/table geometry from
@@ -205,13 +222,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
205
222
  }
206
223
  }
207
224
  };
208
- /** Measure + build a float block at horizontal offset `x` (y = 0), or null
209
- * when the resource id is unknown. Caller offsets it to its final `y`. */
210
- const buildFloatBlock = (resourceId, x, width) => {
225
+ const layoutFloat = (resourceId, width) => {
211
226
  const resource = resourceById.get(resourceId);
212
227
  if (!resource)
213
228
  return null;
214
- const { block: rb, totalHeight } = layoutResourceBlock({
229
+ return layoutResourceBlock({
215
230
  resource,
216
231
  resourceType: resourceTypeById.get(resource.typeId),
217
232
  number: resourceNumberById.get(resourceId) ?? '',
@@ -221,6 +236,46 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
221
236
  resourceTypes,
222
237
  resources,
223
238
  });
239
+ };
240
+ /** Height (and caption baseline) of a float at a given width, memoised —
241
+ * fit checks run for every pending float on every loop iteration. */
242
+ const floatMeasureMemo = new Map();
243
+ const measureFloat = (resourceId, width) => {
244
+ const key = `${resourceId}:${width.toFixed(2)}`;
245
+ const memo = floatMeasureMemo.get(key);
246
+ if (memo !== undefined)
247
+ return memo;
248
+ const laid = layoutFloat(resourceId, width);
249
+ let m = null;
250
+ if (laid) {
251
+ // A bottom band aligns the float's LAST text line to the grid: the
252
+ // caption's (or the note's) last line when it sits under the body. A
253
+ // caption set above the body (caption bars) leaves the body's bottom
254
+ // edge as the visual bottom instead.
255
+ const rb = laid.block;
256
+ const bodyBottom = rb.bodyRect.y + rb.bodyRect.height;
257
+ let lastBaseline;
258
+ for (const ln of [...rb.captionLines, ...rb.noteLines]) {
259
+ if (ln.bbox.y < bodyBottom - 0.5)
260
+ continue;
261
+ if (lastBaseline === undefined || ln.baseline > lastBaseline)
262
+ lastBaseline = ln.baseline;
263
+ }
264
+ m = {
265
+ height: laid.totalHeight,
266
+ ...(lastBaseline !== undefined ? { lastCaptionBaseline: lastBaseline } : {}),
267
+ };
268
+ }
269
+ floatMeasureMemo.set(key, m);
270
+ return m;
271
+ };
272
+ /** Measure + build a float block at horizontal offset `x` (y = 0), or null
273
+ * when the resource id is unknown. Caller offsets it to its final `y`. */
274
+ const buildFloatBlock = (resourceId, x, width) => {
275
+ const laid = layoutFloat(resourceId, width);
276
+ if (!laid)
277
+ return null;
278
+ const { block: rb, totalHeight } = laid;
224
279
  const blk = createVDTBlock(`float-${resourceId}`, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
225
280
  blk.resourceBlock = rb;
226
281
  blk.dirty = false;
@@ -230,160 +285,185 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
230
285
  offsetResourceBlockToAbsolute(rb, x, 0);
231
286
  return { block: blk, height: totalHeight };
232
287
  };
233
- /** Reserve top/bottom bands on a freshly opened page and position as many
234
- * pending floats as fit, shrinking the affected columns so body text flows
235
- * around them. Preserves reading order: stops at the first float that does
236
- * not fit (so figures never reorder relative to their references), except
237
- * on a band that is still all-text, where a dominating/oversized float is
238
- * force-placed so the queue always makes progress. */
239
- const flushFloatsIntoPage = (page) => {
240
- if (pendingFloats.length === 0)
241
- return;
242
- const topUsed = page.columns.map(() => 0);
243
- const botUsed = page.columns.map(() => 0);
244
- const floats = page.floats ?? [];
245
- /** Try to place one float on this page. Returns whether it was placed,
246
- * must be deferred (does not fit), or skipped (unknown id). Only mutates
247
- * page geometry when it actually places. */
248
- const attemptFloat = (f) => {
249
- const pageSpan = f.span === 'page' && page.columns.length > 1;
250
- let targetCols;
251
- if (pageSpan) {
252
- targetCols = page.columns.map((_, i) => i);
253
- }
254
- else {
255
- // Single-column float: pick the column with the most room left.
256
- let best = 0;
257
- for (let i = 1; i < page.columns.length; i++) {
258
- if (topUsed[i] + botUsed[i] < topUsed[best] + botUsed[best])
259
- best = i;
260
- }
261
- targetCols = [best];
262
- }
263
- const firstCol = page.columns[targetCols[0]];
264
- const width = pageSpan ? page.contentArea.width : firstCol.bbox.width;
265
- const xLeft = pageSpan ? page.contentArea.x : firstCol.bbox.x;
266
- const built = buildFloatBlock(f.resourceId, xLeft, width);
267
- if (!built)
268
- return 'skip';
288
+ /** Float bands reserved per column in this pass (the fresh-page flush
289
+ * sends single-column floats to the least reserved column). */
290
+ const floatReserved = new Map();
291
+ const reservedOf = (col) => floatReserved.get(col) ?? { top: 0, bottom: 0 };
292
+ /** Kind of cap the column is under (`undefined` when uncapped). A cap that
293
+ * cannot be attributed to the active band is treated as a span cap — the
294
+ * conservative reading, which keeps the column's bottom off the slot list. */
295
+ const capKindOf = (col) => {
296
+ if (!uncappedBottoms.has(col))
297
+ return undefined;
298
+ if (activeCap && bandCaps && activeCap.pageIndex === cursor.pageIndex && activeCap.band === (col.band ?? 0)) {
299
+ return bandCaps.get(activeCap.spanIndex)?.kind ?? 'span';
300
+ }
301
+ return 'span';
302
+ };
303
+ /**
304
+ * Reserve a float band on `targetCols` (one column, or every text column
305
+ * of the band for a page-span float) and position the float there.
306
+ * `'fresh'` is the freshly-opened-page rule: keep three lines of text room
307
+ * once a band already holds a float, but force-place a dominating float
308
+ * on an all-text band so the queue always progresses. `'strict'` is the
309
+ * current-page rule: the band must fit in each column's remaining height
310
+ * (below its content), keeping the text room only next to another band.
311
+ * Only mutates page geometry when it places.
312
+ */
313
+ const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode) => {
314
+ const first = targetCols[0];
315
+ const width = pageSpan ? page.contentArea.width : first.bbox.width;
316
+ const xLeft = pageSpan ? page.contentArea.x : first.bbox.x;
317
+ const measure = measureFloat(f.resourceId, width);
318
+ if (!measure)
319
+ return 'skip';
320
+ const { need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms));
321
+ if (mode === 'fresh') {
269
322
  let minAvail = Infinity;
270
323
  let anyReserved = false;
271
324
  for (const c of targetCols) {
272
- minAvail = Math.min(minAvail, page.columns[c].availableHeight);
273
- if (topUsed[c] > 0 || botUsed[c] > 0)
325
+ minAvail = Math.min(minAvail, c.availableHeight);
326
+ const r = reservedOf(c);
327
+ if (r.top > 0 || r.bottom > 0)
274
328
  anyReserved = true;
275
329
  }
276
- // Both band kinds are corrected against the baseline grid so the text
277
- // around them — and the facing page — stays on the global rhythm:
278
- // - top: the band pushes the column start (`col.bbox.y`) downward, and
279
- // all grid snapping inside the column is anchored at that start. The
280
- // band height is rounded up to a grid multiple (growing the gap
281
- // below the float) or every line in the displaced column would land
282
- // off-grid, visibly misaligned with neighbouring columns.
283
- // - bottom: the float is anchored so its visual bottom sits on the
284
- // grid — the caption's last line shares its baseline with the last
285
- // text line of the other columns (captionless content aligns its
286
- // bottom edge to the last grid slot). Pages then end at the same
287
- // height across columns and across facing pages.
288
- let need;
289
- let floatY = 0;
290
- if (f.position === 'top') {
291
- const rawNeed = built.height + floatGapPx;
292
- need = Math.ceil((rawNeed - 0.01) / baselineGrid) * baselineGrid;
293
- }
294
- else {
295
- const bottomLimit = Math.min(...targetCols.map((c) => {
296
- const cb = page.columns[c].bbox;
297
- return cb.y + cb.height;
298
- }));
299
- const gridAlignedBottom = page.contentArea.y
300
- + Math.floor((bottomLimit - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
301
- const capLines = built.block.resourceBlock.captionLines;
302
- if (capLines.length > 0) {
303
- // Body baselines sit at 0.2 × grid above each slot bottom; anchor
304
- // the caption's last baseline there.
305
- const lastBaseline = capLines[capLines.length - 1].baseline; // block-relative
306
- floatY = gridAlignedBottom - 0.2 * baselineGrid - lastBaseline;
307
- }
308
- else {
309
- floatY = gridAlignedBottom - built.height;
310
- }
311
- need = 0;
312
- for (const c of targetCols) {
313
- const col = page.columns[c];
314
- need = Math.max(need, col.bbox.height - (floatY - floatGapPx - col.bbox.y));
315
- }
316
- }
317
- // Keep some text room, unless this band is still all-text (then a
318
- // dominating / oversized float is force-placed so the queue progresses).
319
330
  if (need > minAvail - minTextPx && anyReserved)
320
331
  return 'defer';
321
- let y = 0;
332
+ }
333
+ else {
322
334
  for (const c of targetCols) {
323
- const col = page.columns[c];
324
- if (f.position === 'top') {
325
- y = col.bbox.y; // float sits at the current top edge
326
- col.bbox.y += need; // push column content below the band
327
- col.bbox.height = Math.max(0, col.bbox.height - need);
328
- col.availableHeight = Math.max(0, col.availableHeight - need);
329
- topUsed[c] += need;
335
+ if (position === 'bottom' && uncappedBottoms.has(c)) {
336
+ // Trailing cap: the band must lie entirely below the level cut.
337
+ if (y - floatGapPx < c.bbox.y + c.bbox.height - 0.01)
338
+ return 'defer';
339
+ continue;
340
+ }
341
+ if (!fitsStrict(need, c, columnHasFloatBand(page, c), minTextPx))
342
+ return 'defer';
343
+ }
344
+ }
345
+ const built = buildFloatBlock(f.resourceId, xLeft, width);
346
+ if (!built)
347
+ return 'skip';
348
+ for (const col of targetCols) {
349
+ const r = { ...reservedOf(col) };
350
+ if (position === 'top') {
351
+ col.bbox.y += need;
352
+ col.bbox.height = Math.max(0, col.bbox.height - need);
353
+ col.availableHeight = Math.max(0, col.availableHeight - need);
354
+ r.top += need;
355
+ }
356
+ else {
357
+ const capped = uncappedBottoms.get(col);
358
+ if (capped !== undefined) {
359
+ uncappedBottoms.set(col, capped - need);
330
360
  }
331
361
  else {
332
- y = floatY;
333
- const newHeight = Math.max(0, floatY - floatGapPx - col.bbox.y);
362
+ const newHeight = Math.max(0, y - floatGapPx - col.bbox.y);
334
363
  const reserved = col.bbox.height - newHeight;
335
364
  col.bbox.height = newHeight;
336
365
  col.availableHeight = Math.max(0, col.availableHeight - reserved);
337
- botUsed[c] += reserved;
338
366
  }
367
+ r.bottom += need;
339
368
  }
340
- offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, y);
341
- built.block.bbox = createBoundingBox(xLeft, y, width, built.height);
342
- built.block.pageIndex = page.index;
343
- built.block.columnIndex = targetCols[0];
344
- floats.push(built.block);
345
- return 'placed';
369
+ floatReserved.set(col, r);
370
+ }
371
+ offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, y);
372
+ built.block.bbox = createBoundingBox(xLeft, y, width, built.height);
373
+ built.block.pageIndex = page.index;
374
+ built.block.columnIndex = first.index;
375
+ (page.floats ??= []).push(built.block);
376
+ return 'placed';
377
+ };
378
+ const positionsFor = (f) => f.position === 'auto' ? ['top', 'bottom'] : [f.position];
379
+ /** Reserve top/bottom bands on a freshly opened page and position as many
380
+ * pending floats as fit, shrinking the affected columns so body text flows
381
+ * around them. Full-width (page-span) floats reserve the outermost bands
382
+ * first, so a later single-column float nests inside the remaining column
383
+ * space rather than overlapping a full-width band. A float that does not
384
+ * fit never holds up the ones behind it: each takes the first slot it
385
+ * fits (numbering follows first-reference order regardless). */
386
+ const flushFloatsIntoPage = (page) => {
387
+ if (pendingFloats.length === 0)
388
+ return;
389
+ const textCols = page.columns.filter((c) => c.kind !== 'span');
390
+ if (textCols.length === 0)
391
+ return;
392
+ const leastReserved = () => {
393
+ let best = textCols[0];
394
+ for (const c of textCols) {
395
+ const rb = reservedOf(best);
396
+ const rc = reservedOf(c);
397
+ if (rc.top + rc.bottom < rb.top + rb.bottom)
398
+ best = c;
399
+ }
400
+ return best;
346
401
  };
347
- // Full-width (page-span) floats reserve the outermost bands first, so a
348
- // later single-column float nests inside the remaining column space rather
349
- // than overlapping a full-width band. Within each pass, stop at the first
350
- // float that does not fit to preserve reading order.
351
402
  for (const pageSpanPass of [true, false]) {
352
403
  let i = 0;
353
404
  while (i < pendingFloats.length) {
354
405
  const f = pendingFloats[i];
355
- const isPageSpan = f.span === 'page' && page.columns.length > 1;
406
+ const isPageSpan = f.span === 'page' && textCols.length > 1;
356
407
  if (isPageSpan !== pageSpanPass) {
357
408
  i++;
358
409
  continue;
359
410
  }
360
- const r = attemptFloat(f);
361
- if (r === 'placed' || r === 'skip')
362
- pendingFloats.splice(i, 1);
411
+ let r = 'defer';
412
+ for (const pos of positionsFor(f)) {
413
+ const cols = isPageSpan ? textCols : [leastReserved()];
414
+ r = placeFloatInColumns(page, f, cols, pos, isPageSpan, 'fresh');
415
+ if (r !== 'defer')
416
+ break;
417
+ }
418
+ if (r === 'defer')
419
+ i++;
363
420
  else
364
- break; // defer: leave this and the rest of the pass for a later page
421
+ pendingFloats.splice(i, 1);
365
422
  }
366
423
  }
367
- if (floats.length > 0)
368
- page.floats = floats;
369
424
  };
370
- /** Drain floats still pending after body placement (referenced on the last
371
- * page, or never followed by a content-overflow page break) onto freshly
372
- * appended pages. Each new page force-places at least one float. */
373
- const finalizeFloats = () => {
425
+ /** Offer every pending float the free slots of the current page after the
426
+ * cursor (bottom of the referencing column, top / bottom of the next
427
+ * empty columns; the band bottom for page-span floats). Runs before each
428
+ * block is placed, so a float lands in the first gap after its reference. */
429
+ const tryPlacePendingFloatsOnCurrentPage = () => {
430
+ if (pendingFloats.length === 0)
431
+ return;
432
+ const page = doc.pages[cursor.pageIndex];
433
+ for (let i = 0; i < pendingFloats.length;) {
434
+ const f = pendingFloats[i];
435
+ let r = 'defer';
436
+ for (const slot of enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf)) {
437
+ r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict');
438
+ if (r !== 'defer')
439
+ break;
440
+ }
441
+ if (r === 'defer')
442
+ i++;
443
+ else
444
+ pendingFloats.splice(i, 1);
445
+ }
446
+ };
447
+ /** Reserve floats on each freshly opened content page. Passed only to the
448
+ * content-flow column advances — parity / force-blank pages never get it. */
449
+ const onNewPage = (page) => flushFloatsIntoPage(page);
450
+ /** Chapter barrier: place every pending float before the boundary — in
451
+ * the current page's free slots, then on fresh pages opened ahead of it
452
+ * (each force-places at least one float). The cursor is left on the last
453
+ * float page so the boundary's own page break opens AFTER them. */
454
+ const drainPendingFloats = () => {
455
+ tryPlacePendingFloatsOnCurrentPage();
374
456
  let guard = 0;
375
457
  while (pendingFloats.length > 0 && guard++ < 1000) {
376
458
  const before = pendingFloats.length;
377
- const page = createPageWithColumns(doc.pages.length, resolved, contentArea, pageWidthPx, pageHeightPx);
378
- doc.pages.push(page);
379
- flushFloatsIntoPage(page);
459
+ const startPageIndex = cursor.pageIndex;
460
+ do {
461
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
462
+ } while (cursor.pageIndex === startPageIndex);
380
463
  if (pendingFloats.length === before)
381
464
  break; // safety: no progress
382
465
  }
383
466
  };
384
- /** Reserve floats on each freshly opened content page. Passed only to the
385
- * content-flow column advances — parity / force-blank pages never get it. */
386
- const onNewPage = (page) => flushFloatsIntoPage(page);
387
467
  // Everything per-block measurement needs that is constant for this pass.
388
468
  const measureCtx = {
389
469
  resolved,
@@ -459,6 +539,64 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
459
539
  break;
460
540
  }
461
541
  };
542
+ /**
543
+ * Trailing band balance: when the flow reaches a boundary (chapter opener,
544
+ * `:::part`, a chapter-closing fixed box, end of document) with the
545
+ * current band's text columns uneven, propose a cap that cuts them level
546
+ * — `ceil(Σ used / N / grid)` lines — so the next pass re-places the band
547
+ * and its columns end at the same height, the way a compositor sets a
548
+ * short closing page. Keyed by the boundary block's content index
549
+ * (`contentBlocks.length` at EOF). In the capped pass the boundary reports
550
+ * the cap delivered when it is reached inside the capped band (nothing
551
+ * spilled past the cut); the columns are NOT uncapped afterwards, so
552
+ * column balancing does not stretch them back to the page bottom.
553
+ */
554
+ const proposeTrailingCap = (boundaryIndex) => {
555
+ const balancingCfg = resolved.headings.balancing;
556
+ if (!balancingCfg.enabled || !balancingCfg.trailing)
557
+ return;
558
+ const page = doc.pages[cursor.pageIndex];
559
+ if (page.columns[cursor.columnIndex]?.kind === 'span' || page.partInfo)
560
+ return;
561
+ const band = currentBand(page, cursor);
562
+ const cols = bandColumns(page, band).filter((c) => c.bbox.height > 0.5);
563
+ if (cols.length < 2 || cols.some((c) => c.forcedBreak))
564
+ return;
565
+ if (bandCaps?.has(boundaryIndex)) {
566
+ if (activeCap && activeCap.spanIndex === boundaryIndex
567
+ && activeCap.pageIndex === page.index && activeCap.band === band) {
568
+ spanPlacedInBand.add(boundaryIndex);
569
+ }
570
+ return;
571
+ }
572
+ if (activeCap && activeCap.pageIndex === page.index && activeCap.band === band)
573
+ return;
574
+ if (!cols.some((c) => c.blocks.length > 0))
575
+ return;
576
+ if (!bandStart || !registeredBand || registeredBand.pageIndex !== page.index || registeredBand.band !== band)
577
+ return;
578
+ const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
579
+ if (Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5)
580
+ return;
581
+ bandCapProposals.set(boundaryIndex, {
582
+ kind: 'trailing',
583
+ startContentIndex: bandStart.contentIndex,
584
+ startPart: bandStart.part,
585
+ lines: bandCapLines(cols, baselineGrid),
586
+ retries: 0,
587
+ });
588
+ };
589
+ /** Close the flow at a chapter-level boundary (block `boundaryIndex`):
590
+ * floats take the page's free slots, the closing band is levelled, the
591
+ * page is marked as an explicit break, and every float still pending is
592
+ * drained onto pages opened BEFORE the boundary. The caller then opens
593
+ * the boundary's own page. */
594
+ const closeFlowSegment = (boundaryIndex) => {
595
+ tryPlacePendingFloatsOnCurrentPage();
596
+ proposeTrailingCap(boundaryIndex);
597
+ markForcedBreak();
598
+ drainPendingFloats();
599
+ };
462
600
  /** Parity of the page break a closed `:::part` still owes (applied before
463
601
  * the next placed block). */
464
602
  let pendingPartBreak = null;
@@ -510,13 +648,18 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
510
648
  * convert the laid-out box to absolute coordinates at the frame's placed
511
649
  * origin, and push frame + children — in that order — to `doc.blocks`
512
650
  * and to the column the frame landed in. */
513
- const commitCallout = (result, startIdx, plan, col) => {
514
- const frame = result.frame;
651
+ /** Stamp a callout frame's content index and source range (the whole
652
+ * fence, opening to closing marker). */
653
+ const stampCalloutSource = (frame, startIdx, plan) => {
515
654
  const startBlock = contentBlocks[startIdx];
516
655
  const endBlock = contentBlocks[plan.endIdx];
517
656
  frame.contentIndex = startIdx;
518
657
  frame.sourceStart = startBlock.sourceStart + bodyOffset;
519
658
  frame.sourceEnd = endBlock.sourceEnd + bodyOffset;
659
+ };
660
+ const commitCallout = (result, startIdx, plan, col) => {
661
+ const frame = result.frame;
662
+ stampCalloutSource(frame, startIdx, plan);
520
663
  offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
521
664
  doc.blocks.push(frame);
522
665
  for (const child of result.children) {
@@ -606,12 +749,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
606
749
  // room for the box plus the widow minimum of body lines below it.
607
750
  const cols = bandColumns(page, currentBand(page, cursor));
608
751
  const lines = bandCapLines(cols, baselineGrid);
609
- const capBottom = Math.max(...cols.map((c) => c.bbox.y)) + lines * baselineGrid;
752
+ const capBottom = bandTop(cols) + lines * baselineGrid;
610
753
  const spacing = Math.max(pendingSpacing, result.marginTopPx);
611
754
  const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
612
755
  const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
613
756
  if (capBottom + need + minRoomPx <= bandBottom + 0.01) {
614
757
  bandCapProposals.set(startIdx, {
758
+ kind: 'span',
615
759
  startContentIndex: bandStart.contentIndex,
616
760
  startPart: bandStart.part,
617
761
  lines,
@@ -625,7 +769,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
625
769
  // left no room — but a truly empty page is kept: the box then simply
626
770
  // does not fit a page and is force-placed (overflowing, like inline).
627
771
  const curPage = doc.pages[cursor.pageIndex];
628
- if (pageHasContent(curPage) || (curPage.floats?.length ?? 0) > 0) {
772
+ if (pageIsOccupied(curPage)) {
629
773
  pendingSpacing = 0;
630
774
  const startPageIndex = cursor.pageIndex;
631
775
  do {
@@ -646,16 +790,174 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
646
790
  commitCallout(result, startIdx, plan, spanCol);
647
791
  // Floats first-referenced inside the box enqueue once it is committed,
648
792
  // 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
- }
793
+ for (let i = startIdx + 1; i <= plan.endIdx; i++)
794
+ enqueueFloatsFor(i);
654
795
  // The new band starts on the grid right below the span column; nothing
655
796
  // to snap — `need` already bakes in `marginBottom`.
656
797
  pendingSpacing = 0;
657
798
  return true;
658
799
  };
800
+ /** Whether the flow ends at a chapter-level boundary right after block
801
+ * `from` (skipping container markers): a chapter opener, a `:::part`, a
802
+ * float-barrier box, or the end of the document. */
803
+ const nextIsBarrier = (from) => {
804
+ for (let i = from; i < contentBlocks.length; i++) {
805
+ const b = contentBlocks[i];
806
+ if (b.type === 'containerEnd')
807
+ continue;
808
+ if (b.type === 'containerStart') {
809
+ if (b.containerName === 'part')
810
+ return true;
811
+ if (b.containerName === 'callout') {
812
+ const plan = calloutPlan.get(i);
813
+ const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
814
+ return style?.floatBarrier === true;
815
+ }
816
+ continue;
817
+ }
818
+ if (b.type === 'heading' && b.level) {
819
+ const level = headingLevelByNumber.get(b.level);
820
+ return level?.breakBefore?.enabled === true || level?.span === 'page';
821
+ }
822
+ return false;
823
+ }
824
+ return true;
825
+ };
826
+ const rectsOverlap = (a, b) => a.x < b.x + b.width - 0.5 && a.x + a.width > b.x + 0.5
827
+ && a.y < b.y + b.height - 0.5 && a.y + a.height > b.y + 0.5;
828
+ /**
829
+ * Place a `placement: 'fixed'` `:::callout` at page coordinates: the box
830
+ * is anchored (nine-point grid + offset) to the page content area, the
831
+ * trim box or the bleed box of the page where it occurs in the flow, out
832
+ * of the column flow. Text columns whose x-range meets the box give up the
833
+ * zone it covers — cut from the bottom (the zone's centre in the lower
834
+ * half) or from the top (empty columns only) — like a float band. When the
835
+ * zone already meets placed content, a float or a span column, the box
836
+ * moves to the next page and is force-placed there. Frame and children go
837
+ * to `page.floats` (rendered outside the column clip) and `doc.blocks`.
838
+ * A box that closes the chapter first levels the band above it (trailing
839
+ * cap), so the closing columns end at the same height above the box.
840
+ */
841
+ const placeCalloutFixed = (startIdx, plan, style, layoutAt) => {
842
+ const anchor = style.fixed.anchor;
843
+ const offset = {
844
+ x: dimensionToPx(style.fixed.offset.x, dpi, bodyStyle.fontSizePx),
845
+ y: dimensionToPx(style.fixed.offset.y, dpi, bodyStyle.fontSizePx),
846
+ };
847
+ const snapDown = (page, v) => page.contentArea.y + Math.floor((v - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
848
+ const snapUp = (page, v) => page.contentArea.y + Math.ceil((v - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
849
+ const attempt = (page, force) => {
850
+ const ref = anchor.to === 'page' ? designFrames.page
851
+ : anchor.to === 'bleed' ? designFrames.bleed
852
+ : page.contentArea;
853
+ const band = cursor.pageIndex === page.index ? currentBand(page, cursor) : 0;
854
+ const cols = bandColumns(page, band).filter((c) => c.bbox.height > 0.5);
855
+ let result = layoutAt(page.contentArea.width);
856
+ if (style.width !== 'auto') {
857
+ // `fill`: as wide as the text column under the anchor point.
858
+ const probe = anchorBox(anchor.edge, ref, result.width, result.totalHeight, offset);
859
+ const mid = probe.x + probe.width / 2;
860
+ const under = cols.find((c) => mid >= c.bbox.x - 0.5 && mid <= c.bbox.x + c.bbox.width + 0.5);
861
+ if (under && Math.abs(under.bbox.width - result.width) > 0.01)
862
+ result = layoutAt(under.bbox.width);
863
+ }
864
+ const rect = anchorBox(anchor.edge, ref, result.width, result.totalHeight, offset);
865
+ const zoneTop = snapDown(page, rect.y - result.marginTopPx);
866
+ const zoneBottom = snapUp(page, rect.y + rect.height + result.marginBottomPx);
867
+ const cuts = [];
868
+ for (const col of cols) {
869
+ const meetsX = rect.x < col.bbox.x + col.bbox.width - 0.5 && rect.x + rect.width > col.bbox.x + 0.5;
870
+ if (!meetsX)
871
+ continue;
872
+ const colTop = col.bbox.y;
873
+ const colBottom = trueBottom(col, uncappedBottoms);
874
+ if (zoneTop >= colBottom - 0.5 || zoneBottom <= colTop + 0.5)
875
+ continue;
876
+ const centre = (zoneTop + zoneBottom) / 2;
877
+ if (centre >= colTop + (colBottom - colTop) / 2) {
878
+ const usedBottom = colTop + (col.bbox.height - col.availableHeight);
879
+ const capBottom = uncappedBottoms.has(col) ? colTop + col.bbox.height : usedBottom;
880
+ if (zoneTop < Math.max(usedBottom, capBottom) - 0.01) {
881
+ if (!force)
882
+ return null;
883
+ continue;
884
+ }
885
+ cuts.push({ col, kind: 'bottom', edge: zoneTop });
886
+ }
887
+ else {
888
+ if (col.blocks.length > 0) {
889
+ if (!force)
890
+ return null;
891
+ continue;
892
+ }
893
+ cuts.push({ col, kind: 'top', edge: zoneBottom });
894
+ }
895
+ }
896
+ if (!force) {
897
+ for (const fb of page.floats ?? [])
898
+ if (rectsOverlap(rect, fb.bbox))
899
+ return null;
900
+ for (const c of page.columns)
901
+ if (c.kind === 'span' && rectsOverlap(rect, c.bbox))
902
+ return null;
903
+ }
904
+ return { rect, result, cuts };
905
+ };
906
+ // A box that closes the chapter levels the columns above it first.
907
+ if (nextIsBarrier(plan.endIdx + 1)) {
908
+ tryPlacePendingFloatsOnCurrentPage();
909
+ proposeTrailingCap(startIdx);
910
+ }
911
+ let page = doc.pages[cursor.pageIndex];
912
+ let fit = attempt(page, false);
913
+ if (!fit) {
914
+ pendingSpacing = 0;
915
+ const startPageIndex = cursor.pageIndex;
916
+ do {
917
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
918
+ } while (cursor.pageIndex === startPageIndex);
919
+ page = doc.pages[cursor.pageIndex];
920
+ fit = attempt(page, false) ?? attempt(page, true);
921
+ }
922
+ for (const cut of fit.cuts) {
923
+ const col = cut.col;
924
+ if (cut.kind === 'bottom') {
925
+ const capped = uncappedBottoms.get(col);
926
+ if (capped !== undefined) {
927
+ uncappedBottoms.set(col, cut.edge);
928
+ }
929
+ else {
930
+ const newHeight = Math.max(0, cut.edge - col.bbox.y);
931
+ const reserved = col.bbox.height - newHeight;
932
+ col.bbox.height = newHeight;
933
+ col.availableHeight = Math.max(0, col.availableHeight - reserved);
934
+ }
935
+ }
936
+ else {
937
+ const shift = Math.max(0, cut.edge - col.bbox.y);
938
+ col.bbox.y += shift;
939
+ col.bbox.height = Math.max(0, col.bbox.height - shift);
940
+ col.availableHeight = Math.max(0, col.availableHeight - shift);
941
+ }
942
+ }
943
+ const { result, rect } = fit;
944
+ const frame = result.frame;
945
+ stampCalloutSource(frame, startIdx, plan);
946
+ frame.pageIndex = page.index;
947
+ frame.columnIndex = fit.cuts[0]?.col.index ?? (cursor.pageIndex === page.index ? cursor.columnIndex : 0);
948
+ offsetCalloutToAbsolute(result, rect.x, rect.y);
949
+ const floats = (page.floats ??= []);
950
+ doc.blocks.push(frame);
951
+ floats.push(frame);
952
+ for (const child of result.children) {
953
+ child.pageIndex = frame.pageIndex;
954
+ child.columnIndex = frame.columnIndex;
955
+ doc.blocks.push(child);
956
+ floats.push(child);
957
+ }
958
+ for (let i = startIdx + 1; i <= plan.endIdx; i++)
959
+ enqueueFloatsFor(i);
960
+ };
659
961
  /**
660
962
  * Place a `:::callout` inline at the current column width as one atomic
661
963
  * unit: the frame block followed by its children in the same column. The
@@ -692,6 +994,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
692
994
  paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
693
995
  });
694
996
  };
997
+ // Fixed boxes leave the flow entirely.
998
+ if (placement === 'fixed') {
999
+ placeCalloutFixed(startIdx, plan, style, layoutAt);
1000
+ return undefined;
1001
+ }
695
1002
  // Page-span boxes split a multi-column page into column bands (stage 1
696
1003
  // of span blocks). Floating placements keep the inline fallback.
697
1004
  {
@@ -735,11 +1042,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
735
1042
  // Floats first-referenced inside the box still enqueue in reading order
736
1043
  // (only once the box is committed, so a keep-with-next replay does not
737
1044
  // 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
- }
1045
+ for (let i = startIdx + 1; i <= plan.endIdx; i++)
1046
+ enqueueFloatsFor(i);
743
1047
  const frame = result.frame;
744
1048
  const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
745
1049
  enterBand(startIdx, 0);
@@ -762,13 +1066,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
762
1066
  if (options?.shouldCancel?.())
763
1067
  throw new BuildCancelledError();
764
1068
  const rawBlock = contentBlocks[blockIdx];
765
- // Enqueue floats first-referenced in this block so the next page opened
766
- // while placing it (or any later block) reserves their band. Done before
767
- // placement so a reference near a column/page boundary still floats onto
768
- // the page that follows it.
769
- const floatsHere = floatsByFirstBlock.get(blockIdx);
770
- if (floatsHere)
771
- pendingFloats.push(...floatsHere);
1069
+ // Floats whose reference landed in an earlier iteration take the first
1070
+ // free slot of the current page now — after their reference in reading
1071
+ // order. Then enqueue the floats first-referenced in this block, so the
1072
+ // next page opened while placing it (or any later block) reserves their
1073
+ // band and the next iteration offers them the slots that follow.
1074
+ tryPlacePendingFloatsOnCurrentPage();
1075
+ enqueueFloatsFor(blockIdx);
772
1076
  // --- Directives ----------------------------------------------------
773
1077
  if (rawBlock.type === 'directive') {
774
1078
  const name = rawBlock.directiveName;
@@ -784,6 +1088,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
784
1088
  || parity === 'always-even') {
785
1089
  enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
786
1090
  }
1091
+ // Pending floats land on the page that follows the break — after
1092
+ // parity padding, so a blank parity page never carries a float.
1093
+ flushFloatsIntoPage(doc.pages[cursor.pageIndex]);
787
1094
  flushPendingNumberingAtBoundary();
788
1095
  }
789
1096
  else if (name === 'columnbreak') {
@@ -834,7 +1141,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
834
1141
  const plan = partPlan.byStart.get(blockIdx);
835
1142
  if (plan) {
836
1143
  pendingSpacing = 0;
837
- markForcedBreak();
1144
+ closeFlowSegment(blockIdx);
838
1145
  leaveCurrentPage();
839
1146
  enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, resolved.parts.breakBefore.parity);
840
1147
  cursor.columnIndex = 0;
@@ -870,7 +1177,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
870
1177
  const parity = pendingPartBreak;
871
1178
  pendingPartBreak = null;
872
1179
  pendingSpacing = 0;
873
- markForcedBreak();
1180
+ closeFlowSegment(blockIdx);
874
1181
  leaveCurrentPage();
875
1182
  enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
876
1183
  flushPendingNumberingAtBoundary();
@@ -878,6 +1185,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
878
1185
  if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'callout') {
879
1186
  const plan = calloutPlan.get(blockIdx);
880
1187
  if (plan && pickCalloutStyle(resolved.calloutStyles, plan.attrs.type)) {
1188
+ // A float barrier box (e.g. a chapter's closing "key points")
1189
+ // takes every pending float first — in the current page's free
1190
+ // slots, else on pages opened ahead of it — so no float escapes
1191
+ // past it. The page stays balanceable (no forced break).
1192
+ if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier) {
1193
+ tryPlacePendingFloatsOnCurrentPage();
1194
+ drainPendingFloats();
1195
+ }
881
1196
  // `span: 'page'` boxes in multi-column layouts branch to the
882
1197
  // span-block path inside; `placement: 'top' | 'bottom'` still
883
1198
  // falls back to inline placement (floating boxes pending).
@@ -909,7 +1224,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
909
1224
  const bb = level?.breakBefore;
910
1225
  if (bb && bb.enabled) {
911
1226
  pendingSpacing = 0;
912
- markForcedBreak();
1227
+ closeFlowSegment(blockIdx);
913
1228
  advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
914
1229
  if (bb.parity !== 'any') {
915
1230
  enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, bb.parity);
@@ -922,7 +1237,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
922
1237
  // have their availableHeight reduced symmetrically after placement.
923
1238
  if (level?.span === 'page') {
924
1239
  pendingSpacing = 0;
925
- markForcedBreak();
1240
+ closeFlowSegment(blockIdx);
926
1241
  advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
927
1242
  cursor.columnIndex = 0;
928
1243
  flushPendingNumberingAtBoundary();
@@ -1447,6 +1762,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1447
1762
  advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1448
1763
  continue;
1449
1764
  }
1765
+ // Empty column with less than a line of room (a band cap cutting right
1766
+ // under a float band, a column swallowed by reservations): nothing can
1767
+ // go here — move on. The next column, or a fresh page, has room.
1768
+ if (curCol.availableHeight < style.lineHeightPx - 0.01 && totalRemainHeight > curCol.availableHeight + 0.01) {
1769
+ pendingSpacing = 0;
1770
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1771
+ continue;
1772
+ }
1450
1773
  // Empty column but block still doesn't fit (block taller than page) — place anyway
1451
1774
  const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
1452
1775
  const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
@@ -1488,9 +1811,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1488
1811
  }
1489
1812
  flushPendingNumberingAtBoundary();
1490
1813
  }
1491
- // Place any floats still pending (referenced on the last page, or never
1492
- // followed by a content-overflow page break) onto freshly appended pages.
1493
- finalizeFloats();
1814
+ // End of the document: level the closing band and place any floats still
1815
+ // pending (referenced on the last page) on pages appended after it.
1816
+ closeFlowSegment(contentBlocks.length);
1494
1817
  // Stamp page-number info onto every page (including blank parity pages).
1495
1818
  const labels = buildPageLabels(doc.pages.length, pageNumberSegments);
1496
1819
  for (let i = 0; i < doc.pages.length; i++) {
@@ -1508,16 +1831,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1508
1831
  return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome };
1509
1832
  }
1510
1833
  export function buildDocument(content, config, cache, options) {
1834
+ const runPass = (hints) => buildDocumentPass(content, config, cache, options, hints);
1511
1835
  // --- Band caps (page-span blocks mid-page) -----------------------------
1512
1836
  // A span block that arrived in an uneven band proposes a cap; the driver
1513
1837
  // re-places the document with it (and grows / drops caps whose band
1514
1838
  // overflowed) before balancing runs. Documents without such blocks get
1515
1839
  // their first pass back untouched — no extra pass.
1516
- const bands = resolveBandCaps(buildDocumentPass(content, config, cache, options), (bandCaps) => buildDocumentPass(content, config, cache, options, { bandCaps }));
1840
+ const bands = resolveBandCaps(runPass(), (bandCaps) => runPass({ bandCaps }));
1517
1841
  let best = bands.result;
1518
1842
  const bandCaps = bands.bandCaps;
1519
1843
  let passCount = bands.passCount;
1520
1844
  best.doc.iterationCount = passCount;
1845
+ /** Hints that produced `best` (replayed by the trailing-cap passes). */
1846
+ let bestHints = { bandCaps };
1521
1847
  // --- Column balancing (vertical justification) ------------------------
1522
1848
  // Iteratively re-place the document with extra grid lines above headings
1523
1849
  // until every balanceable column ends flush with the page bottom (or no
@@ -1533,66 +1859,93 @@ export function buildDocument(content, config, cache, options) {
1533
1859
  let applied = { lines: new Map(), loose: new Map() };
1534
1860
  const failedLoose = new Set();
1535
1861
  let converged = bestScore === 0;
1536
- while (!converged && passCount < MAX_BALANCING_PASSES) {
1537
- const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
1538
- maxLinesPerHeading: balancing.maxLinesPerHeading,
1539
- stretchAfterLists: balancing.stretchAfterLists,
1540
- maxLinesAfterList: balancing.maxLinesAfterList,
1541
- looseParagraphs: balancing.looseParagraphs,
1542
- maxLooseParagraphs: balancing.maxLooseParagraphs,
1543
- optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
1544
- failedLoose,
1545
- });
1546
- if (!proposal.changed) {
1547
- // No stretch point can absorb the remaining gaps — stable.
1548
- converged = true;
1549
- break;
1550
- }
1551
- const extraPx = new Map();
1552
- for (const [idx, n] of proposal.lines)
1553
- extraPx.set(idx, n * best.doc.baselineGrid);
1554
- const next = buildDocumentPass(content, config, cache, options, {
1555
- balanceExtraPx: extraPx,
1556
- balanceLooseness: proposal.loose,
1557
- balanceLooseBudget: proposal.looseBudget,
1558
- bandCaps,
1559
- });
1560
- passCount++;
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');
1575
- if (score < bestScore) {
1576
- best = next;
1577
- bestScore = score;
1578
- applied = {
1579
- lines: proposal.lines,
1580
- loose: new Map([...proposal.loose].filter(([k]) => applied.loose.has(k) || looseWon.includes(k))),
1862
+ const balance = () => {
1863
+ while (!converged && passCount < MAX_BALANCING_PASSES) {
1864
+ const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
1865
+ maxLinesPerHeading: balancing.maxLinesPerHeading,
1866
+ stretchAfterLists: balancing.stretchAfterLists,
1867
+ maxLinesAfterList: balancing.maxLinesAfterList,
1868
+ looseParagraphs: balancing.looseParagraphs,
1869
+ maxLooseParagraphs: balancing.maxLooseParagraphs,
1870
+ optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
1871
+ failedLoose,
1872
+ });
1873
+ if (!proposal.changed) {
1874
+ // No stretch point can absorb the remaining gaps — stable.
1875
+ converged = true;
1876
+ break;
1877
+ }
1878
+ const extraPx = new Map();
1879
+ for (const [idx, n] of proposal.lines)
1880
+ extraPx.set(idx, n * best.doc.baselineGrid);
1881
+ const hints = {
1882
+ balanceExtraPx: extraPx,
1883
+ balanceLooseness: proposal.loose,
1884
+ balanceLooseBudget: proposal.looseBudget,
1885
+ bandCaps,
1581
1886
  };
1582
- converged = score === 0;
1583
- }
1584
- else {
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
1589
- // plateau means we're done: keep the best layout found so far.
1590
- if (looseFailed.length > 0 || looseWon.length > 0) {
1591
- for (const k of looseWon)
1592
- failedLoose.add(k);
1593
- continue;
1887
+ const next = runPass(hints);
1888
+ passCount++;
1889
+ // Band caps ride along unchanged; a retry that unsettles one (its span
1890
+ // block no longer lands in the capped band, or a levelled closing band
1891
+ // spills past its cut) counts as a regression — capped columns without
1892
+ // their box are not a layout we may keep.
1893
+ const capsDelivered = [...bandCaps.keys()].every((i) => next.spanPlacedInBand.has(i));
1894
+ const score = capsDelivered ? totalGapLines(next.doc, next.forcedBreakPages) : Infinity;
1895
+ // Loose paragraphs that gained no line at any tracking rung are
1896
+ // blacklisted whatever the score did, and never counted as applied.
1897
+ // Candidates the pass never tried (their column's budget was met
1898
+ // first) stay eligible for a later proposal.
1899
+ const newlyLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
1900
+ const looseFailed = newlyLoose.filter((k) => next.looseOutcome.get(k) === null);
1901
+ for (const k of looseFailed)
1902
+ failedLoose.add(k);
1903
+ const looseWon = newlyLoose.filter((k) => typeof next.looseOutcome.get(k) === 'number');
1904
+ if (score < bestScore) {
1905
+ best = next;
1906
+ bestHints = hints;
1907
+ bestScore = score;
1908
+ applied = {
1909
+ lines: proposal.lines,
1910
+ loose: new Map([...proposal.loose].filter(([k]) => applied.loose.has(k) || looseWon.includes(k))),
1911
+ };
1912
+ converged = score === 0;
1594
1913
  }
1595
- break;
1914
+ else {
1915
+ // Plateau or regression. Retry when a loose candidate was just
1916
+ // blacklisted (the proposer falls through to the next one), or when
1917
+ // the new loose paragraphs gained their lines yet the layout did not
1918
+ // improve (the gain landed elsewhere — drop them too). A pure spacing
1919
+ // plateau means we're done: keep the best layout found so far.
1920
+ if (looseFailed.length > 0 || looseWon.length > 0) {
1921
+ for (const k of looseWon)
1922
+ failedLoose.add(k);
1923
+ continue;
1924
+ }
1925
+ break;
1926
+ }
1927
+ }
1928
+ };
1929
+ balance();
1930
+ // --- Trailing bands (closing columns cut level) -------------------------
1931
+ // Once the balancing levers have settled the earlier pages, level the
1932
+ // closing band of every chapter / the document with a trailing cap. It is
1933
+ // resolved AFTER balancing because a cap is keyed by the block that opens
1934
+ // its band, and that block moves whenever an earlier page absorbs extra
1935
+ // lines; with the balancing hints frozen the band opens with the same
1936
+ // block and the cap applies. A short polish round then lets the levers
1937
+ // fill what the cut left short (a column ending a line under the cap).
1938
+ if (balancing.trailing) {
1939
+ const trailing = resolveTrailingCaps(best, bandCaps, (caps) => runPass({ ...bestHints, bandCaps: caps }));
1940
+ passCount += trailing.passCount;
1941
+ if (trailing.result !== best) {
1942
+ best = trailing.result;
1943
+ for (const [i, cap] of trailing.caps)
1944
+ bandCaps.set(i, cap);
1945
+ bestHints = { ...bestHints, bandCaps };
1946
+ bestScore = totalGapLines(best.doc, best.forcedBreakPages);
1947
+ converged = bestScore === 0;
1948
+ balance();
1596
1949
  }
1597
1950
  }
1598
1951
  best.doc.iterationCount = passCount;