postext 0.3.43 → 0.3.44

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 (120) hide show
  1. package/README.md +1 -1
  2. package/dist/__tests__/createLayout.test.js +5 -65
  3. package/dist/__tests__/createLayout.test.js.map +1 -1
  4. package/dist/__tests__/defaults/calloutStyles.test.js +3 -0
  5. package/dist/__tests__/defaults/calloutStyles.test.js.map +1 -1
  6. package/dist/__tests__/parse/containers.test.js +1 -1
  7. package/dist/__tests__/parse/containers.test.js.map +1 -1
  8. package/dist/__tests__/parse/scripts.test.d.ts +2 -0
  9. package/dist/__tests__/parse/scripts.test.d.ts.map +1 -0
  10. package/dist/__tests__/parse/scripts.test.js +68 -0
  11. package/dist/__tests__/parse/scripts.test.js.map +1 -0
  12. package/dist/__tests__/pipeline/calloutColumnsLabel.test.d.ts +2 -0
  13. package/dist/__tests__/pipeline/calloutColumnsLabel.test.d.ts.map +1 -0
  14. package/dist/__tests__/pipeline/calloutColumnsLabel.test.js +163 -0
  15. package/dist/__tests__/pipeline/calloutColumnsLabel.test.js.map +1 -0
  16. package/dist/__tests__/pipeline/calloutFloat.test.d.ts +2 -0
  17. package/dist/__tests__/pipeline/calloutFloat.test.d.ts.map +1 -0
  18. package/dist/__tests__/pipeline/calloutFloat.test.js +138 -0
  19. package/dist/__tests__/pipeline/calloutFloat.test.js.map +1 -0
  20. package/dist/__tests__/pipeline/calloutPlacement.test.js +10 -6
  21. package/dist/__tests__/pipeline/calloutPlacement.test.js.map +1 -1
  22. package/dist/__tests__/pipeline/floatPlacement.test.js +3 -3
  23. package/dist/__tests__/pipeline/floatPlacement.test.js.map +1 -1
  24. package/dist/__tests__/pipeline/sideColumn.test.d.ts +2 -0
  25. package/dist/__tests__/pipeline/sideColumn.test.d.ts.map +1 -0
  26. package/dist/__tests__/pipeline/sideColumn.test.js +130 -0
  27. package/dist/__tests__/pipeline/sideColumn.test.js.map +1 -0
  28. package/dist/__tests__/pipeline/spanBlocks.test.js +11 -3
  29. package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
  30. package/dist/canvas-backend/blockRender.js +2 -2
  31. package/dist/canvas-backend/blockRender.js.map +1 -1
  32. package/dist/canvas-backend/renderResourceBlock.js +2 -2
  33. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  34. package/dist/columnRule.d.ts.map +1 -1
  35. package/dist/columnRule.js +3 -0
  36. package/dist/columnRule.js.map +1 -1
  37. package/dist/defaults/calloutStyles.d.ts +23 -0
  38. package/dist/defaults/calloutStyles.d.ts.map +1 -1
  39. package/dist/defaults/calloutStyles.js +68 -0
  40. package/dist/defaults/calloutStyles.js.map +1 -1
  41. package/dist/defaults/headerFooter.d.ts.map +1 -1
  42. package/dist/defaults/headerFooter.js +2 -0
  43. package/dist/defaults/headerFooter.js.map +1 -1
  44. package/dist/defaults/headings.d.ts.map +1 -1
  45. package/dist/defaults/headings.js +7 -1
  46. package/dist/defaults/headings.js.map +1 -1
  47. package/dist/defaults/layout.d.ts.map +1 -1
  48. package/dist/defaults/layout.js +12 -0
  49. package/dist/defaults/layout.js.map +1 -1
  50. package/dist/design/layout.d.ts +3 -0
  51. package/dist/design/layout.d.ts.map +1 -1
  52. package/dist/design/layout.js +149 -5
  53. package/dist/design/layout.js.map +1 -1
  54. package/dist/html-backend.d.ts.map +1 -1
  55. package/dist/html-backend.js +8 -4
  56. package/dist/html-backend.js.map +1 -1
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js.map +1 -1
  60. package/dist/knuthPlass/richAdapter.d.ts +3 -0
  61. package/dist/knuthPlass/richAdapter.d.ts.map +1 -1
  62. package/dist/knuthPlass/richAdapter.js +1 -0
  63. package/dist/knuthPlass/richAdapter.js.map +1 -1
  64. package/dist/measure/font.d.ts +9 -0
  65. package/dist/measure/font.d.ts.map +1 -1
  66. package/dist/measure/font.js +17 -1
  67. package/dist/measure/font.js.map +1 -1
  68. package/dist/measure/rich.d.ts +17 -0
  69. package/dist/measure/rich.d.ts.map +1 -1
  70. package/dist/measure/rich.js +45 -4
  71. package/dist/measure/rich.js.map +1 -1
  72. package/dist/parse/blockParser.d.ts.map +1 -1
  73. package/dist/parse/blockParser.js +1 -1
  74. package/dist/parse/blockParser.js.map +1 -1
  75. package/dist/parse/inlineFormatting.d.ts +5 -0
  76. package/dist/parse/inlineFormatting.d.ts.map +1 -1
  77. package/dist/parse/inlineFormatting.js +29 -4
  78. package/dist/parse/inlineFormatting.js.map +1 -1
  79. package/dist/parse/types.d.ts +4 -1
  80. package/dist/parse/types.d.ts.map +1 -1
  81. package/dist/pipeline/build.d.ts.map +1 -1
  82. package/dist/pipeline/build.js +452 -85
  83. package/dist/pipeline/build.js.map +1 -1
  84. package/dist/pipeline/calloutLayout.d.ts +4 -0
  85. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  86. package/dist/pipeline/calloutLayout.js +390 -131
  87. package/dist/pipeline/calloutLayout.js.map +1 -1
  88. package/dist/pipeline/columnBalancing.js +1 -1
  89. package/dist/pipeline/columnBalancing.js.map +1 -1
  90. package/dist/pipeline/config.d.ts +16 -1
  91. package/dist/pipeline/config.d.ts.map +1 -1
  92. package/dist/pipeline/config.js +35 -2
  93. package/dist/pipeline/config.js.map +1 -1
  94. package/dist/pipeline/floatPlacement.d.ts +21 -0
  95. package/dist/pipeline/floatPlacement.d.ts.map +1 -1
  96. package/dist/pipeline/floatPlacement.js +12 -3
  97. package/dist/pipeline/floatPlacement.js.map +1 -1
  98. package/dist/pipeline/floatSlots.d.ts +17 -0
  99. package/dist/pipeline/floatSlots.d.ts.map +1 -1
  100. package/dist/pipeline/floatSlots.js +27 -3
  101. package/dist/pipeline/floatSlots.js.map +1 -1
  102. package/dist/pipeline/headerFooter.js +1 -1
  103. package/dist/pipeline/headerFooter.js.map +1 -1
  104. package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
  105. package/dist/pipeline/measureContentBlock.js +13 -3
  106. package/dist/pipeline/measureContentBlock.js.map +1 -1
  107. package/dist/pipeline/placement.d.ts +12 -1
  108. package/dist/pipeline/placement.d.ts.map +1 -1
  109. package/dist/pipeline/placement.js +50 -7
  110. package/dist/pipeline/placement.js.map +1 -1
  111. package/dist/pipeline/resourceLayout.d.ts +17 -0
  112. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  113. package/dist/pipeline/resourceLayout.js +45 -11
  114. package/dist/pipeline/resourceLayout.js.map +1 -1
  115. package/dist/types.d.ts +174 -62
  116. package/dist/types.d.ts.map +1 -1
  117. package/dist/vdt.d.ts +11 -2
  118. package/dist/vdt.d.ts.map +1 -1
  119. package/dist/vdt.js.map +1 -1
  120. package/package.json +1 -1
