postext 0.3.34 → 0.3.36

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 (36) hide show
  1. package/dist/__tests__/columnBalancing.test.js +28 -0
  2. package/dist/__tests__/columnBalancing.test.js.map +1 -1
  3. package/dist/__tests__/pipeline/calloutOverflow.test.js +3 -1
  4. package/dist/__tests__/pipeline/calloutOverflow.test.js.map +1 -1
  5. package/dist/__tests__/pipeline/floatFirstSlot.test.js +34 -3
  6. package/dist/__tests__/pipeline/floatFirstSlot.test.js.map +1 -1
  7. package/dist/__tests__/pipeline/segmentBalancing.test.d.ts +2 -0
  8. package/dist/__tests__/pipeline/segmentBalancing.test.d.ts.map +1 -0
  9. package/dist/__tests__/pipeline/segmentBalancing.test.js +70 -0
  10. package/dist/__tests__/pipeline/segmentBalancing.test.js.map +1 -0
  11. package/dist/__tests__/pipeline/spanBlocks.test.js +9 -8
  12. package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
  13. package/dist/__tests__/pipeline/tableSplit.test.js +48 -0
  14. package/dist/__tests__/pipeline/tableSplit.test.js.map +1 -1
  15. package/dist/defaults/pdfGeneration.d.ts.map +1 -1
  16. package/dist/defaults/pdfGeneration.js +7 -0
  17. package/dist/defaults/pdfGeneration.js.map +1 -1
  18. package/dist/pipeline/build.d.ts.map +1 -1
  19. package/dist/pipeline/build.js +406 -129
  20. package/dist/pipeline/build.js.map +1 -1
  21. package/dist/pipeline/columnBalancing.d.ts +27 -5
  22. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  23. package/dist/pipeline/columnBalancing.js +48 -9
  24. package/dist/pipeline/columnBalancing.js.map +1 -1
  25. package/dist/pipeline/floatPlacement.d.ts +8 -0
  26. package/dist/pipeline/floatPlacement.d.ts.map +1 -1
  27. package/dist/pipeline/floatPlacement.js.map +1 -1
  28. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  29. package/dist/pipeline/resourceLayout.js +3 -1
  30. package/dist/pipeline/resourceLayout.js.map +1 -1
  31. package/dist/types.d.ts +8 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/vdt.d.ts +3 -0
  34. package/dist/vdt.d.ts.map +1 -1
  35. package/dist/vdt.js.map +1 -1
  36. package/package.json +1 -1
@@ -22,7 +22,7 @@ import { enumerateCurrentPageSlots, measureFloatBand, columnHasFloatBand, fitsSt
22
22
  import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
23
23
  import { defaultResourceTypes } from '../defaults/resourceTypes';
24
24
  import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
25
- import { totalGapLines, proposeBalanceLines, collectColumnGaps, firstDivergentColumn, MAX_BALANCING_PASSES, balanceKey } from './columnBalancing';
25
+ import { proposeBalanceLines, collectColumnGaps, firstDivergentColumn, gapLinesIn, pageSegments, MAX_BALANCING_PASSES, MAX_BALANCING_PASSES_PER_DOCUMENT, balanceKey } from './columnBalancing';
26
26
  import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, bandCapLinesAroundZone, } from './bandCaps';
27
27
  import { raggedLooseLines } from './raggedLines';
28
28
  /** Tolerance for "does this block fit" checks against a column's free
@@ -364,9 +364,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
364
364
  * decides what becomes of the rows left over — a rest float to continue
365
365
  * on the next page (`'split'`), nothing (`'clip'`) — or, for `'hide'`,
366
366
  * that the table is dropped. Returns null for a figure or a table with
367
- * nothing to cut, which are force-placed like before.
367
+ * nothing to cut, which are force-placed like before. In the `'strict'`
368
+ * (current-page) mode nothing is forced: `'none'` when not even the
369
+ * smallest slice fits the slot.
368
370
  */
369
- const splitTableFloat = (f, width, position, targetCols, contentArea, avail) => {
371
+ const splitTableFloat = (f, width, position, targetCols, contentArea, avail, mode) => {
370
372
  const rowCount = tableRowCount(f.resourceId);
371
373
  if (rowCount === 0)
372
374
  return null;
@@ -390,11 +392,15 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
390
392
  // `avail` is the grid multiple below it, less the gap.
391
393
  const hMax = Math.floor((avail + 0.01) / baselineGrid) * baselineGrid - floatGapPx;
392
394
  let end = planTableSlice(metrics, startRow, hMax - overhead);
393
- // Nothing fits: carry the smallest slice anyway (it overflows, as a
394
- // dominating figure would) rather than stall the queue.
395
395
  const floor = firstBody + 1;
396
- if (end < floor)
396
+ if (end < floor) {
397
+ // Nothing fits. A fresh page carries the smallest slice anyway (it
398
+ // overflows, as a dominating figure would) rather than stall the
399
+ // queue; a slot of the current page is simply not this table's.
400
+ if (mode === 'strict')
401
+ return 'none';
397
402
  end = floor;
403
+ }
398
404
  // A last page holding a row or two under a repeated header reads as a
399
405
  // stranded tail: give the closing slice at least `MIN_TAIL_ROWS` rows by
400
406
  // handing some back from this one (at a breakable edge, not under a
@@ -429,6 +435,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
429
435
  while (end > floor && !(metrics.breakableAfter[end - 1] ?? true));
430
436
  slice = sliceFor(end);
431
437
  }
438
+ if (mode === 'strict' && !fits(slice))
439
+ return 'none';
432
440
  const rest = slice.continues ? { ...f, startRow: end } : undefined;
433
441
  return rest ? { slice, rest } : { slice };
434
442
  };
