postext 0.3.45 → 0.3.47

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.
@@ -84,7 +84,14 @@ function measureLooseParagraph(rawBlock, blockIdx, columnWidth, ctx, styleOverri
84
84
  * @internal Exposed for tests only; use `buildDocument`.
85
85
  */
86
86
  export function buildDocumentPass(content, config, cache, options, hints = {}) {
87
- const { balanceExtraPx, balanceLooseness, balanceLooseBudget, bandCaps } = hints;
87
+ const { balanceExtraPx, balanceLooseness, balanceLooseBudget, bandCaps, captionUnder } = hints;
88
+ const captionUnderProposals = new Set();
89
+ /** Side columns whose foot a figure's side caption has cut, by the figure. */
90
+ const asideCutBy = new WeakMap();
91
+ /** A side column that does not run the page's whole content height: a
92
+ * band above (a page-span box, a top float band) or a caption below has
93
+ * shortened it, so what does not fit it may still fit a full column. */
94
+ const shortSideColumn = (page, col) => col.bbox.height < page.contentArea.height - 0.5;
88
95
  const resolved = resolveAllConfig(config);
89
96
  // Tracking rungs a loose paragraph may climb: none, then 5‰ steps up to
90
97
  // the cap (`headings.balancing.maxTracking`, thousandths of an em), the
@@ -243,7 +250,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
243
250
  pendingFloats.push(stamped);
244
251
  }
245
252
  };
246
- /** Floated boxes (`placement: 'top' | 'bottom'`, `span` column / page):
253
+ /** Floated boxes (`placement: 'auto' | 'top' | 'bottom'`, `span` column / page):
247
254
  * a `:::callout` that leaves the flow like a resource and takes the
248
255
  * first free band after the point it occurs at — the foot of the
249
256
  * current page, or the head / foot of a page the flow opens later —
@@ -604,7 +611,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
604
611
  // The caption beside the figure, in the band's side column.
605
612
  let aside;
606
613
  let sideCol;
607
- if (f.captionSide && !pageSpan && !side && !rotated) {
614
+ if (f.captionSide && !pageSpan && !side && !rotated && !captionUnder?.has(f.resourceId)) {
608
615
  const sc = sideColumnOf(page, first.band ?? 0);
609
616
  if (sc && sc.bbox.width > 0.5) {
610
617
  sideCol = sc;
@@ -639,6 +646,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
639
646
  }
640
647
  }
641
648
  }
649
+ // A bottom float's caption cuts the side column's foot; when the side
650
+ // stack already reaches into that band (a box set beside the text
651
+ // above), the caption goes under the figure instead of behind the box.
652
+ if (aside && sideCol && position === 'bottom' && measure.asideHeight !== undefined) {
653
+ const captionTop = y + measure.height - measure.asideHeight - floatGapPx;
654
+ if (sideUsedBottom(sideCol) > captionTop + 0.5) {
655
+ const plain = measureFloat(f.resourceId, width, slice, rotated);
656
+ if (plain) {
657
+ ({ need, y } = measureFloatBand(position, plain, targetCols, page.contentArea, baselineGrid, floatGapPx, bottomOf));
658
+ return { need, y, measure: plain, slice, width, xLeft, rotated };
659
+ }
660
+ }
661
+ }
642
662
  return { need, y, measure, slice, width, xLeft, rotated, ...(aside ? { aside, sideCol } : {}) };
643
663
  };
644
664
  /** Free height `col` keeps for the flow once a float band `probe` is
@@ -704,6 +724,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
704
724
  if (need > first.availableHeight + 0.01) {
705
725
  if (mode === 'strict' || sideUsedBottom(first) > first.bbox.y + 0.5)
706
726
  return 'defer';
727
+ // An empty column shortened by a band above it (a page-span box,
728
+ // a top float) is no measure of the float: when a full column
729
+ // would hold it, it waits for one instead of overflowing this one.
730
+ if (shortSideColumn(page, first) && measure.height <= page.contentArea.height + 0.01)
731
+ return 'defer';
732
+ // Overflowing a column a side caption has shortened: the next pass
733
+ // sets that caption under its figure and gives the column back.
734
+ const cutBy = asideCutBy.get(first);
735
+ if (cutBy !== undefined)
736
+ captionUnderProposals.add(cutBy);
707
737
  }
708
738
  const built = buildFloatBlock(f.resourceId, xLeft, width, slice);
709
739
  if (!built)
@@ -824,6 +854,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
824
854
  const cut = Math.max(0, sideCol.bbox.y + sideCol.bbox.height - (y + built.height - measure.asideHeight - floatGapPx));
825
855
  sideCol.bbox.height = Math.max(0, sideCol.bbox.height - cut);
826
856
  sideCol.availableHeight = Math.max(0, sideCol.availableHeight - cut);
857
+ if (cut > 0.5)
858
+ asideCutBy.set(sideCol, f.resourceId);
827
859
  }
828
860
  }
829
861
  for (const col of targetCols) {
@@ -2197,6 +2229,15 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2197
2229
  if (!fits()) {
2198
2230
  if (mode === 'strict' || used > side.bbox.y + 0.5)
2199
2231
  return false;
2232
+ // A box that a full side column would hold waits for one rather than
2233
+ // overflowing an empty column a band above has shortened.
2234
+ if (shortSideColumn(page, side) && result.totalHeight <= page.contentArea.height + 0.01)
2235
+ return false;
2236
+ // Same as a side float: a box that overflows a caption-shortened
2237
+ // column asks for that caption to go under its figure.
2238
+ const cutBy = asideCutBy.get(side);
2239
+ if (cutBy !== undefined)
2240
+ captionUnderProposals.add(cutBy);
2200
2241
  }
2201
2242
  const frame = result.frame;
2202
2243
  stampCalloutSource(frame, startIdx, plan);
@@ -2277,7 +2318,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2277
2318
  // this point (the foot of the current page, or the head / foot of a
2278
2319
  // page the flow opens later) and the text after them fills the page.
2279
2320
  // A side box always stacks beside the text it interrupts.
2280
- if ((placement === 'top' || placement === 'bottom') && span !== 'side') {
2321
+ if ((placement === 'auto' || placement === 'top' || placement === 'bottom') && span !== 'side') {
2281
2322
  enqueueCalloutFloat(startIdx, plan, style, L, placement, span);
2282
2323
  return undefined;
2283
2324
  }
@@ -3284,7 +3325,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
3284
3325
  buildHeadersAndFooters(doc, resourceById);
3285
3326
  doc.converged = true;
3286
3327
  doc.iterationCount = 1;
3287
- return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome };
3328
+ return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome, captionUnderProposals };
3288
3329
  }
3289
3330
  /** Passes a document printing its own contents gets at most, beyond the
3290
3331
  * first, for the page labels it prints to settle. */