@@ -12,7 +12,7 @@ import { computeOutline, hasTocDirective, outlineFromDoc, sameOutline } from './
12
12
  import { expandTocDirectives } from './toc';
13
13
  import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
14
14
  import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
15
- import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, pageIsOccupied, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
15
+ import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, sideColumnOf, sideColumns, sideUsedBottom, pageIsOccupied, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
16
16
  import { chooseParagraphSplit } from './orphanWidow';
17
17
  import { applyStyleAttrs, computePageMetrics, isMarkerBlock, nextNonMarkerBlock, prevNonMarkerBlock, rollbackTrailingBlocks, } from './buildHelpers';
18
18
  import { measureContentBlock } from './measureContentBlock';
@@ -21,7 +21,7 @@ import { planParts, derivePartMeasureContext } from './parts';
21
21
  import { layoutCallout, offsetCalloutToAbsolute, pickCalloutStyle, planCallouts, resolveCalloutAttrs, } from './calloutLayout';
22
22
  import { layoutResourceBlock, planTableSlice } from './resourceLayout';
23
23
  import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
24
- import { enumerateCurrentPageSlots, measureFloatBand, columnHasFloatBand, fitsStrict, trueBottom, } from './floatSlots';
24
+ import { enumerateCurrentPageSlots, measureFloatBand, measureSideStack, columnHasFloatBand, fitsStrict, trueBottom, } from './floatSlots';
25
25
  import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
26
26
  import { defaultResourceTypes } from '../defaults/resourceTypes';
27
27
  import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
@@ -211,6 +211,17 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
211
211
  // Floats whose first reference has been passed but which are not yet placed
212
212
  // into a page band, in reading order.
213
213
  const pendingFloats = [];
214
+ /** `span: 'side'` boxes that found no room in the side column of their
215
+ * page: they take the side column of the next page the flow opens, in
216
+ * order (see `placeCalloutSide`). */
217
+ const pendingSideBoxes = [];
218
+ /** Whether the band the cursor is in lays out as a multi-column band:
219
+ * two or more text columns, or one beside a float-only side column — a
220
+ * page-span block then cuts the band across every column. */
221
+ const multiColumnBand = (page) => {
222
+ const band = currentBand(page, cursor);
223
+ return bandColumns(page, band).length > 1 || sideColumnOf(page, band) !== undefined;
224
+ };
214
225
  /** Resource ids already enqueued in this pass — a keep-with-next rewind
215
226
  * replays the loop top for the rolled-back blocks and must not enqueue
216
227
  * (and later place) the same float twice. */
@@ -219,13 +230,56 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
219
230
  const fl = floatsByFirstBlock.get(blockIdx);
220
231
  if (!fl)
221
232
  return;
233
+ const page = doc.pages[cursor.pageIndex];
234
+ const col = page?.columns[cursor.columnIndex];
222
235
  for (const f of fl) {
223
236
  if (enqueuedFloatIds.has(f.resourceId))
224
237
  continue;
225
238
  enqueuedFloatIds.add(f.resourceId);
226
- pendingFloats.push(f);
239
+ // Where the citing block starts: a side float stacks beside it.
240
+ const stamped = col
241
+ ? { ...f, refPageIndex: page.index, refY: col.bbox.y + (col.bbox.height - col.availableHeight) + (col.blocks.length > 0 ? pendingSpacing : 0) }
242
+ : f;
243
+ pendingFloats.push(stamped);
227
244
  }
228
245
  };
246
+ /** Floated boxes (`placement: 'top' | 'bottom'`, `span` column / page):
247
+ * a `:::callout` that leaves the flow like a resource and takes the
248
+ * first free band after the point it occurs at — the foot of the
249
+ * current page, or the head / foot of a page the flow opens later —
250
+ * while the text after it fills the page it left. Keyed by the fence's
251
+ * content index; the pending float carries the synthetic id
252
+ * `callout:<idx>` (see `calloutFloatOf`). */
253
+ const calloutFloats = new Map();
254
+ const CALLOUT_FLOAT_PREFIX = 'callout:';
255
+ const calloutFloatOf = (resourceId) => resourceId.startsWith(CALLOUT_FLOAT_PREFIX) ? calloutFloats.get(Number(resourceId.slice(CALLOUT_FLOAT_PREFIX.length))) : undefined;
256
+ /** Boxes laid out for a band, waiting for the band's `y` (built in
257
+ * `buildFloatBlock`, committed in `commitFloatBlock`). */
258
+ const calloutFloatResults = new Map();
259
+ /** Pages whose head or foot a floated box took (see the gallery rule in
260
+ * `placeFloatInColumns`). */
261
+ const calloutBandPages = new Set();
262
+ const enqueueCalloutFloat = (startIdx, plan, style, L, placement, span) => {
263
+ const key = `${CALLOUT_FLOAT_PREFIX}${startIdx}`;
264
+ // A keep-with-next replay passes the fence again: enqueue once.
265
+ if (!enqueuedFloatIds.has(key)) {
266
+ enqueuedFloatIds.add(key);
267
+ calloutFloats.set(startIdx, { plan, style, L });
268
+ const page = doc.pages[cursor.pageIndex];
269
+ const col = page?.columns[cursor.columnIndex];
270
+ pendingFloats.push({
271
+ resourceId: key,
272
+ firstBlockIdx: startIdx,
273
+ position: placement,
274
+ span: span === 'page' ? 'page' : 'column',
275
+ callout: { startIdx },
276
+ ...(col ? { refPageIndex: page.index, refY: col.bbox.y + (col.bbox.height - col.availableHeight) } : {}),
277
+ });
278
+ }
279
+ // Floats first referenced inside the box enqueue in reading order.
280
+ for (let i = startIdx + 1; i <= plan.endIdx; i++)
281
+ enqueueFloatsFor(i);
282
+ };
229
283
  const floatGapPx = bodyStyle.lineHeightPx;
230
284
  const minTextPx = bodyStyle.lineHeightPx * 3;
231
285
  /** Fewest body rows the closing slice of a split table carries. */