@@ -483,6 +491,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
483
491
  const { width, xLeft } = probe;
484
492
  let { slice, measure, need, y } = probe;
485
493
  let rest;
494
+ /** The float was cut to this slot (a table slice). */
495
+ let cut = false;
486
496
  if (mode === 'fresh') {
487
497
  let minAvail = Infinity;
488
498
  let anyReserved = false;
@@ -496,12 +506,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
496
506
  return 'defer';
497
507
  if (need > minAvail + 0.01) {
498
508
  // Dominating the band: a table is cut to it.
499
- const split = splitTableFloat(f, width, position, targetCols, page.contentArea, minAvail);
509
+ const split = splitTableFloat(f, width, position, targetCols, page.contentArea, minAvail, 'fresh');
500
510
  if (split === 'skip')
501
511
  return 'skip';
502
- if (split) {
512
+ if (split && split !== 'none') {
503
513
  slice = split.slice;
504
514
  rest = split.rest;
515
+ cut = true;
505
516
  const m = measureFloat(f.resourceId, width, slice);
506
517
  if (!m)
507
518
  return 'skip';
@@ -518,10 +529,44 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
518
529
  return 'defer';
519
530
  continue;
520
531
  }
521
- if (!fitsStrict(need, c, columnHasFloatBand(page, c), minTextPx))
532
+ const hasBand = columnHasFloatBand(page, c);
533
+ if (fitsStrict(need, c, hasBand, minTextPx))
534
+ continue;
535
+ // The head of an empty column: a splittable table is cut to the
536
+ // column and continues in the next slot — the compositor sets a
537
+ // long table beside the text that cites it, not pages later. A
538
+ // table that clips or hides when too tall keeps to fresh pages.
539
+ if (position !== 'top' || pageSpan || c.blocks.length > 0)
540
+ return 'defer';
541
+ if (resolved.tableStyle.overflow !== 'split')
542
+ return 'defer';
543
+ const split = splitTableFloat(f, width, position, targetCols, page.contentArea, c.availableHeight - (hasBand ? minTextPx : 0), 'strict');
544
+ if (!split || split === 'none' || split === 'skip')
545
+ return 'defer';
546
+ slice = split.slice;
547
+ rest = split.rest;
548
+ cut = true;
549
+ const m = measureFloat(f.resourceId, width, slice);
550
+ if (!m)
551
+ return 'skip';
552
+ measure = m;
553
+ ({ need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, (cc) => trueBottom(cc, uncappedBottoms)));
554
+ if (!fitsStrict(need, c, hasBand, minTextPx))
522
555
  return 'defer';
523
556
  }
524
557
  }
558
+ // A slice cut to the head of a column takes the column whole when the
559
+ // rows leave less than the text minimum under it: a line or two of
560
+ // body text stranded under a table reads worse than an empty foot.
561
+ if (cut && position === 'top') {
562
+ const minAvail = Math.min(...targetCols.map((c) => c.availableHeight));
563
+ if (minAvail - need < minTextPx)
564
+ need = Math.max(need, minAvail);
565
+ }
566
+ // The rest goes on after this slice in reading order, never before.
567
+ if (rest) {
568
+ rest = { ...rest, notBefore: { pageIndex: page.index, columnIndex: targetCols[targetCols.length - 1].index } };
569
+ }
525
570
  const built = buildFloatBlock(f.resourceId, xLeft, width, slice);
526
571
  if (!built)
527
572
  return 'skip';
@@ -563,35 +608,61 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
563
608
  return rest ? { rest } : 'placed';
564
609
  };
565
610
  /** Apply a slot outcome to the queue at `i`: drop a placed float, keep a
566
- * deferred one, swap in the rest of a split table. Returns the index to
567
- * continue from. */
611
+ * deferred one, swap in the rest of a split table (offered the next slot
612
+ * right away, so a table cut to one column goes on in the column beside
613
+ * it). Returns the index to continue from. */
568
614
  const settle = (i, r) => {
569
615
  if (r === 'defer')
570
616
  return i + 1;
571
617
  if (typeof r === 'object') {
572
618
  pendingFloats[i] = r.rest;
573
- return i + 1;
619
+ return i;
574
620
  }
575
621
  pendingFloats.splice(i, 1);
576
622
  return i;
577
623
  };
