postext 0.3.28 → 0.3.29
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 +78 -1
- package/dist/__tests__/columnBalancing.test.js.map +1 -1
- package/dist/__tests__/pipeline/colonBalancing.test.d.ts +2 -0
- package/dist/__tests__/pipeline/colonBalancing.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/colonBalancing.test.js +124 -0
- package/dist/__tests__/pipeline/colonBalancing.test.js.map +1 -0
- package/dist/__tests__/pipeline/floatFirstSlot.test.js +33 -0
- package/dist/__tests__/pipeline/floatFirstSlot.test.js.map +1 -1
- package/dist/__tests__/pipeline/headingShortBand.test.d.ts +2 -0
- package/dist/__tests__/pipeline/headingShortBand.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/headingShortBand.test.js +89 -0
- package/dist/__tests__/pipeline/headingShortBand.test.js.map +1 -0
- package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
- package/dist/canvas-backend/headerFooter.js +2 -6
- package/dist/canvas-backend/headerFooter.js.map +1 -1
- package/dist/canvas-backend/index.d.ts +1 -1
- package/dist/canvas-backend/index.d.ts.map +1 -1
- package/dist/canvas-backend/renderResourceBlock.d.ts +15 -1
- package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
- package/dist/canvas-backend/renderResourceBlock.js +104 -8
- package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
- package/dist/defaults/headings.d.ts +2 -0
- package/dist/defaults/headings.d.ts.map +1 -1
- package/dist/defaults/headings.js +14 -0
- package/dist/defaults/headings.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +177 -17
- package/dist/pipeline/build.js.map +1 -1
- package/dist/pipeline/columnBalancing.d.ts +30 -5
- package/dist/pipeline/columnBalancing.d.ts.map +1 -1
- package/dist/pipeline/columnBalancing.js +70 -4
- package/dist/pipeline/columnBalancing.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.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 { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES } from './columnBalancing';
|
|
25
|
+
import { totalGapLines, proposeBalanceLines, collectColumnGaps, firstDivergentColumn, MAX_BALANCING_PASSES } from './columnBalancing';
|
|
26
26
|
import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, bandCapLinesAroundZone, } from './bandCaps';
|
|
27
27
|
import { raggedUrlLines } from './raggedUrl';
|
|
28
28
|
export class BuildCancelledError extends Error {
|
|
@@ -303,6 +303,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
303
303
|
* sends single-column floats to the least reserved column). */
|
|
304
304
|
const floatReserved = new Map();
|
|
305
305
|
const reservedOf = (col) => floatReserved.get(col) ?? { top: 0, bottom: 0 };
|
|
306
|
+
/** Content index of the block that first referenced the latest float
|
|
307
|
+
* reserved at the head of each column. */
|
|
308
|
+
const topFloatRefOf = new Map();
|
|
306
309
|
/** Kind of cap the column is under (`undefined` when uncapped). A cap that
|
|
307
310
|
* cannot be attributed to the active band is treated as a span cap — the
|
|
308
311
|
* conservative reading, which keeps the column's bottom off the slot list. */
|
|
@@ -366,6 +369,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
366
369
|
col.bbox.height = Math.max(0, col.bbox.height - need);
|
|
367
370
|
col.availableHeight = Math.max(0, col.availableHeight - need);
|
|
368
371
|
r.top += need;
|
|
372
|
+
topFloatRefOf.set(col, Math.max(topFloatRefOf.get(col) ?? -1, f.firstBlockIdx));
|
|
369
373
|
}
|
|
370
374
|
else {
|
|
371
375
|
const capped = uncappedBottoms.get(col);
|
|
@@ -440,14 +444,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
440
444
|
* cursor (bottom of the referencing column, top / bottom of the next
|
|
441
445
|
* empty columns; the band bottom for page-span floats). Runs before each
|
|
442
446
|
* block is placed, so a float lands in the first gap after its reference. */
|
|
443
|
-
const tryPlacePendingFloatsOnCurrentPage = () => {
|
|
447
|
+
const tryPlacePendingFloatsOnCurrentPage = (preferTop = false) => {
|
|
444
448
|
if (pendingFloats.length === 0)
|
|
445
449
|
return;
|
|
446
450
|
const page = doc.pages[cursor.pageIndex];
|
|
447
451
|
for (let i = 0; i < pendingFloats.length;) {
|
|
448
452
|
const f = pendingFloats[i];
|
|
449
453
|
let r = 'defer';
|
|
450
|
-
|
|
454
|
+
let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf);
|
|
455
|
+
// A page-span box comes next: the head of an empty column keeps the
|
|
456
|
+
// band cuttable under the float (the box then sits below both the
|
|
457
|
+
// text and the figure), where the referencing column's foot would
|
|
458
|
+
// wall the box off the page.
|
|
459
|
+
if (preferTop)
|
|
460
|
+
slots = [...slots.filter((s) => s.position === 'top'), ...slots.filter((s) => s.position !== 'top')];
|
|
461
|
+
for (const slot of slots) {
|
|
451
462
|
r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict');
|
|
452
463
|
if (r !== 'defer')
|
|
453
464
|
break;
|
|
@@ -461,6 +472,20 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
461
472
|
/** Reserve floats on each freshly opened content page. Passed only to the
|
|
462
473
|
* content-flow column advances — parity / force-blank pages never get it. */
|
|
463
474
|
const onNewPage = (page) => flushFloatsIntoPage(page);
|
|
475
|
+
/** Whether content block `idx` opens a callout that will span the page
|
|
476
|
+
* in the current (multi-column) band — the floats placed right before
|
|
477
|
+
* it prefer the head of an empty column. */
|
|
478
|
+
const spanBoxAt = (idx) => {
|
|
479
|
+
const b = contentBlocks[idx];
|
|
480
|
+
if (!b || b.type !== 'containerStart' || b.containerName !== 'callout')
|
|
481
|
+
return false;
|
|
482
|
+
const plan = calloutPlan.get(idx);
|
|
483
|
+
const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
|
|
484
|
+
if (!style || style.span !== 'page' || style.placement === 'fixed')
|
|
485
|
+
return false;
|
|
486
|
+
const page = doc.pages[cursor.pageIndex];
|
|
487
|
+
return bandColumns(page, currentBand(page, cursor)).length > 1;
|
|
488
|
+
};
|
|
464
489
|
/** Chapter barrier: place every pending float before the boundary — in
|
|
465
490
|
* the current page's free slots, then on fresh pages opened ahead of it
|
|
466
491
|
* (each force-places at least one float). The cursor is left on the last
|
|
@@ -531,6 +556,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
531
556
|
let activeCap = null;
|
|
532
557
|
/** True bottoms of capped columns (restored when the span block cuts). */
|
|
533
558
|
const uncappedBottoms = new Map();
|
|
559
|
+
/** Column balancing: height (px) the levers added inside each column so
|
|
560
|
+
* far — extra grid lines above headings / list ends and lines gained by
|
|
561
|
+
* loose paragraphs. Balancing only ever fills a column's bottom gap, so
|
|
562
|
+
* a placement rule that needs slack *after* a block (keep-colon-with-
|
|
563
|
+
* list) must see the column as it was before the levers filled it:
|
|
564
|
+
* otherwise the block that closed the column in the plain pass moves to
|
|
565
|
+
* the next column, the flow shifts on every later page, and the pass is
|
|
566
|
+
* discarded as a regression. */
|
|
567
|
+
const balanceExtraInColumn = new Map();
|
|
568
|
+
const addBalanceExtra = (col, px) => {
|
|
569
|
+
if (px > 0)
|
|
570
|
+
balanceExtraInColumn.set(col, (balanceExtraInColumn.get(col) ?? 0) + px);
|
|
571
|
+
};
|
|
534
572
|
const bandCapProposals = new Map();
|
|
535
573
|
const spanPlacedInBand = new Set();
|
|
536
574
|
const bandCapsApplied = new Set();
|
|
@@ -835,6 +873,34 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
835
873
|
const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
|
|
836
874
|
return Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5;
|
|
837
875
|
};
|
|
876
|
+
/** Level for the box: the text columns end level, and a column holding
|
|
877
|
+
* only a float band (a figure at its head, no text yet) counts as level
|
|
878
|
+
* when a band cap could not level it either — the figure was first
|
|
879
|
+
* referenced by the very block before the box, so a cut that spills
|
|
880
|
+
* that block into the figure's column leaves the figure no slot after
|
|
881
|
+
* its reference (it would fall off the page, and the box with it). The
|
|
882
|
+
* box then cuts under the text and the figure alike, the slack under
|
|
883
|
+
* the figure being the compositor's usual trade; a figure referenced
|
|
884
|
+
* earlier keeps the cap route, which flows text under it. */
|
|
885
|
+
const lastBlockBefore = () => {
|
|
886
|
+
let j = startIdx - 1;
|
|
887
|
+
while (j >= 0 && isMarkerBlock(contentBlocks[j]))
|
|
888
|
+
j--;
|
|
889
|
+
return j;
|
|
890
|
+
};
|
|
891
|
+
const levelForBox = (cols) => {
|
|
892
|
+
if (isBandLevel(cols))
|
|
893
|
+
return true;
|
|
894
|
+
const textCols = cols.filter((c) => c.blocks.length > 0);
|
|
895
|
+
const floatOnly = cols.filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0);
|
|
896
|
+
if (textCols.length === 0 || textCols.length + floatOnly.length !== cols.length)
|
|
897
|
+
return false;
|
|
898
|
+
if (!isBandLevel(textCols))
|
|
899
|
+
return false;
|
|
900
|
+
const textBottom = bandUsedBottom(textCols);
|
|
901
|
+
const before = lastBlockBefore();
|
|
902
|
+
return floatOnly.every((c) => c.bbox.y <= textBottom + baselineGrid + 0.5 && topFloatRefOf.get(c) === before);
|
|
903
|
+
};
|
|
838
904
|
/** Position in the children the fragment to place starts at, and its
|
|
839
905
|
* 0-based index among the fragments (0 = the box, or its head). */
|
|
840
906
|
let from = 0;
|
|
@@ -855,7 +921,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
855
921
|
* `requireLevel` is set. */
|
|
856
922
|
const measureBand = (requireLevel) => {
|
|
857
923
|
const cols = bandColumns(page, currentBand(page, cursor));
|
|
858
|
-
if (cols.length === 0 || (requireLevel && !
|
|
924
|
+
if (cols.length === 0 || (requireLevel && !levelForBox(cols)))
|
|
859
925
|
return null;
|
|
860
926
|
const cutY = gridUp(page, bandUsedBottom(cols));
|
|
861
927
|
const bandHasContent = cols.some((c) => c.blocks.length > 0);
|
|
@@ -1301,7 +1367,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1301
1367
|
// order. Then enqueue the floats first-referenced in this block, so the
|
|
1302
1368
|
// next page opened while placing it (or any later block) reserves their
|
|
1303
1369
|
// band and the next iteration offers them the slots that follow.
|
|
1304
|
-
tryPlacePendingFloatsOnCurrentPage();
|
|
1370
|
+
tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
|
|
1305
1371
|
enqueueFloatsFor(blockIdx);
|
|
1306
1372
|
// --- Directives ----------------------------------------------------
|
|
1307
1373
|
if (rawBlock.type === 'directive') {
|
|
@@ -1421,7 +1487,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1421
1487
|
// slots, else on pages opened ahead of it — so no float escapes
|
|
1422
1488
|
// past it. The page stays balanceable (no forced break).
|
|
1423
1489
|
if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier) {
|
|
1424
|
-
tryPlacePendingFloatsOnCurrentPage();
|
|
1490
|
+
tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
|
|
1425
1491
|
drainPendingFloats();
|
|
1426
1492
|
}
|
|
1427
1493
|
// `span: 'page'` boxes in multi-column layouts branch to the
|
|
@@ -1614,6 +1680,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1614
1680
|
// A justified line a link leaves with too few spaces is set ragged.
|
|
1615
1681
|
let remainingLines = [...raggedUrlLines(measured.lines, style.textAlign, rawBlock.text)];
|
|
1616
1682
|
let partIndex = 0;
|
|
1683
|
+
/** Times this block left an EMPTY short column (see `shortColumn`) —
|
|
1684
|
+
* bounded so a page whose columns are all short (footnotes, design
|
|
1685
|
+
* bands) cannot make it wander forever. */
|
|
1686
|
+
let shortColumnMoves = 0;
|
|
1617
1687
|
// "Keep with next" for colon-introduced lists: a paragraph ending in `:`
|
|
1618
1688
|
// followed directly by a list acts as a lead-in title — the colon-bearing
|
|
1619
1689
|
// line must share a column with the first list item. Only checked for the
|
|
@@ -1642,8 +1712,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1642
1712
|
// page top.
|
|
1643
1713
|
if (vdtType === 'heading') {
|
|
1644
1714
|
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
1645
|
-
if (extraPx)
|
|
1715
|
+
if (extraPx) {
|
|
1646
1716
|
spacingBefore += extraPx;
|
|
1717
|
+
addBalanceExtra(curCol, extraPx);
|
|
1718
|
+
}
|
|
1647
1719
|
}
|
|
1648
1720
|
}
|
|
1649
1721
|
else if (vdtType === 'listItem') {
|
|
@@ -1657,10 +1729,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1657
1729
|
// handled inside the heading branch above (after margin collapsing).
|
|
1658
1730
|
if (vdtType !== 'heading' && vdtType !== 'mathDisplay' && partIndex === 0) {
|
|
1659
1731
|
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
1660
|
-
if (extraPx)
|
|
1732
|
+
if (extraPx) {
|
|
1661
1733
|
spacingBefore += extraPx;
|
|
1734
|
+
addBalanceExtra(curCol, extraPx);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
else if (partIndex === 0 && reservedOf(curCol).top > 0) {
|
|
1739
|
+
// Column balancing: extra grid lines between the float band at the
|
|
1740
|
+
// head of this column and its first block (the after-float lever).
|
|
1741
|
+
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
1742
|
+
if (extraPx) {
|
|
1743
|
+
spacingBefore += extraPx;
|
|
1744
|
+
addBalanceExtra(curCol, extraPx);
|
|
1662
1745
|
}
|
|
1663
1746
|
}
|
|
1747
|
+
// A loose paragraph's extra line is balancing height too (it lands
|
|
1748
|
+
// whole in this column — loose candidates are never split parts).
|
|
1749
|
+
if (partIndex === 0 && tryLoose && looseLines !== undefined && typeof looseOutcome.get(blockIdx) === 'number') {
|
|
1750
|
+
addBalanceExtra(curCol, looseLines * style.lineHeightPx);
|
|
1751
|
+
}
|
|
1664
1752
|
const effectiveAvailable = curCol.availableHeight - spacingBefore;
|
|
1665
1753
|
const linesPerAvailable = Math.floor(effectiveAvailable / style.lineHeightPx);
|
|
1666
1754
|
// Math display blocks carry their natural pixel height on the single
|
|
@@ -1704,7 +1792,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1704
1792
|
&& curCol.blocks.length > 0) {
|
|
1705
1793
|
const usedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
|
|
1706
1794
|
const paragraphBottom = usedHeight + totalRemainHeight;
|
|
1707
|
-
|
|
1795
|
+
// Slack after the paragraph as the plain pass saw it: the height
|
|
1796
|
+
// balancing added above in this column is not room the list lost.
|
|
1797
|
+
const availableAfter = curCol.bbox.height - paragraphBottom + (balanceExtraInColumn.get(curCol) ?? 0);
|
|
1708
1798
|
const nextListKind = nextBlock?.listKind ?? 'unordered';
|
|
1709
1799
|
const nextListMarginDim = nextListKind === 'ordered'
|
|
1710
1800
|
? resolved.orderedLists.marginTop
|
|
@@ -1784,6 +1874,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1784
1874
|
// headingRunCount === curCol.blocks.length: fall through to place.
|
|
1785
1875
|
}
|
|
1786
1876
|
}
|
|
1877
|
+
// A block opening a column normally stays (no column can take it any
|
|
1878
|
+
// better), except in a column at least a line shorter than the content
|
|
1879
|
+
// area — the band under a page-span box, or a column cut by a float:
|
|
1880
|
+
// a heading that fits there alone, or a block that does not fit there
|
|
1881
|
+
// but would fit a full column, opens the next column instead (the move
|
|
1882
|
+
// count is bounded besides). A column cut by a band cap is short on
|
|
1883
|
+
// purpose — its content is meant to end at the cut.
|
|
1884
|
+
const shortColumn = shortColumnMoves < 4
|
|
1885
|
+
&& !uncappedBottoms.has(curCol)
|
|
1886
|
+
&& curCol.bbox.height < contentArea.height - baselineGrid;
|
|
1787
1887
|
// Block fits in current column
|
|
1788
1888
|
if (effectiveRemainHeight <= effectiveAvailable) {
|
|
1789
1889
|
// Heading keep-with-next: never leave a heading as the last block of a
|
|
@@ -1800,7 +1900,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1800
1900
|
&& resolved.headings.keepWithNext
|
|
1801
1901
|
&& !nextIsHeading
|
|
1802
1902
|
&& nextBlock !== null
|
|
1803
|
-
&& curCol.blocks.length > 0) {
|
|
1903
|
+
&& (curCol.blocks.length > 0 || shortColumn)) {
|
|
1804
1904
|
const wouldUsedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
|
|
1805
1905
|
const naturalBottom = wouldUsedHeight + effectiveRemainHeight + style.marginBottomPx;
|
|
1806
1906
|
const snappedBottom = shouldSnapToGrid
|
|
@@ -1812,6 +1912,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1812
1912
|
: 1;
|
|
1813
1913
|
const minSpaceAfter = minLinesNeeded * bodyStyle.lineHeightPx;
|
|
1814
1914
|
if (remainAfterHeading < minSpaceAfter) {
|
|
1915
|
+
if (curCol.blocks.length === 0)
|
|
1916
|
+
shortColumnMoves++;
|
|
1815
1917
|
// Roll back any immediately-preceding heading blocks in this
|
|
1816
1918
|
// column so they travel with this one.
|
|
1817
1919
|
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
@@ -1975,8 +2077,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1975
2077
|
}
|
|
1976
2078
|
// choice.splitAt === 0: fall through to push whole paragraph to next column
|
|
1977
2079
|
}
|
|
1978
|
-
// Cannot split — advance to next column if current has content
|
|
1979
|
-
|
|
2080
|
+
// Cannot split — advance to next column if current has content (or
|
|
2081
|
+
// the column is a short band that cannot hold the block at all).
|
|
2082
|
+
if (curCol.blocks.length > 0 || (shortColumn && effectiveRemainHeight <= contentArea.height)) {
|
|
2083
|
+
if (curCol.blocks.length === 0)
|
|
2084
|
+
shortColumnMoves++;
|
|
1980
2085
|
// Heading keep-with-next (no-fit variant): when a heading can't fit
|
|
1981
2086
|
// in the current column and the column's tail is a run of headings,
|
|
1982
2087
|
// pull those headings along so they don't remain stranded as orphans
|
|
@@ -2099,17 +2204,67 @@ export function buildDocument(content, config, cache, options) {
|
|
|
2099
2204
|
let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
|
|
2100
2205
|
let applied = { lines: new Map(), loose: new Map() };
|
|
2101
2206
|
const failedLoose = new Set();
|
|
2207
|
+
const failedLines = new Set();
|
|
2102
2208
|
let converged = bestScore === 0;
|
|
2209
|
+
/**
|
|
2210
|
+
* A rejected pass moved content across a column break somewhere (a
|
|
2211
|
+
* split paragraph whose head no longer fits, a float that lost its slot,
|
|
2212
|
+
* a lead-in that left with its list…): every page after that point is
|
|
2213
|
+
* re-flowed, gaps open elsewhere and a span cap may miss its band. The
|
|
2214
|
+
* levers are meant to be local, so contain the damage: find the first
|
|
2215
|
+
* column whose content changed and blacklist the levers this pass newly
|
|
2216
|
+
* applied there (failing that, on its page; failing that, everywhere), so
|
|
2217
|
+
* the next proposal keeps the working levers before it and tries again
|
|
2218
|
+
* without the one that cascaded. Returns whether anything was blacklisted.
|
|
2219
|
+
*/
|
|
2220
|
+
const containCascade = (next, proposal) => {
|
|
2221
|
+
const div = firstDivergentColumn(best.doc, next.doc);
|
|
2222
|
+
if (!div)
|
|
2223
|
+
return false;
|
|
2224
|
+
const newLines = [...proposal.lines].filter(([k, n]) => n > (applied.lines.get(k) ?? 0)).map(([k]) => k);
|
|
2225
|
+
const newLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
|
|
2226
|
+
if (newLines.length === 0 && newLoose.length === 0)
|
|
2227
|
+
return false;
|
|
2228
|
+
const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
|
|
2229
|
+
const blacklist = (cands) => {
|
|
2230
|
+
let hit = false;
|
|
2231
|
+
for (const k of newLines)
|
|
2232
|
+
if (!cands || cands.has(k)) {
|
|
2233
|
+
failedLines.add(k);
|
|
2234
|
+
hit = true;
|
|
2235
|
+
}
|
|
2236
|
+
for (const k of newLoose)
|
|
2237
|
+
if (!cands || cands.has(k)) {
|
|
2238
|
+
failedLoose.add(k);
|
|
2239
|
+
hit = true;
|
|
2240
|
+
}
|
|
2241
|
+
return hit;
|
|
2242
|
+
};
|
|
2243
|
+
const inColumn = gaps
|
|
2244
|
+
.filter((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)
|
|
2245
|
+
.flatMap((g) => g.candidates.map((c) => c.contentIndex));
|
|
2246
|
+
if (blacklist(new Set(inColumn)))
|
|
2247
|
+
return true;
|
|
2248
|
+
const onPage = gaps
|
|
2249
|
+
.filter((g) => g.pageIndex === div.pageIndex)
|
|
2250
|
+
.flatMap((g) => g.candidates.map((c) => c.contentIndex));
|
|
2251
|
+
if (blacklist(new Set(onPage)))
|
|
2252
|
+
return true;
|
|
2253
|
+
return blacklist(null);
|
|
2254
|
+
};
|
|
2103
2255
|
const balance = () => {
|
|
2104
2256
|
while (!converged && passCount < MAX_BALANCING_PASSES) {
|
|
2105
2257
|
const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
|
|
2106
2258
|
maxLinesPerHeading: balancing.maxLinesPerHeading,
|
|
2107
2259
|
stretchAfterLists: balancing.stretchAfterLists,
|
|
2108
2260
|
maxLinesAfterList: balancing.maxLinesAfterList,
|
|
2261
|
+
stretchAfterFloats: balancing.stretchAfterFloats,
|
|
2262
|
+
maxLinesAfterFloat: balancing.maxLinesAfterFloat,
|
|
2109
2263
|
looseParagraphs: balancing.looseParagraphs,
|
|
2110
2264
|
maxLooseParagraphs: balancing.maxLooseParagraphs,
|
|
2111
2265
|
optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
|
|
2112
2266
|
failedLoose,
|
|
2267
|
+
failedLines,
|
|
2113
2268
|
});
|
|
2114
2269
|
if (!proposal.changed) {
|
|
2115
2270
|
// No stretch point can absorb the remaining gaps — stable.
|
|
@@ -2153,11 +2308,16 @@ export function buildDocument(content, config, cache, options) {
|
|
|
2153
2308
|
converged = score === 0;
|
|
2154
2309
|
}
|
|
2155
2310
|
else {
|
|
2156
|
-
// Plateau or regression.
|
|
2157
|
-
//
|
|
2158
|
-
//
|
|
2159
|
-
//
|
|
2160
|
-
//
|
|
2311
|
+
// Plateau or regression. First contain a cascade: a lever that
|
|
2312
|
+
// moved content across a column break is blacklisted and the loop
|
|
2313
|
+
// retries without it. Otherwise retry when a loose candidate was
|
|
2314
|
+
// just blacklisted (the proposer falls through to the next one), or
|
|
2315
|
+
// when the new loose paragraphs gained their lines yet the layout
|
|
2316
|
+
// did not improve (the gain landed elsewhere — drop them too). A
|
|
2317
|
+
// pure spacing plateau means we're done: keep the best layout found
|
|
2318
|
+
// so far.
|
|
2319
|
+
if (containCascade(next, proposal))
|
|
2320
|
+
continue;
|
|
2161
2321
|
if (looseFailed.length > 0 || looseWon.length > 0) {
|
|
2162
2322
|
for (const k of looseWon)
|
|
2163
2323
|
failedLoose.add(k);
|