@@ -276,7 +330,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
276
330
  }
277
331
  };
278
332
  const rotationKey = (rotated) => rotated ? `:${rotated.direction}${rotated.length.toFixed(2)}` : '';
279
- const layoutFloat = (resourceId, width, slice, rotated) => {
333
+ const asideKey = (a) => (a ? `:aside${a.dx.toFixed(1)}x${a.width.toFixed(1)}${a.alignBottom ? 'b' : 't'}${(a.offsetY ?? 0).toFixed(1)}` : '');
334
+ const layoutFloat = (resourceId, width, slice, rotated, aside) => {
280
335
  const resource = resourceById.get(resourceId);
281
336
  if (!resource)
282
337
  return null;
@@ -291,6 +346,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
291
346
  resources,
292
347
  ...(slice ? { slice } : {}),
293
348
  ...(rotated ? { rotate: rotated.direction, rotatedLength: rotated.length } : {}),
349
+ ...(aside ? { captionAside: aside } : {}),
294
350
  });
295
351
  };
296
352
  /** The rotation of a pending float on a band whose columns keep `avail`
@@ -322,12 +378,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
322
378
  /** Height (and caption baseline) of a float at a given width, memoised —
323
379
  * fit checks run for every pending float on every loop iteration. */
324
380
  const floatMeasureMemo = new Map();