@@ -3363,16 +3404,31 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0) {
3363
3404
  const passOptions = onProgress
3364
3405
  ? { ...options, onProgress: (p) => onProgress({ ...p, pass: passIndex }) }
3365
3406
  : options;
3407
+ // Side captions moved under their figure (see `PassHints.captionUnder`):
3408
+ // once a pass asks for one, every later pass keeps it.
3409
+ const captionUnder = new Set();
3366
3410
  const runPass = (hints) => {
3367
3411
  passIndex++;
3368
3412
  const started = onPass ? now() : 0;
3369
- const result = buildDocumentPass(content, config, cache, passOptions, hints);
3413
+ const result = buildDocumentPass(content, config, cache, passOptions, { ...hints, captionUnder });
3414
+ for (const id of result.captionUnderProposals)
3415
+ captionUnder.add(id);
3370
3416
  onPass?.({ pass: passIndex, tocRound, ms: now() - started, pages: result.doc.pages.length });
3371
3417
  return result;
3372
3418
  };
3373
3419
  // The first pass is a pass too: let a cancel land before the caps run.
3374
- const initial = runPass();
3420
+ let initial = runPass();
3375
3421
  yield;
3422
+ // A side box or side float spilled over a figure's side caption: place
3423
+ // again with that caption under the figure (a moved caption can free or
3424
+ // crowd another side column, hence the short loop).
3425
+ for (let round = 0; round < 3 && initial.captionUnderProposals.size > 0; round++) {
3426
+ const before = captionUnder.size;
3427
+ initial = runPass();
3428
+ yield;
3429
+ if (captionUnder.size === before)
3430
+ break;
3431
+ }
3376
3432
  // --- Band caps (page-span blocks mid-page) -----------------------------
3377
3433
  // A span block that arrived in an uneven band proposes a cap; the driver
3378
3434
  // re-places the document with it (and grows / drops caps whose band
@@ -3529,6 +3585,7 @@ function* buildDocumentBalanced(content, config, cache, options, tocRound = 0) {
3529
3585
  spanPlacedInBand: mergeSet(best.spanPlacedInBand, next.spanPlacedInBand),
3530
3586
  bandCapsApplied: mergeSet(best.bandCapsApplied, next.bandCapsApplied),
3531
3587
  looseOutcome: mergeMap(best.looseOutcome, next.looseOutcome),
3588
+ captionUnderProposals: new Set([...best.captionUnderProposals, ...next.captionUnderProposals]),
3532
3589
  };
3533
3590
  };
3534
3591
  /**