624
+ /** Whether the pending float at `i` must wait: an earlier float of the
625
+ * same numbering sequence (resource type) is still pending. Figures and
626
+ * tables are numbered in first-reference order, and the reader must
627
+ * meet them in that order too — table 3 never lands after table 4, even
628
+ * when 4 would fit a slot 3 does not. Sequences do not hold each other
629
+ * up: a waiting table lets a later figure through. */
630
+ const heldBack = (i) => {
631
+ const typeId = resourceById.get(pendingFloats[i].resourceId)?.typeId;
632
+ for (let j = 0; j < i; j++) {
633
+ if (resourceById.get(pendingFloats[j].resourceId)?.typeId === typeId)
634
+ return true;
635
+ }
636
+ return false;
637
+ };
578
638
  const positionsFor = (f) => f.position === 'auto' ? ['top', 'bottom'] : [f.position];
579
639
  /** Reserve top/bottom bands on a freshly opened page and position as many
580
640
  * pending floats as fit, shrinking the affected columns so body text flows
581
641
  * around them. Full-width (page-span) floats reserve the outermost bands
582
642
  * first, so a later single-column float nests inside the remaining column
583
- * space rather than overlapping a full-width band. A float that does not
584
- * fit never holds up the ones behind it: each takes the first slot it
585
- * fits (numbering follows first-reference order regardless). */
643
+ * space rather than overlapping a full-width band; the passes repeat
644
+ * while they place something, so a page-span float held back behind a
645
+ * column float of its sequence still gets the page's foot once that one
646
+ * is set. A float that does not fit holds up the ones behind it in its
647
+ * numbering sequence (see `heldBack`), never the other sequence. */
586
648
  const flushFloatsIntoPage = (page) => {
587
649
  if (pendingFloats.length === 0)
588
650
  return;
589
651
  const textCols = page.columns.filter((c) => c.kind !== 'span');
590
652
  if (textCols.length === 0)
591
653
  return;
592
- const leastReserved = () => {
593
- let best = textCols[0];
654
+ /** The least reserved text column a float may take (the rest of a
655
+ * split table: only columns after its previous slice on this page). */
656
+ const leastReserved = (f) => {
657
+ const after = f.notBefore && f.notBefore.pageIndex === page.index ? f.notBefore.columnIndex : -1;
658
+ let best;
594
659
  for (const c of textCols) {
660
+ if (c.index <= after)
661
+ continue;
662
+ if (!best) {
663
+ best = c;
664
+ continue;
665
+ }
595
666
  const rb = reservedOf(best);
596
667
  const rc = reservedOf(c);
597
668
  if (rc.top + rc.bottom < rb.top + rb.bottom)
@@ -599,24 +670,36 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
599
670
  }
600
671
  return best;
601
672
  };
602
- for (const pageSpanPass of [true, false]) {
603
- let i = 0;
604
- while (i < pendingFloats.length) {
605
- const f = pendingFloats[i];
606
- const isPageSpan = f.span === 'page' && textCols.length > 1;
607
- if (isPageSpan !== pageSpanPass) {
608
- i++;
609
- continue;
610
- }
611
- let r = 'defer';
612
- for (const pos of positionsFor(f)) {
613
- const cols = isPageSpan ? textCols : [leastReserved()];
614
- r = placeFloatInColumns(page, f, cols, pos, isPageSpan, 'fresh');
615
- if (r !== 'defer')
616
- break;
673
+ for (let progress = true; progress;) {
674
+ const before = floatsPlaced;
675
+ for (const pageSpanPass of [true, false]) {
676
+ let i = 0;
677
+ while (i < pendingFloats.length) {
678
+ const f = pendingFloats[i];
679
+ const isPageSpan = f.span === 'page' && textCols.length > 1;
680
+ if (isPageSpan !== pageSpanPass || heldBack(i)) {
681
+ i++;
682
+ continue;
683
+ }
684
+ // A page-span rest never shares the page of its previous slice.
685
+ if (isPageSpan && f.notBefore?.pageIndex === page.index) {
686
+ i++;
687
+ continue;
688
+ }
689
+ let r = 'defer';
690
+ for (const pos of positionsFor(f)) {
691
+ const col = isPageSpan ? undefined : leastReserved(f);
692
+ if (!isPageSpan && !col)
693
+ break;
694
+ const cols = isPageSpan ? textCols : [col];
695
+ r = placeFloatInColumns(page, f, cols, pos, isPageSpan, 'fresh');
696
+ if (r !== 'defer')
697
+ break;
698
+ }
699
+ i = settle(i, r);
617
700
  }
618
- i = settle(i, r);
619
701
  }
702
+ progress = floatsPlaced > before;
620
703
  }
621
704
  };
622
705
  /** The keep-together box content block `idx` opens, when it is one that
@@ -686,9 +769,20 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
686
769
  const page = doc.pages[cursor.pageIndex];
687
770
  const box = nextBlockIdx !== undefined ? keepTogetherBoxAt(nextBlockIdx) : null;
688
771
  for (let i = 0; i < pendingFloats.length;) {
772
+ if (heldBack(i)) {
773
+ i++;
774
+ continue;
775
+ }
689
776
  const f = pendingFloats[i];
690
777
  let r = 'defer';
691
778
  let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf);
779
+ // The rest of a table cut on this page only takes the slots after
780
+ // its previous slice in reading order (never the foot of the column
781
+ // before it; a page-span rest waits for the next page).
782
+ if (f.notBefore && f.notBefore.pageIndex === page.index) {
783
+ const after = f.notBefore.columnIndex;
784
+ slots = slots.filter((s) => !s.pageSpan && s.cols[0].index > after);
785
+ }
692
786
  // A page-span box comes next: the head of an empty column keeps the
693
787
  // band cuttable under the float (the box then sits below both the
694
788
  // text and the figure), where the referencing column's foot would
@@ -1212,7 +1306,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1212
1306
  for (let i = 0; i < pendingFloats.length;) {
1213
1307
  const f = pendingFloats[i];
1214
1308
  const cols = bandColumns(page, currentBand(page, cursor));
1215
- if (f.span !== 'page' || cols.length < 2 || !((capActiveHere && !placedAny) || levelForBox(cols))) {
1309
+ if (f.span !== 'page' || cols.length < 2 || heldBack(i) || !((capActiveHere && !placedAny) || levelForBox(cols))) {
1216
1310
  i++;
1217
1311
  continue;
1218
1312
  }
@@ -1309,7 +1403,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1309
1403
  // box moving on and the band ending level like a closing one.
1310
1404
  // Only a page-span figure is planned for here; column figures keep
1311
1405
  // the ordinary slots (their level is the band cap's own business).
1312
- const first = pendingFloats.find((f) => f.span === 'page');
1406
+ const first = pendingFloats.find((f, i) => f.span === 'page' && !heldBack(i));
1313
1407
  if (first && !capActive && cap === undefined && bandStart && registeredBand
1314
1408
  && registeredBand.pageIndex === page.index && registeredBand.band === bandBefore) {
1315
1409
  const cols = bandColumns(page, bandBefore).filter((c) => c.bbox.height > 0.5);
@@ -2682,72 +2776,209 @@ export function buildDocument(content, config, cache, options) {
2682
2776
  const bandCaps = bands.bandCaps;
2683
2777
  let passCount = bands.passCount;
2684
2778
  best.doc.iterationCount = passCount;
2685
- /** Hints that produced `best` (replayed by the trailing-cap passes). */
2686
- let bestHints = { bandCaps };
2687
2779
  // --- Column balancing (vertical justification) ------------------------
2688
2780
  // Iteratively re-place the document with extra grid lines above headings
2689
- // until every balanceable column ends flush with the page bottom (or no
2690
- // further adjustment is possible). Each retry recomputes the remaining
2691
- // gaps on the freshly placed document, so split/keep-with-next decisions
2692
- // that shift under the new spacing are accounted for. The best layout
2693
- // (fewest leftover gap lines) always wins — a retry that regresses is
2694
- // discarded.
2781
+ // (and the other levers) until every balanceable column ends flush with
2782
+ // the page bottom, or no further adjustment is possible. Each retry
2783
+ // recomputes the remaining gaps on the freshly placed document, so
2784
+ // split / keep-with-next decisions that shift under the new spacing are
2785
+ // accounted for. The best layout (fewest leftover gap lines) always wins
2786
+ // — a retry that regresses is discarded.
2787
+ //
2788
+ // The pages between explicit breaks (chapter openers, `:::pagebreak`)
2789
+ // are laid out independently of one another — nothing flows across such
2790
+ // a break, so a lever inside one run of pages cannot move a line of any
2791
+ // other. The loop therefore judges every run (a *segment*, see
2792
+ // `pageSegments`) on its own: a pass places the whole document, but each
2793
+ // segment keeps or rejects its share of the levers by its own score,
2794
+ // blacklists its own cascades, plateaus on its own and spends its own
2795
+ // budget of attempts; a rejected segment gets its pages back from the
2796
+ // best pass it had (`spliceSegments`). A book of thirty chapters thus
2797
+ // balances exactly as its chapters would one by one, instead of one
2798
+ // cascade anywhere costing every page of the book a pass.
2695
2799
  const balancing = best.doc.config.headings.balancing;
2696
2800
  if (!balancing.enabled)
2697
2801
  return best.doc;
2698
- let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
2699
- let applied = { lines: new Map(), loose: new Map() };
2700
- const failedLoose = new Set();
2701
- const failedLines = new Set();
2702
- let converged = bestScore === 0;
2802
+ /** Levers accepted so far, over every segment (keyed by content index /
2803
+ * `balanceKey`; each key belongs to exactly one segment). */
2804
+ const applied = { lines: new Map(), loose: new Map() };
2805
+ let segments = [];
2806
+ /** (Re)derive the segments of the current best layout, keeping the
2807
+ * attempts and blacklists of the segment at the same position. */
2808
+ const resetSegments = () => {
2809
+ const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
2810
+ const prev = segments;
2811
+ segments = pageSegments(best.doc.pages.length, best.forcedBreakPages).map((range, i) => {
2812
+ const score = gapLinesIn(gaps, range);
2813
+ const old = prev[i];
2814
+ return {
2815
+ range,
2816
+ bestScore: score,
2817
+ attempts: old?.attempts ?? 0,
2818
+ done: score === 0,
2819
+ stable: score === 0,
2820
+ failedLoose: old?.failedLoose ?? new Set(),
2821
+ failedLines: old?.failedLines ?? new Set(),
2822
+ };
2823
+ });
2824
+ };
2825
+ const hintsFrom = (lines, loose, looseBudget) => {
2826
+ const extraPx = new Map();
2827
+ for (const [idx, n] of lines)
2828
+ if (n > 0)
2829
+ extraPx.set(idx, n * best.doc.baselineGrid);
2830
+ return {
2831
+ balanceExtraPx: extraPx,
2832
+ balanceLooseness: loose,
2833
+ ...(looseBudget ? { balanceLooseBudget: looseBudget } : {}),
2834
+ bandCaps,
2835
+ };
2836
+ };
2837
+ const segmentAt = (pageIndex) => segments.findIndex((s) => pageIndex >= s.range.from && pageIndex <= s.range.to);
2838
+ /** Segment owning each lever key of the current best layout. */
2839
+ const keyOwners = (gaps) => {
2840
+ const owner = new Map();
2841
+ for (const g of gaps) {
2842
+ const si = segmentAt(g.pageIndex);
2843
+ if (si < 0)
2844
+ continue;
2845
+ for (const c of g.candidates)
2846
+ owner.set(balanceKey(c.contentIndex, c.part ?? 0), si);
2847
+ }
2848
+ return owner;
2849
+ };
2850
+ /** First page each content index was placed on. */
2851
+ const pageOfContent = (doc) => {
2852
+ const m = new Map();
2853
+ for (const b of doc.blocks) {
2854
+ if (b.contentIndex !== undefined && b.pageIndex !== undefined && !m.has(b.contentIndex))
2855
+ m.set(b.contentIndex, b.pageIndex);
2856
+ }
2857
+ return m;
2858
+ };
2859
+ const inRange = (r, p) => p !== undefined && p >= r.from && p <= r.to;
2860
+ /** Whether the explicit breaks before / around a segment fell on the same
2861
+ * pages in `next` as in the best layout — the segment's pages then line
2862
+ * up and can be compared or spliced. `before` checks only the pages
2863
+ * ahead of it (an earlier segment's cascade shifts everything after). */
2864
+ const breaksMatch = (next, upTo) => {
2865
+ for (let p = 0; p < upTo; p++) {
2866
+ if (best.forcedBreakPages.has(p) !== next.forcedBreakPages.has(p))
2867
+ return false;
2868
+ }
2869
+ return true;
2870
+ };
2871
+ const startIntact = (next, s) => breaksMatch(next, s.range.from);
2872
+ const wholeIntact = (next, s, last) => {
2873
+ if (!breaksMatch(next, s.range.to + 1))
2874
+ return false;
2875
+ if (next.doc.pages.length <= s.range.to)
2876
+ return false;
2877
+ return !last || next.doc.pages.length === best.doc.pages.length;
2878
+ };
2879
+ /**
2880
+ * The best layout with the pages of `ranges` taken from `next` (whose
2881
+ * breaks line up with it there): pages, the blocks and warnings on them,
2882
+ * and the pass report entries whose block sits on them.
2883
+ */
2884
+ const spliceSegments = (next, ranges) => {
2885
+ const taken = (p) => ranges.some((r) => inRange(r, p));
2886
+ const pages = best.doc.pages.map((pg, i) => (taken(i) ? next.doc.pages[i] : pg));
2887
+ const blocks = [
2888
+ ...best.doc.blocks.filter((b) => !taken(b.pageIndex)),
2889
+ ...next.doc.blocks.filter((b) => taken(b.pageIndex)),
2890
+ ].sort((a, b) => (a.pageIndex ?? -1) - (b.pageIndex ?? -1));
2891
+ const warnings = [
2892
+ ...(best.doc.warnings ?? []).filter((w) => !taken(w.pageIndex)),
2893
+ ...(next.doc.warnings ?? []).filter((w) => taken(w.pageIndex)),
2894
+ ].sort((a, b) => a.pageIndex - b.pageIndex);
2895
+ const doc = { ...best.doc, pages, blocks, ...(warnings.length > 0 ? { warnings } : { warnings: undefined }) };
2896
+ const pageBest = pageOfContent(best.doc);
2897
+ const pageNext = pageOfContent(next.doc);
2898
+ const mergeMap = (a, b) => {
2899
+ const out = new Map();
2900
+ for (const [k, v] of a)
2901
+ if (!taken(pageBest.get(k)))
2902
+ out.set(k, v);
2903
+ for (const [k, v] of b)
2904
+ if (taken(pageNext.get(k)))
2905
+ out.set(k, v);
2906
+ return out;
2907
+ };
2908
+ const mergeSet = (a, b) => {
2909
+ const out = new Set();
2910
+ for (const k of a)
2911
+ if (!taken(pageBest.get(k)))
2912
+ out.add(k);
2913
+ for (const k of b)
2914
+ if (taken(pageNext.get(k)))
2915
+ out.add(k);
2916
+ return out;
2917
+ };
2918
+ return {
2919
+ doc,
2920
+ forcedBreakPages: best.forcedBreakPages,
2921
+ bandCapProposals: mergeMap(best.bandCapProposals, next.bandCapProposals),
2922
+ spanPlacedInBand: mergeSet(best.spanPlacedInBand, next.spanPlacedInBand),
2923
+ bandCapsApplied: mergeSet(best.bandCapsApplied, next.bandCapsApplied),
2924
+ looseOutcome: mergeMap(best.looseOutcome, next.looseOutcome),
2925
+ };
2926
+ };
2703
2927
  /**
2704
- * A rejected pass moved content across a column break somewhere (a
2705
- * split paragraph whose head no longer fits, a float that lost its slot,
2706
- * a lead-in that left with its list…): every page after that point is
2707
- * re-flowed, gaps open elsewhere and a span cap may miss its band. The
2708
- * levers are meant to be local, so contain the damage: find the first
2709
- * column whose content changed and blacklist the levers this pass newly
2710
- * applied there (failing that, on its page; failing that, everywhere), so
2711
- * the next proposal keeps the working levers before it and tries again
2712
- * without the one that cascaded. Returns whether anything was blacklisted.
2928
+ * A rejected segment moved content across a column break somewhere in
2929
+ * its pages (a split paragraph whose head no longer fits, a float that
2930
+ * lost its slot, a lead-in that left with its list…): the pages after
2931
+ * that point re-flow, gaps open elsewhere and a span cap may miss its
2932
+ * band. The levers are meant to be local, so contain the damage: find
2933
+ * the first column of the segment whose content changed and blacklist
2934
+ * the levers this pass newly applied there (failing that, on its page;
2935
+ * failing that, in the whole segment), so the next proposal keeps the
2936
+ * working levers before it and tries again without the one that
2937
+ * cascaded. Returns whether anything was blacklisted.
2713
2938
  */
2714
- const containCascade = (next, proposal) => {
2715
- const div = firstDivergentColumn(best.doc, next.doc);
2939
+ const containCascade = (next, s, newLines, newLoose, gaps) => {
2940
+ const div = firstDivergentColumn(best.doc, next.doc, s.range);
2716
2941
  if (!div)
2717
2942
  return false;
2718
- const newLines = [...proposal.lines].filter(([k, n]) => n > (applied.lines.get(k) ?? 0)).map(([k]) => k);
2719
- const newLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
2720
2943
  if (newLines.length === 0 && newLoose.length === 0)
2721
2944
  return false;
2722
- const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
2723
2945
  const blacklist = (cands) => {
2724
2946
  let hit = false;
2725
2947
  for (const k of newLines)
2726
2948
  if (!cands || cands.has(k)) {
2727
- failedLines.add(k);
2949
+ s.failedLines.add(k);
2728
2950
  hit = true;
2729
2951
  }
2730
2952
  for (const k of newLoose)
2731
2953
  if (!cands || cands.has(k)) {
2732
- failedLoose.add(k);
2954
+ s.failedLoose.add(k);
2733
2955
  hit = true;
2734
2956
  }
2735
2957
  return hit;
2736
2958
  };
2737
- const inColumn = gaps
2738
- .filter((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)
2739
- .flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
2740
- if (blacklist(new Set(inColumn)))
2959
+ const keysOf = (pick) => new Set(gaps.filter(pick).flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0))));
2960
+ if (blacklist(keysOf((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)))
2741
2961
  return true;
2742
- const onPage = gaps
2743
- .filter((g) => g.pageIndex === div.pageIndex)
2744
- .flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
2745
- if (blacklist(new Set(onPage)))
2962
+ if (blacklist(keysOf((g) => g.pageIndex === div.pageIndex)))
2746
2963
  return true;
2747
2964
  return blacklist(null);
2748
2965
  };
2966
+ let balancingPasses = 0;
2749
2967
  const balance = () => {
2750
- while (!converged && passCount < MAX_BALANCING_PASSES) {
2968
+ while (balancingPasses < MAX_BALANCING_PASSES_PER_DOCUMENT) {
2969
+ const active = segments.filter((s) => !s.done);
2970
+ if (active.length === 0)
2971
+ break;
2972
+ const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
2973
+ const owner = keyOwners(gaps);
2974
+ const failedLoose = new Set();
2975
+ const failedLines = new Set();
2976
+ for (const s of segments) {
2977
+ for (const k of s.failedLoose)
2978
+ failedLoose.add(k);
2979
+ for (const k of s.failedLines)
2980
+ failedLines.add(k);
2981
+ }
2751
2982
  const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
2752
2983
  maxLinesPerHeading: balancing.maxLinesPerHeading,
2753
2984
  stretchAfterLists: balancing.stretchAfterLists,
@@ -2760,67 +2991,114 @@ export function buildDocument(content, config, cache, options) {
2760
2991
  failedLoose,
2761
2992
  failedLines,
2762
2993
  });
2763
- if (!proposal.changed) {
2994
+ // The levers newly proposed, by segment; those of a segment that is
2995
+ // done (plateaued, out of attempts) are withdrawn from the pass.
2996
+ const newLines = [];
2997
+ const newLoose = [];
2998
+ for (const [k, n] of proposal.lines) {
2999
+ const cur = applied.lines.get(k) ?? 0;
3000
+ if (n <= cur)
3001
+ continue;
3002
+ const si = owner.get(k);
3003
+ if (si === undefined || segments[si].done) {
3004
+ if (cur > 0)
3005
+ proposal.lines.set(k, cur);
3006
+ else
3007
+ proposal.lines.delete(k);
3008
+ continue;
3009
+ }
3010
+ newLines.push(k);
3011
+ }
3012
+ for (const k of [...proposal.loose.keys()]) {
3013
+ if (applied.loose.has(k))
3014
+ continue;
3015
+ const si = owner.get(k);
3016
+ if (si === undefined || segments[si].done) {
3017
+ proposal.loose.delete(k);
3018
+ proposal.looseBudget.delete(k);
3019
+ continue;
3020
+ }
3021
+ newLoose.push(k);
3022
+ }
3023
+ const trying = new Set([...newLines, ...newLoose].map((k) => owner.get(k)));
3024
+ if (trying.size === 0) {
2764
3025
  // No stretch point can absorb the remaining gaps — stable.
2765
- converged = true;
3026
+ for (const s of active) {
3027
+ s.done = true;
3028
+ s.stable = true;
3029
+ }
2766
3030
  break;
2767
3031
  }
2768
- const extraPx = new Map();
2769
- for (const [idx, n] of proposal.lines)
2770
- extraPx.set(idx, n * best.doc.baselineGrid);
2771
- const hints = {
2772
- balanceExtraPx: extraPx,
2773
- balanceLooseness: proposal.loose,
2774
- balanceLooseBudget: proposal.looseBudget,
2775
- bandCaps,
2776
- };
2777
- const next = runPass(hints);
3032
+ for (const si of trying)
3033
+ segments[si].attempts++;
3034
+ const next = runPass(hintsFrom(proposal.lines, proposal.loose, proposal.looseBudget));
2778
3035
  passCount++;
2779
- // Band caps ride along unchanged; a retry that unsettles one (its span
2780
- // block no longer lands in the capped band, or a levelled closing band
2781
- // spills past its cut) counts as a regression — capped columns without
2782
- // their box are not a layout we may keep.
2783
- const capsDelivered = [...bandCaps.keys()].every((i) => next.spanPlacedInBand.has(i));
2784
- const score = capsDelivered ? totalGapLines(next.doc, next.forcedBreakPages) : Infinity;
2785
- // Loose paragraphs that gained no line at any tracking rung are
2786
- // blacklisted whatever the score did, and never counted as applied.
2787
- // Candidates the pass never tried (their column's budget was met
2788
- // first) stay eligible for a later proposal.
2789
- const newlyLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
2790
- const looseFailed = newlyLoose.filter((k) => next.looseOutcome.get(k) === null);
2791
- for (const k of looseFailed)
2792
- failedLoose.add(k);
2793
- const looseWon = newlyLoose.filter((k) => typeof next.looseOutcome.get(k) === 'number');
2794
- if (score < bestScore) {
2795
- best = next;
2796
- bestHints = hints;
2797
- bestScore = score;
2798
- applied = {
2799
- lines: proposal.lines,
2800
- loose: new Map([...proposal.loose].filter(([k]) => applied.loose.has(k) || looseWon.includes(k))),
2801
- };
2802
- converged = score === 0;
2803
- }
2804
- else {
2805
- // Plateau or regression. First contain a cascade: a lever that
2806
- // moved content across a column break is blacklisted and the loop
2807
- // retries without it. Otherwise retry when a loose candidate was
2808
- // just blacklisted (the proposer falls through to the next one), or
2809
- // when the new loose paragraphs gained their lines yet the layout
2810
- // did not improve (the gain landed elsewhere — drop them too). A
2811
- // pure spacing plateau means we're done: keep the best layout found
2812
- // so far.
2813
- if (containCascade(next, proposal))
3036
+ balancingPasses++;
3037
+ const nextGaps = collectColumnGaps(next.doc, next.forcedBreakPages);
3038
+ const capPage = pageOfContent(best.doc);
3039
+ const accepted = [];
3040
+ for (const si of trying) {
3041
+ const s = segments[si];
3042
+ // An earlier segment's cascade shifted this one's pages: the pass
3043
+ // says nothing about its levers. They are offered again once the
3044
+ // culprit is blacklisted.
3045
+ if (!startIntact(next, s)) {
3046
+ s.attempts--;
2814
3047
  continue;
2815
- if (looseFailed.length > 0 || looseWon.length > 0) {
3048
+ }
3049
+ const keysLines = newLines.filter((k) => owner.get(k) === si);
3050
+ const keysLoose = newLoose.filter((k) => owner.get(k) === si);
3051
+ // Band caps ride along unchanged; a retry that unsettles one of the
3052
+ // segment's (its span block no longer lands in the capped band, or
3053
+ // a levelled closing band spills past its cut) is a regression —
3054
+ // capped columns without their box are not a layout we may keep.
3055
+ const capsDelivered = [...bandCaps.keys()].every((i) => !inRange(s.range, capPage.get(i)) || next.spanPlacedInBand.has(i));
3056
+ const score = capsDelivered && wholeIntact(next, s, si === segments.length - 1)
3057
+ ? gapLinesIn(nextGaps, s.range)
3058
+ : Infinity;
3059
+ // Loose paragraphs that gained no line at any tracking rung are
3060
+ // blacklisted whatever the score did, and never counted as applied.
3061
+ // Candidates the pass never tried (their column's budget was met
3062
+ // first) stay eligible for a later proposal.
3063
+ const looseFailed = keysLoose.filter((k) => next.looseOutcome.get(k) === null);
3064
+ for (const k of looseFailed)
3065
+ s.failedLoose.add(k);
3066
+ const looseWon = keysLoose.filter((k) => typeof next.looseOutcome.get(k) === 'number');
3067
+ if (score < s.bestScore) {
3068
+ for (const k of keysLines)
3069
+ applied.lines.set(k, proposal.lines.get(k));
2816
3070
  for (const k of looseWon)
2817
- failedLoose.add(k);
2818
- continue;
3071
+ applied.loose.set(k, proposal.loose.get(k));
3072
+ s.bestScore = score;
3073
+ if (score === 0) {
3074
+ s.done = true;
3075
+ s.stable = true;
3076
+ }
3077
+ accepted.push(s.range);
2819
3078
  }
2820
- break;
3079
+ else if (!containCascade(next, s, keysLines, keysLoose, gaps)) {
3080
+ // Plateau or regression without a cascade to contain: retry when
3081
+ // a loose candidate was just blacklisted (the proposer falls
3082
+ // through to the next one), or when the new loose paragraphs
3083
+ // gained their lines yet the segment did not improve (the gain
3084
+ // landed elsewhere — drop them too). A pure spacing plateau means
3085
+ // the segment is done: it keeps the best layout found so far.
3086
+ if (looseFailed.length > 0 || looseWon.length > 0) {
3087
+ for (const k of looseWon)
3088
+ s.failedLoose.add(k);
3089
+ }
3090
+ else {
3091
+ s.done = true;
3092
+ }
3093
+ }
3094
+ if (!s.done && s.attempts >= MAX_BALANCING_PASSES)
3095
+ s.done = true;
2821
3096
  }
3097
+ if (accepted.length > 0)
3098
+ best = spliceSegments(next, accepted);
2822
3099
  }
2823
3100
  };
3101
+ resetSegments();
2824
3102
  balance();
2825
3103
  // --- Trailing bands (closing columns cut level) -------------------------
2826
3104
  // Once the balancing levers have settled the earlier pages, level the
@@ -2831,20 +3109,19 @@ export function buildDocument(content, config, cache, options) {
2831
3109
  // block and the cap applies. A short polish round then lets the levers
2832
3110
  // fill what the cut left short (a column ending a line under the cap).
2833
3111
  if (balancing.trailing) {
2834
- const trailing = resolveTrailingCaps(best, bandCaps, (caps) => runPass({ ...bestHints, bandCaps: caps }));
3112
+ const frozen = hintsFrom(applied.lines, applied.loose);
3113
+ const trailing = resolveTrailingCaps(best, bandCaps, (caps) => runPass({ ...frozen, bandCaps: caps }));
2835
3114
  passCount += trailing.passCount;
2836
3115
  if (trailing.result !== best) {
2837
3116
  best = trailing.result;
2838
3117
  for (const [i, cap] of trailing.caps)
2839
3118
  bandCaps.set(i, cap);
2840
- bestHints = { ...bestHints, bandCaps };
2841
- bestScore = totalGapLines(best.doc, best.forcedBreakPages);
2842
- converged = bestScore === 0;
3119
+ resetSegments();
2843
3120
  balance();
2844
3121
  }
2845
3122
  }
2846
3123
  best.doc.iterationCount = passCount;
2847
- best.doc.converged = converged || bestScore === 0;
3124
+ best.doc.converged = segments.every((s) => s.stable || s.bestScore === 0);
2848
3125
  return best.doc;
2849
3126
  }
2850
3127
  //# sourceMappingURL=build.js.map