325
- const measureFloat = (resourceId, width, slice, rotated) => {
326
- const key = `${resourceId}:${width.toFixed(2)}${sliceKey(slice)}${rotationKey(rotated)}`;
381
+ const measureFloat = (resourceId, width, slice, rotated, aside) => {
382
+ const key = `${resourceId}:${width.toFixed(2)}${sliceKey(slice)}${rotationKey(rotated)}${asideKey(aside)}`;
327
383
  const memo = floatMeasureMemo.get(key);
328
384
  if (memo !== undefined)
329
385
  return memo;
330
- const laid = layoutFloat(resourceId, width, slice, rotated);
386
+ const cf = calloutFloatOf(resourceId);
387
+ if (cf) {
388
+ // A floated box: its frame at the band's width, title and icon
389
+ // included; no caption baseline to align.
390
+ const r = cf.L.layoutRange(CUT_START, cf.L.end, width, 'float-probe', false);
391
+ const mc = { height: r.totalHeight };
392
+ floatMeasureMemo.set(key, mc);
393
+ return mc;
394
+ }
395
+ const laid = layoutFloat(resourceId, width, slice, rotated, aside);
331
396
  let m = null;
332
397
  if (laid && laid.block.rotation) {
333
398
  // A rotated block takes its band whole: no caption baseline to align,
@@ -351,6 +416,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
351
416
  m = {
352
417
  height: laid.totalHeight,
353
418
  ...(lastBaseline !== undefined ? { lastCaptionBaseline: lastBaseline } : {}),
419
+ ...(laid.asideHeight !== undefined ? { asideHeight: laid.asideHeight } : {}),
354
420
  };
355
421
  }
356
422
  floatMeasureMemo.set(key, m);
@@ -361,8 +427,18 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
361
427
  const buildFloatBlock = (resourceId, x, width, slice, rotated,
362
428
  /** A rotated block sits flush to the band's right edge (the spine of a
363
429
  * verso page) instead of its left. */
364
- flushEnd = false) => {
365
- const laid = layoutFloat(resourceId, width, slice, rotated);
430
+ flushEnd = false, aside,
431
+ /** The page is a verso of mirrored margins (a floated box's `'outer'`
432
+ * corner icon hangs on the left there). */
433
+ mirrored = false) => {
434
+ const cf = calloutFloatOf(resourceId);
435
+ if (cf) {
436
+ const startIdx = Number(resourceId.slice(CALLOUT_FLOAT_PREFIX.length));
437
+ const result = cf.L.layoutRange(CUT_START, cf.L.end, width, `block-${blockIdCounter++}`, false, mirrored);
438
+ calloutFloatResults.set(result.frame, { result, startIdx, plan: cf.plan });
439
+ return { block: result.frame, height: result.totalHeight };
440
+ }
441
+ const laid = layoutFloat(resourceId, width, slice, rotated, aside);
366
442
  if (!laid)
367
443
  return null;
368
444
  const { block: rb, totalHeight } = laid;
@@ -514,22 +590,56 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
514
590
  /** The band a float would take in a slot — its height (`need`) and the
515
591
  * float's `y` — without reserving it. `null` when the float cannot be
516
592
  * measured. */
517
- const probeFloatBand = (page, f, targetCols, position, pageSpan, anchorToCap) => {
593
+ const probeFloatBand = (page, f, targetCols, position, pageSpan, anchorToCap, side = false, refY) => {
518
594
  const first = targetCols[0];
519
- const width = pageSpan ? page.contentArea.width : first.bbox.width;
520
- const xLeft = pageSpan ? page.contentArea.x : first.bbox.x;
595
+ const slotWidth = pageSpan ? page.contentArea.width : first.bbox.width;
596
+ const slotX = pageSpan ? page.contentArea.x : first.bbox.x;
597
+ // A narrower float (`placement.width`) sits in its slot per `align`.
598
+ const width = f.widthFraction && f.widthFraction < 1 ? slotWidth * f.widthFraction : slotWidth;
599
+ const alignK = f.align === 'center' ? 0.5 : f.align === 'right' ? 1 : 0;
600
+ const xLeft = slotX + (slotWidth - width) * alignK;
521
601
  const slice = sliceOf(f);
522
- const rotated = rotationFor(f, Math.min(...targetCols.map((c) => c.availableHeight)));
523
- const measure = measureFloat(f.resourceId, width, slice, rotated);
602
+ // A side float never turns: it stacks upright in the side column.
603
+ const rotated = side ? undefined : rotationFor(f, Math.min(...targetCols.map((c) => c.availableHeight)));
604
+ // The caption beside the figure, in the band's side column.
605
+ let aside;
606
+ let sideCol;
607
+ if (f.captionSide && !pageSpan && !side && !rotated) {
608
+ const sc = sideColumnOf(page, first.band ?? 0);
609
+ if (sc && sc.bbox.width > 0.5) {
610
+ sideCol = sc;
611
+ aside = { dx: sc.bbox.x - xLeft, width: sc.bbox.width, alignBottom: position === 'bottom' };
612
+ }
613
+ }
614
+ const measure = measureFloat(f.resourceId, width, slice, rotated, aside);
524
615
  if (!measure)
525
616
  return null;
617
+ // A side float stacks in the side column beside the citing text.
618
+ if (side) {
619
+ const { need, y } = measureSideStack(measure, first, refY, page.contentArea, baselineGrid, floatGapPx);
620
+ return { need, y, measure, slice, width, xLeft, rotated };
621
+ }
526
622
  // A bottom band normally anchors to the column's true foot (under a
527
623
  // trailing cap, the page bottom — the closing-page figure). Before a
528
624
  // page-span box the cap IS the band's foot: the figure hugs the text
529
625
  // and the box follows both.
530
626
  const bottomOf = (c) => anchorToCap ? c.bbox.y + c.bbox.height : trueBottom(c, uncappedBottoms);
531
- const { need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, bottomOf);
532
- return { need, y, measure, slice, width, xLeft, rotated };
627
+ let { need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, bottomOf);
628
+ // A caption level with a top float's head would overlap what the side
629
+ // column already holds: it drops under the stack instead.
630
+ if (aside && sideCol && position === 'top' && !aside.alignBottom) {
631
+ const used = sideUsedBottom(sideCol);
632
+ if (used > y + 0.5) {
633
+ const shifted = { ...aside, offsetY: used - y };
634
+ const m2 = measureFloat(f.resourceId, width, slice, rotated, shifted);
635
+ if (m2) {
636
+ aside = shifted;
637
+ ({ need, y } = measureFloatBand(position, m2, targetCols, page.contentArea, baselineGrid, floatGapPx, bottomOf));
638
+ return { need, y, measure: m2, slice, width, xLeft, rotated, aside, sideCol };
639
+ }
640
+ }
641
+ }
642
+ return { need, y, measure, slice, width, xLeft, rotated, ...(aside ? { aside, sideCol } : {}) };
533
643
  };
534
644
  /** Free height `col` keeps for the flow once a float band `probe` is
535
645
  * reserved in it at `position` (mirrors the reservation arithmetic of
@@ -543,18 +653,68 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
543
653
  const newHeight = Math.max(0, probe.y - floatGapPx - col.bbox.y);
544
654
  return Math.max(0, col.availableHeight - (col.bbox.height - newHeight));
545
655
  };
546
- const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode, anchorToCap = false) => {
656
+ /** Set a built float at its band position and hand it to the page: a
657
+ * resource block into `page.floats`; a floated box's frame and children
658
+ * into `page.floats` and `doc.blocks`, the way a fixed box goes. */
659
+ const commitFloatBlock = (page, col, built, x, y, width) => {
660
+ const cf = calloutFloatResults.get(built.block);
661
+ if (cf) {
662
+ calloutFloatResults.delete(built.block);
663
+ const { result, startIdx, plan } = cf;
664
+ const frame = result.frame;
665
+ stampCalloutSource(frame, startIdx, plan);
666
+ frame.pageIndex = page.index;
667
+ frame.columnIndex = col.index;
668
+ offsetCalloutToAbsolute(result, x, y);
669
+ const floats = (page.floats ??= []);
670
+ doc.blocks.push(frame);
671
+ floats.push(frame);
672
+ for (const child of result.children) {
673
+ child.pageIndex = frame.pageIndex;
674
+ child.columnIndex = frame.columnIndex;
675
+ doc.blocks.push(child);
676
+ floats.push(child);
677
+ }
678
+ calloutBandPages.add(page);
679
+ return;
680
+ }
681
+ offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, y);
682
+ built.block.bbox = createBoundingBox(x, y, width, built.height);
683
+ built.block.pageIndex = page.index;
684
+ built.block.columnIndex = col.index;
685
+ (page.floats ??= []).push(built.block);
686
+ };
687
+ const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode, anchorToCap = false, side = false, refY) => {
547
688
  const first = targetCols[0];
548
- const probe = probeFloatBand(page, f, targetCols, position, pageSpan, anchorToCap);
689
+ const probe = probeFloatBand(page, f, targetCols, position, pageSpan, anchorToCap, side, refY);
549
690
  if (!probe)
550
691
  return 'skip';
551
- const { width, xLeft, rotated } = probe;
692
+ const { width, xLeft, rotated, aside, sideCol } = probe;
552
693
  let { slice, measure, need, y } = probe;
553
694
  let rest;
554
695
  /** The float was cut to this slot (a table slice). */
555
696
  let cut = false;
556
697
  /** A rotated block wider than the band (its upright height). */
557
698
  const tooWide = (m) => m.rotatedWidth !== undefined && m.rotatedWidth > width + 0.01;
699
+ if (side) {
700
+ // The side column's stack: the float goes under what the column holds
701
+ // when the rest of the column takes it; otherwise it waits for the
702
+ // side column of the next page — where it is set anyway, overflowing,
703
+ // when even an empty column cannot hold it (a dominating figure).
704
+ if (need > first.availableHeight + 0.01) {
705
+ if (mode === 'strict' || sideUsedBottom(first) > first.bbox.y + 0.5)
706
+ return 'defer';
707
+ }
708
+ const built = buildFloatBlock(f.resourceId, xLeft, width, slice);
709
+ if (!built)
710
+ return 'skip';
711
+ first.availableHeight = Math.max(0, first.availableHeight - need);
712
+ commitFloatBlock(page, first, built, xLeft, y, width);
713
+ floatsPlaced++;
714
+ return 'placed';
715
+ }
716
+ /** Gallery page (see below): the band takes the columns' whole room. */
717
+ let galleryFill = false;
558
718
  if (mode === 'fresh') {
559
719
  let minAvail = Infinity;
560
720
  let anyReserved = false;
@@ -564,8 +724,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
564
724
  if (r.top > 0 || r.bottom > 0)
565
725
  anyReserved = true;
566
726
  }
567
- if (need > minAvail - minTextPx && anyReserved)
568
- return 'defer';
727
+ if (need > minAvail - minTextPx && anyReserved) {
728
+ // Gallery page: a float cited on an earlier page that arrives
729
+ // under a floated box heading this page takes the rest of the
730
+ // page — the compositor's box-plus-figure page — rather than
731
+ // waiting for the next one and leaving a line or two stranded
732
+ // between the bands. Only a float that fits whole.
733
+ const gallery = need <= minAvail + 0.01
734
+ && f.refPageIndex !== undefined && f.refPageIndex < page.index
735
+ && calloutBandPages.has(page) && !calloutFloatOf(f.resourceId);
736
+ if (!gallery)
737
+ return 'defer';
738
+ galleryFill = true;
739
+ }
569
740
  if (need > minAvail + 0.01 || tooWide(measure)) {
570
741
  // Dominating the band: a table is cut to it (a rotated table, to
571
742
  // the band's width).
@@ -635,9 +806,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
635
806
  if (rest) {
636
807
  rest = { ...rest, notBefore: { pageIndex: page.index, columnIndex: targetCols[targetCols.length - 1].index } };
637
808
  }
638
- const built = buildFloatBlock(f.resourceId, xLeft, width, slice, rotated, rotated ? rotatedFlushEnd(page) : false);
809
+ const built = buildFloatBlock(f.resourceId, xLeft, width, slice, rotated, rotated ? rotatedFlushEnd(page) : false, aside, mirroredOf(page));
639
810
  if (!built)
640
811
  return 'skip';
812
+ // The caption beside the figure takes its band of the side column: a
813
+ // top float's caption is consumed from the stack's head (when the
814
+ // stack is still above it), a bottom float's cuts the column's foot.
815
+ if (aside && sideCol && measure.asideHeight !== undefined) {
816
+ const bandH = measure.asideHeight + floatGapPx;
817
+ if (position === 'top') {
818
+ const used = sideUsedBottom(sideCol);
819
+ const bottom = y + (aside.offsetY ?? 0) + bandH;
820
+ if (bottom > used)
821
+ sideCol.availableHeight = Math.max(0, sideCol.availableHeight - (bottom - used));
822
+ }
823
+ else {
824
+ const cut = Math.max(0, sideCol.bbox.y + sideCol.bbox.height - (y + built.height - measure.asideHeight - floatGapPx));
825
+ sideCol.bbox.height = Math.max(0, sideCol.bbox.height - cut);
826
+ sideCol.availableHeight = Math.max(0, sideCol.availableHeight - cut);
827
+ }
828
+ }
641
829
  for (const col of targetCols) {
642
830
  const r = { ...reservedOf(col) };
643
831
  if (position === 'top') {
@@ -667,11 +855,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
667
855
  }
668
856
  floatReserved.set(col, r);
669
857
  }
670
- offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, y);
671
- built.block.bbox = createBoundingBox(xLeft, y, width, built.height);
672
- built.block.pageIndex = page.index;
673
- built.block.columnIndex = first.index;
674
- (page.floats ??= []).push(built.block);
858
+ // A gallery page keeps no text room between its bands.
859
+ if (galleryFill)
860
+ for (const col of targetCols)
861
+ col.availableHeight = 0;
862
+ commitFloatBlock(page, first, built, xLeft, y, width);
675
863
  floatsPlaced++;
676
864
  return rest ? { rest } : 'placed';
677
865
  };
@@ -715,11 +903,22 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
715
903
  * is set. A float that does not fit holds up the ones behind it in its
716
904
  * numbering sequence (see `heldBack`), never the other sequence. */
717
905
  const flushFloatsIntoPage = (page) => {
718
- if (pendingFloats.length === 0)
906
+ if (pendingFloats.length === 0) {
907
+ flushSideBoxesIntoPage(page);
719
908
  return;
720
- const textCols = page.columns.filter((c) => c.kind !== 'span');
909
+ }
910
+ // A floated box heading the page goes first: the figures then take
911
+ // the foot under it (a figure set first would claim the foot and push
912
+ // the box on by the text-room rule). Stable: sequences keep their order.
913
+ const headFirst = (f) => (f.callout && f.position === 'top' ? 0 : 1);
914
+ pendingFloats.sort((a, b) => headFirst(a) - headFirst(b));
915
+ const textCols = page.columns.filter((c) => c.kind !== 'span' && c.kind !== 'side');
721
916
  if (textCols.length === 0)
722
917
  return;
918
+ const sideCols = sideColumns(page);
919
+ /** A page-span float on a fresh page takes the band of every column,
920
+ * the side column included. */
921
+ const pageCols = [...textCols, ...sideCols];
723
922
  /** The least reserved text column a float may take (the rest of a
724
923
  * split table: only columns after its previous slice on this page). */
725
924
  const leastReserved = (f) => {
@@ -739,37 +938,62 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
739
938
  }
740
939
  return best;
741
940
  };
742
- for (let progress = true; progress;) {
743
- const before = floatsPlaced;
744
- for (const pageSpanPass of [true, false]) {
745
- let i = 0;
746
- while (i < pendingFloats.length) {
747
- const f = pendingFloats[i];
748
- const isPageSpan = f.span === 'page' && textCols.length > 1;
749
- if (isPageSpan !== pageSpanPass || heldBack(i)) {
750
- i++;
751
- continue;
752
- }
753
- // A page-span rest never shares the page of its previous slice.
754
- if (isPageSpan && f.notBefore?.pageIndex === page.index) {
755
- i++;
756
- continue;
757
- }
758
- let r = 'defer';
759
- for (const pos of positionsFor(f)) {
760
- const col = isPageSpan ? undefined : leastReserved(f);
761
- if (!isPageSpan && !col)
762
- break;
763
- const cols = isPageSpan ? textCols : [col];
764
- r = placeFloatInColumns(page, f, cols, pos, isPageSpan, 'fresh');
765
- if (r !== 'defer')
766
- break;
941
+ // Order of the passes: page-span floats take the outer bands; column
942
+ // floats whose caption goes to the side column reserve it before the
943
+ // waiting side boxes and side floats stack there; the rest follow.
944
+ let sideBoxesFlushed = false;
945
+ for (let stage = 0; stage < 3; stage++) {
946
+ // Stage 0: page-span floats alone, until none is left to place (they
947
+ // take the outer bands of every column, the side column included);
948
+ // stage 1: column floats whose caption goes to the side column (they
949
+ // reserve their band there); then the waiting side boxes; stage 2:
950
+ // the side floats and the other column floats.
951
+ const passes = stage === 0 ? ['page'] : stage === 1 ? ['aside'] : ['side', 'column'];
952
+ if (stage === 2 && !sideBoxesFlushed) {
953
+ flushSideBoxesIntoPage(page);
954
+ sideBoxesFlushed = true;
955
+ }
956
+ for (let progress = true; progress;) {
957
+ const before = floatsPlaced;
958
+ for (const pass of passes) {
959
+ let i = 0;
960
+ while (i < pendingFloats.length) {
961
+ const f = pendingFloats[i];
962
+ const isPageSpan = f.span === 'page' && (textCols.length > 1 || sideCols.length > 0);
963
+ const isSide = !isPageSpan && f.span === 'side' && sideCols.length > 0;
964
+ const kind = isPageSpan ? 'page' : isSide ? 'side' : f.captionSide && sideCols.length > 0 ? 'aside' : 'column';
965
+ if (kind !== pass || heldBack(i)) {
966
+ i++;
967
+ continue;
968
+ }
969
+ // A page-span rest never shares the page of its previous slice.
970
+ if (isPageSpan && f.notBefore?.pageIndex === page.index) {
971
+ i++;
972
+ continue;
973
+ }
974
+ let r = 'defer';
975
+ if (isSide) {
976
+ r = placeFloatInColumns(page, f, [sideCols[0]], 'top', false, 'fresh', false, true);
977
+ }
978
+ else {
979
+ for (const pos of positionsFor(f)) {
980
+ const col = isPageSpan ? undefined : leastReserved(f);
981
+ if (!isPageSpan && !col)
982
+ break;
983
+ const cols = isPageSpan ? pageCols : [col];
984
+ r = placeFloatInColumns(page, f, cols, pos, isPageSpan, 'fresh');
985
+ if (r !== 'defer')
986
+ break;
987
+ }
988
+ }
989
+ i = settle(i, r);
767
990
  }
768
- i = settle(i, r);
769
991
  }
992
+ progress = floatsPlaced > before;
770
993
  }
771
- progress = floatsPlaced > before;
772
994
  }
995
+ if (!sideBoxesFlushed)
996
+ flushSideBoxesIntoPage(page);
773
997
  };
774
998
  /** The keep-together box content block `idx` opens, when it is one that
775
999
  * lays out inline in a column of the current band (`span: 'column'`,
@@ -787,7 +1011,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
787
1011
  if (placement !== 'here')
788
1012
  return null;
789
1013
  const page = doc.pages[cursor.pageIndex];
790
- if (span === 'page' && bandColumns(page, currentBand(page, cursor)).length > 1)
1014
+ if (span === 'page' && multiColumnBand(page))
1015
+ return null;
1016
+ if (span === 'side' && sideColumnOf(page, currentBand(page, cursor)))
791
1017
  return null;
792
1018
  const L = makeCalloutLayouter(idx, plan, style);
793
1019
  const heights = new Map();
@@ -859,13 +1085,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
859
1085
  // itself, which cuts the band under the text and sets the figure
860
1086
  // there, above the box (`placeSpanFloatsAtCut`) — its only slot here
861
1087
  // would be the band's foot, under the box.
862
- if (preferTop && f.span === 'page' && bandColumns(page, currentBand(page, cursor)).length > 1) {
1088
+ if (preferTop && f.span === 'page' && multiColumnBand(page)) {
863
1089
  i++;
864
1090
  continue;
865
1091
  }
866
1092
  if (preferTop)
867
1093
  slots = [...slots.filter((s) => s.position === 'top'), ...slots.filter((s) => s.position !== 'top')];
868
1094
  for (const slot of slots) {
1095
+ if (slot.side) {
1096
+ r = placeFloatInColumns(page, f, slot.cols, slot.position, false, 'strict', false, true, slot.refY);
1097
+ if (r !== 'defer')
1098
+ break;
1099
+ continue;
1100
+ }
869
1101
  if (box && slotStarvesBox(page, f, slot, box, preferTop))
870
1102
  continue;
871
1103
  r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict', preferTop);
@@ -890,10 +1122,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
890
1122
  if (!style)
891
1123
  return false;
892
1124
  const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
893
- if (span !== 'page' || placement === 'fixed')
1125
+ if (span !== 'page' || placement !== 'here')
894
1126
  return false;
895
- const page = doc.pages[cursor.pageIndex];
896
- return bandColumns(page, currentBand(page, cursor)).length > 1;
1127
+ return multiColumnBand(doc.pages[cursor.pageIndex]);
897
1128
  };
898
1129
  /** Chapter barrier: place every pending float before the boundary — in
899
1130
  * the current page's free slots, then on fresh pages opened ahead of it
@@ -902,7 +1133,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
902
1133
  const drainPendingFloats = () => {
903
1134
  tryPlacePendingFloatsOnCurrentPage();
904
1135
  let guard = 0;
905
- while (pendingFloats.length > 0 && guard++ < 1000) {
1136
+ while ((pendingFloats.length > 0 || pendingSideBoxes.length > 0) && guard++ < 1000) {
906
1137
  const before = floatsPlaced;
907
1138
  const startPageIndex = cursor.pageIndex;
908
1139
  do {
@@ -1252,7 +1483,18 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1252
1483
  if (c.type !== 'directive' && !isMarkerBlock(c))
1253
1484
  realAt.push(k);
1254
1485
  });
1255
- const layoutRange = (from, to, width, frameId, continuation) => {
1486
+ // `:::columns` groups among the children, as [start marker, end marker]
1487
+ // positions: a split never cuts inside one.
1488
+ const groups = [];
1489
+ children.forEach((c, k) => {
1490
+ if (c.type === 'containerStart' && c.containerName === 'columns') {
1491
+ let e = k + 1;
1492
+ while (e < children.length && !(children[e].type === 'containerEnd' && children[e].containerName === 'columns' && children[e].containerId === c.containerId))
1493
+ e++;
1494
+ groups.push([k, e]);
1495
+ }
1496
+ });
1497
+ const layoutRange = (from, to, width, frameId, continuation, mirrored = false) => {
1256
1498
  let n = 0;
1257
1499
  // A cut inside child `to.child` includes that child (its first
1258
1500
  // `to.line` lines); a cut at a child's head excludes it.
@@ -1272,9 +1514,20 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1272
1514
  paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
1273
1515
  ...(from.line > 0 ? { lineFrom: from.line } : {}),
1274
1516
  ...(to.line > 0 ? { lineTo: to.line } : {}),
1517
+ mirrored,
1275
1518
  });
1276
1519
  };
1277
- return { children, childBase: startIdx + 1, realAt, end: { child: children.length, line: 0 }, layoutRange };
1520
+ return { children, childBase: startIdx + 1, realAt, end: { child: children.length, line: 0 }, layoutRange, groups };
1521
+ };
1522
+ /** Whether a page is a verso of mirrored margins (an `'outer'` corner
1523
+ * icon hangs on the left there). */
1524
+ const mirroredOf = (page) => resolved.page.margins.mirror === true && (page.index + pageIndexOffset + 1) % 2 === 0;
1525
+ /** Used bottom of a band, the float-only side column's stack included:
1526
+ * a span block cuts under the side boxes already set there. */
1527
+ const bandUsedBottomWithSide = (page, cols) => {
1528
+ const side = sideColumnOf(page, cols[0]?.band ?? 0);
1529
+ const withSide = side && sideUsedBottom(side) > side.bbox.y + 0.5 ? [...cols, side] : cols;
1530
+ return bandUsedBottom(withSide);
1278
1531
  };
1279
1532
  /** The longest leading fragment of the box from cut `from` on whose box
1280
1533
  * is at most `roomPx` tall. Cuts fall between children or between the
@@ -1284,8 +1537,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1284
1537
  * candidates (box bottom = content bottom at the cut + the box's tail
1285
1538
  * below its last child); the deepest candidate that fits is laid out
1286
1539
  * for real and taken when it truly fits. `null` when none does. */
1287
- const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation, minLines) => {
1288
- const full = L.layoutRange(from, L.end, width, frameId, continuation);
1540
+ const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation, minLines, mirrored = false) => {
1541
+ const full = L.layoutRange(from, L.end, width, frameId, continuation, mirrored);
1289
1542
  const lastChild = full.children[full.children.length - 1];
1290
1543
  if (!lastChild)
1291
1544
  return null;
@@ -1313,13 +1566,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1313
1566
  }
1314
1567
  }
1315
1568
  const min = Math.max(1, minLines);
1569
+ const insideGroup = (cut) => L.groups.some(([gs, ge]) => (cut.line === 0 ? gs < cut.child && cut.child <= ge : gs < cut.child && cut.child < ge));
1316
1570
  const viable = candidates
1317
- .filter((c) => c.headLines >= min && totalLines - c.headLines >= min)
1571
+ .filter((c) => c.headLines >= min && totalLines - c.headLines >= min && !insideGroup(c.cut))
1318
1572
  .sort((a, b) => b.bottom - a.bottom);
1319
1573
  for (const c of viable) {
1320
1574
  if (c.bottom + tail > roomPx + 0.01)
1321
1575
  continue;
1322
- const result = L.layoutRange(from, c.cut, width, frameId, continuation);
1576
+ const result = L.layoutRange(from, c.cut, width, frameId, continuation, mirrored);
1323
1577
  if (result.totalHeight <= roomPx + 0.01)
1324
1578
  return { to: c.cut, result };
1325
1579
  }
@@ -1456,7 +1710,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1456
1710
  i++;
1457
1711
  continue;
1458
1712
  }
1459
- const cutY = gridUp(page, bandUsedBottom(cols));
1713
+ const cutY = gridUp(page, bandUsedBottomWithSide(page, cols));
1460
1714
  const spacing = cols.some((c) => c.blocks.length > 0) ? floatGapPx : 0;
1461
1715
  const need = needFor(spacing, measure.height, floatGapPx);
1462
1716
  const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
@@ -1496,7 +1750,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1496
1750
  for (;;) {
1497
1751
  let page = doc.pages[cursor.pageIndex];
1498
1752
  const continuation = part > 0;
1499
- const layoutAt = (width) => L.layoutRange(from, L.end, width, frameId, continuation);
1753
+ const layoutAt = (width) => L.layoutRange(from, L.end, width, frameId, continuation, mirroredOf(page));
1500
1754
  const result = layoutAt(page.contentArea.width);
1501
1755
  /** Where the box would cut the current band, and whether it fits (room
1502
1756
  * is measured against the columns' TRUE bottoms — a capped band keeps
@@ -1507,7 +1761,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1507
1761
  const cols = bandColumns(page, currentBand(page, cursor));
1508
1762
  if (cols.length === 0 || (requireLevel && !levelForBox(cols)))
1509
1763
  return null;
1510
- const cutY = gridUp(page, bandUsedBottom(cols));
1764
+ const cutY = gridUp(page, bandUsedBottomWithSide(page, cols));
1511
1765
  const bandHasContent = cols.some((c) => c.blocks.length > 0);
1512
1766
  const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
1513
1767
  const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
@@ -1611,14 +1865,18 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1611
1865
  }
1612
1866
  if (!action && forceHere && fit) {
1613
1867
  action = { kind: 'whole', fit, need: fit.need, result };
1614
- (doc.warnings ??= []).push({
1615
- kind: 'calloutOverflow',
1616
- pageIndex: cursor.pageIndex,
1617
- columnIndex: cursor.columnIndex,
1618
- sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
1619
- sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
1620
- overflowPx: Math.max(0, fit.spacing + result.totalHeight - fit.roomPx),
1621
- });
1868
+ // A box that fills its band to the last grid line is not an overflow.
1869
+ const overflowPx = Math.max(0, fit.spacing + result.totalHeight - fit.roomPx);
1870
+ if (overflowPx > 0.5) {
1871
+ (doc.warnings ??= []).push({
1872
+ kind: 'calloutOverflow',
1873
+ pageIndex: cursor.pageIndex,
1874
+ columnIndex: cursor.columnIndex,
1875
+ sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
1876
+ sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
1877
+ overflowPx,
1878
+ });
1879
+ }
1622
1880
  }
1623
1881
  if (action) {
1624
1882
  if (capActive) {
@@ -1899,6 +2157,92 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1899
2157
  for (let i = startIdx + 1; i <= plan.endIdx; i++)
1900
2158
  enqueueFloatsFor(i);
1901
2159
  };
2160
+ /**
2161
+ * Set a `span: 'side'` box in the side column of the current band: laid
2162
+ * out at the side column's width and stacked under what the column holds,
2163
+ * no higher than the flow's current position (beside the text it
2164
+ * interrupts), consuming the column's free height like a side float. A
2165
+ * box the rest of the column cannot hold waits for the side column of the
2166
+ * next page the flow opens (`flushSideBoxesIntoPage`), where it is set
2167
+ * anyway — overflowing — when even an empty column cannot hold it. The
2168
+ * frame and its children leave the flow into `page.floats`, as a fixed
2169
+ * box does. Returns false when the page has no side column: the box then
2170
+ * lays out inline.
2171
+ */
2172
+ const trySideBox = (page, side, box, refY, mode) => {
2173
+ const { startIdx, plan, style } = box;
2174
+ const L = makeCalloutLayouter(startIdx, plan, style);
2175
+ const frameId = `block-${blockIdCounter++}`;
2176
+ const result = L.layoutRange(CUT_START, L.end, side.bbox.width, frameId, false, mirroredOf(page));
2177
+ const used = sideUsedBottom(side);
2178
+ const raw = Math.max(used, refY ?? used);
2179
+ const gridUpSide = (v) => page.contentArea.y + Math.ceil((v - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
2180
+ let y = gridUpSide(raw);
2181
+ const below = Math.max(result.marginBottomPx, floatGapPx);
2182
+ let need = y - used + result.totalHeight + below;
2183
+ // The gap under the box is owed only to what follows: a box whose foot
2184
+ // lands on the column's foot needs none.
2185
+ const fits = () => need <= side.availableHeight + 0.01 || y - used + result.totalHeight <= side.availableHeight + 0.01;
2186
+ if (!fits() && refY !== undefined && raw > used + 0.5) {
2187
+ // Beside its text the box runs off the column: it slides up — as far
2188
+ // as the stack above allows — to the lowest position that fits, its
2189
+ // foot on the column's foot (the bottom-aligned marginal box).
2190
+ const foot = side.bbox.y + side.bbox.height;
2191
+ const fit = page.contentArea.y + Math.floor((foot - result.totalHeight - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
2192
+ if (fit >= used - 0.01) {
2193
+ y = fit;
2194
+ need = y - used + result.totalHeight + below;
2195
+ }
2196
+ }
2197
+ if (!fits()) {
2198
+ if (mode === 'strict' || used > side.bbox.y + 0.5)
2199
+ return false;
2200
+ }
2201
+ const frame = result.frame;
2202
+ stampCalloutSource(frame, startIdx, plan);
2203
+ frame.pageIndex = page.index;
2204
+ frame.columnIndex = side.index;
2205
+ offsetCalloutToAbsolute(result, side.bbox.x, y);
2206
+ const floats = (page.floats ??= []);
2207
+ doc.blocks.push(frame);
2208
+ floats.push(frame);
2209
+ for (const child of result.children) {
2210
+ child.pageIndex = frame.pageIndex;
2211
+ child.columnIndex = frame.columnIndex;
2212
+ doc.blocks.push(child);
2213
+ floats.push(child);
2214
+ }
2215
+ side.availableHeight = Math.max(0, side.availableHeight - need);
2216
+ for (let i = startIdx + 1; i <= plan.endIdx; i++)
2217
+ enqueueFloatsFor(i);
2218
+ return true;
2219
+ };
2220
+ const placeCalloutSide = (startIdx, plan, style) => {
2221
+ const page = doc.pages[cursor.pageIndex];
2222
+ const side = sideColumnOf(page, currentBand(page, cursor));
2223
+ if (!side)
2224
+ return false;
2225
+ const curCol = currentColumn(doc, cursor);
2226
+ const refY = curCol.bbox.y + (curCol.bbox.height - curCol.availableHeight) + (curCol.blocks.length > 0 ? pendingSpacing : 0);
2227
+ if (!trySideBox(page, side, { startIdx, plan, style }, refY, 'strict')) {
2228
+ pendingSideBoxes.push({ startIdx, plan, style });
2229
+ }
2230
+ return true;
2231
+ };
2232
+ /** Set the waiting side boxes in the side column of a freshly opened
2233
+ * page, in order; one that does not fit an empty column is set anyway. */
2234
+ const flushSideBoxesIntoPage = (page) => {
2235
+ if (pendingSideBoxes.length === 0)
2236
+ return;
2237
+ const side = sideColumnOf(page, 0);
2238
+ if (!side)
2239
+ return;
2240
+ while (pendingSideBoxes.length > 0) {
2241
+ if (!trySideBox(page, side, pendingSideBoxes[0], undefined, 'fresh'))
2242
+ break;
2243
+ pendingSideBoxes.shift();
2244
+ }
2245
+ };
1902
2246
  /**
1903
2247
  * Place a `:::callout` inline at the current column width as one atomic
1904
2248
  * unit: the frame block followed by its children in the same column. The
@@ -1923,22 +2267,35 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1923
2267
  const firstFrameId = `block-${blockIdCounter++}`;
1924
2268
  const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
1925
2269
  const L = makeCalloutLayouter(startIdx, plan, style);
1926
- const layoutAt = (width) => L.layoutRange(CUT_START, L.end, width, firstFrameId, false);
2270
+ const layoutAt = (width) => L.layoutRange(CUT_START, L.end, width, firstFrameId, false, mirroredOf(doc.pages[cursor.pageIndex]));
1927
2271
  // Fixed boxes leave the flow entirely.
1928
2272
  if (placement === 'fixed') {
1929
2273
  placeCalloutFixed(startIdx, plan, style, layoutAt);
1930
2274
  return undefined;
1931
2275
  }
2276
+ // Floated boxes leave it too: they take the first free band after
2277
+ // this point (the foot of the current page, or the head / foot of a
2278
+ // page the flow opens later) and the text after them fills the page.
2279
+ // A side box always stacks beside the text it interrupts.
2280
+ if ((placement === 'top' || placement === 'bottom') && span !== 'side') {
2281
+ enqueueCalloutFloat(startIdx, plan, style, L, placement, span);
2282
+ return undefined;
2283
+ }
1932
2284
  // Page-span boxes split a multi-column page into column bands (stage 1
1933
2285
  // of span blocks). Floating placements keep the inline fallback.
1934
2286
  {
1935
2287
  const page = doc.pages[cursor.pageIndex];
1936
2288
  if (span === 'page'
1937
2289
  && placement === 'here'
1938
- && bandColumns(page, currentBand(page, cursor)).length > 1
2290
+ && multiColumnBand(page)
1939
2291
  && placeCalloutSpan(startIdx, plan, style, L, firstFrameId)) {
1940
2292
  return undefined;
1941
2293
  }
2294
+ // Side boxes stack in the float-only side column beside the text
2295
+ // (whatever their placement: a side box never floats to a band).
2296
+ if (span === 'side' && placeCalloutSide(startIdx, plan, style)) {
2297
+ return undefined;
2298
+ }
1942
2299
  }
1943
2300
  const splittable = !style.keepTogether;
1944
2301
  let from = CUT_START;
@@ -1949,7 +2306,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1949
2306
  for (;;) {
1950
2307
  let curCol = currentColumn(doc, cursor);
1951
2308
  const continuation = part > 0;
1952
- const result = L.layoutRange(from, L.end, curCol.bbox.width, frameId, continuation);
2309
+ const result = L.layoutRange(from, L.end, curCol.bbox.width, frameId, continuation, mirroredOf(doc.pages[cursor.pageIndex]));
1953
2310
  // Column balancing: a box closing its column takes the column's gap
1954
2311
  // above it (the trailing-callout lever), so its foot lands on the
1955
2312
  // last grid slot — level with the column beside it. Any fragment
@@ -1965,7 +2322,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1965
2322
  // The (rest of the) box does not fit the column: a splittable box
1966
2323
  // leaves the head that fits here…
1967
2324
  if (splittable)
1968
- fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation, style.splitMinLines);
2325
+ fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation, style.splitMinLines, mirroredOf(doc.pages[cursor.pageIndex]));
1969
2326
  // …otherwise it moves whole to the next column — also out of an
1970
2327
  // EMPTY column that float bands or a band cap have cut short, when
1971
2328
  // a full column would hold it (bounded, so a run of short columns
@@ -2260,6 +2617,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2260
2617
  const isContainerTail = paragraphContainer !== undefined
2261
2618
  && nextRaw?.type === 'containerEnd'
2262
2619
  && nextRaw.containerId === paragraphContainer.id;
2620
+ // A page-span box that left no band under it keeps the cursor on its
2621
+ // (full) span column. Move on before measuring: a block measured at the
2622
+ // span width and placed in the next page's text column would carry
2623
+ // lines wider than that column.
2624
+ if (currentColumn(doc, cursor).kind === 'span') {
2625
+ pendingSpacing = 0;
2626
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2627
+ }
2263
2628
  // Measure against the current column width. `null` means there is nothing
2264
2629
  // to place inline (empty text, unknown resource id, floated resource).
2265
2630
  const col = currentColumn(doc, cursor);
@@ -2313,7 +2678,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2313
2678
  segments: [],
2314
2679
  isLastLine: true,
2315
2680
  }];
2316
- const spacingBefore = pendingSpacing;
2681
+ // An inline resource keeps the float gap (a line) above it, as a
2682
+ // float would, unless the block before asked for more.
2683
+ const spacingBefore = Math.max(pendingSpacing, floatGapPx);
2317
2684
  enterBand(blockIdx, 0);
2318
2685
  placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, geomResolved, contentArea, pageWidthPx, pageHeightPx);
2319
2686
  enterBand(blockIdx, 0);
@@ -2383,7 +2750,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2383
2750
  const nextIsHeading = nextBlock?.type === 'heading';
2384
2751
  // The contents (`:::toc`) keep their own rhythm: an entry set as a list
2385
2752
  // item is not a list tail to realign the text after it.
2386
- const shouldSnapToGrid = rawBlock.toc === undefined && ((vdtType === 'heading' && !nextIsHeading) ||
2753
+ const shouldSnapToGrid = rawBlock.toc === undefined && ((vdtType === 'heading' && !nextIsHeading && resolved.headings.snapToGrid) ||
2387
2754
  (vdtType === 'listItem' && !nextIsListItem) ||
2388
2755
  (vdtType === 'paragraph' && isContainerTail) ||
2389
2756
  vdtType === 'mathDisplay');