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.
- package/dist/__tests__/columnBalancing.test.js +28 -0
- package/dist/__tests__/columnBalancing.test.js.map +1 -1
- package/dist/__tests__/pipeline/calloutOverflow.test.js +3 -1
- package/dist/__tests__/pipeline/calloutOverflow.test.js.map +1 -1
- package/dist/__tests__/pipeline/floatFirstSlot.test.js +34 -3
- package/dist/__tests__/pipeline/floatFirstSlot.test.js.map +1 -1
- package/dist/__tests__/pipeline/segmentBalancing.test.d.ts +2 -0
- package/dist/__tests__/pipeline/segmentBalancing.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/segmentBalancing.test.js +70 -0
- package/dist/__tests__/pipeline/segmentBalancing.test.js.map +1 -0
- package/dist/__tests__/pipeline/spanBlocks.test.js +9 -8
- package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
- package/dist/__tests__/pipeline/tableSplit.test.js +48 -0
- package/dist/__tests__/pipeline/tableSplit.test.js.map +1 -1
- package/dist/defaults/pdfGeneration.d.ts.map +1 -1
- package/dist/defaults/pdfGeneration.js +7 -0
- package/dist/defaults/pdfGeneration.js.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +406 -129
- package/dist/pipeline/build.js.map +1 -1
- package/dist/pipeline/columnBalancing.d.ts +27 -5
- package/dist/pipeline/columnBalancing.d.ts.map +1 -1
- package/dist/pipeline/columnBalancing.js +48 -9
- package/dist/pipeline/columnBalancing.js.map +1 -1
- package/dist/pipeline/floatPlacement.d.ts +8 -0
- package/dist/pipeline/floatPlacement.d.ts.map +1 -1
- package/dist/pipeline/floatPlacement.js.map +1 -1
- package/dist/pipeline/resourceLayout.d.ts.map +1 -1
- package/dist/pipeline/resourceLayout.js +3 -1
- package/dist/pipeline/resourceLayout.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +3 -0
- package/dist/vdt.d.ts.map +1 -1
- package/dist/vdt.js.map +1 -1
- package/package.json +1 -1
package/dist/pipeline/build.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
567
|
-
*
|
|
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
|
|
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
|
|
584
|
-
*
|
|
585
|
-
*
|
|
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
|
-
|
|
593
|
-
|
|
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 (
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
|
2690
|
-
// further adjustment is possible
|
|
2691
|
-
// gaps on the freshly placed document, so
|
|
2692
|
-
// that shift under the new spacing are
|
|
2693
|
-
// (fewest leftover gap lines) always wins
|
|
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
|
-
|
|
2699
|
-
|
|
2700
|
-
const
|
|
2701
|
-
|
|
2702
|
-
|
|
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
|
|
2705
|
-
* split paragraph whose head no longer fits, a float that
|
|
2706
|
-
* a lead-in that left with its list…):
|
|
2707
|
-
* re-
|
|
2708
|
-
* levers are meant to be local, so contain the damage: find
|
|
2709
|
-
* column whose content changed and blacklist
|
|
2710
|
-
* applied there (failing that, on its page;
|
|
2711
|
-
*
|
|
2712
|
-
*
|
|
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,
|
|
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
|
|
2738
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
3026
|
+
for (const s of active) {
|
|
3027
|
+
s.done = true;
|
|
3028
|
+
s.stable = true;
|
|
3029
|
+
}
|
|
2766
3030
|
break;
|
|
2767
3031
|
}
|
|
2768
|
-
const
|
|
2769
|
-
|
|
2770
|
-
|
|
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
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
const
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2818
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|