postext 0.3.35 → 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/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/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +278 -95
- 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/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
|
|
@@ -2776,72 +2776,209 @@ export function buildDocument(content, config, cache, options) {
|
|
|
2776
2776
|
const bandCaps = bands.bandCaps;
|
|
2777
2777
|
let passCount = bands.passCount;
|
|
2778
2778
|
best.doc.iterationCount = passCount;
|
|
2779
|
-
/** Hints that produced `best` (replayed by the trailing-cap passes). */
|
|
2780
|
-
let bestHints = { bandCaps };
|
|
2781
2779
|
// --- Column balancing (vertical justification) ------------------------
|
|
2782
2780
|
// Iteratively re-place the document with extra grid lines above headings
|
|
2783
|
-
// until every balanceable column ends flush with
|
|
2784
|
-
// further adjustment is possible
|
|
2785
|
-
// gaps on the freshly placed document, so
|
|
2786
|
-
// that shift under the new spacing are
|
|
2787
|
-
// (fewest leftover gap lines) always wins
|
|
2788
|
-
// 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.
|
|
2789
2799
|
const balancing = best.doc.config.headings.balancing;
|
|
2790
2800
|
if (!balancing.enabled)
|
|
2791
2801
|
return best.doc;
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
const
|
|
2795
|
-
|
|
2796
|
-
|
|
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
|
+
};
|
|
2797
2927
|
/**
|
|
2798
|
-
* A rejected
|
|
2799
|
-
* split paragraph whose head no longer fits, a float that
|
|
2800
|
-
* a lead-in that left with its list…):
|
|
2801
|
-
* re-
|
|
2802
|
-
* levers are meant to be local, so contain the damage: find
|
|
2803
|
-
* column whose content changed and blacklist
|
|
2804
|
-
* applied there (failing that, on its page;
|
|
2805
|
-
*
|
|
2806
|
-
*
|
|
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.
|
|
2807
2938
|
*/
|
|
2808
|
-
const containCascade = (next,
|
|
2809
|
-
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);
|
|
2810
2941
|
if (!div)
|
|
2811
2942
|
return false;
|
|
2812
|
-
const newLines = [...proposal.lines].filter(([k, n]) => n > (applied.lines.get(k) ?? 0)).map(([k]) => k);
|
|
2813
|
-
const newLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
|
|
2814
2943
|
if (newLines.length === 0 && newLoose.length === 0)
|
|
2815
2944
|
return false;
|
|
2816
|
-
const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
|
|
2817
2945
|
const blacklist = (cands) => {
|
|
2818
2946
|
let hit = false;
|
|
2819
2947
|
for (const k of newLines)
|
|
2820
2948
|
if (!cands || cands.has(k)) {
|
|
2821
|
-
failedLines.add(k);
|
|
2949
|
+
s.failedLines.add(k);
|
|
2822
2950
|
hit = true;
|
|
2823
2951
|
}
|
|
2824
2952
|
for (const k of newLoose)
|
|
2825
2953
|
if (!cands || cands.has(k)) {
|
|
2826
|
-
failedLoose.add(k);
|
|
2954
|
+
s.failedLoose.add(k);
|
|
2827
2955
|
hit = true;
|
|
2828
2956
|
}
|
|
2829
2957
|
return hit;
|
|
2830
2958
|
};
|
|
2831
|
-
const
|
|
2832
|
-
|
|
2833
|
-
.flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
|
|
2834
|
-
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)))
|
|
2835
2961
|
return true;
|
|
2836
|
-
|
|
2837
|
-
.filter((g) => g.pageIndex === div.pageIndex)
|
|
2838
|
-
.flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
|
|
2839
|
-
if (blacklist(new Set(onPage)))
|
|
2962
|
+
if (blacklist(keysOf((g) => g.pageIndex === div.pageIndex)))
|
|
2840
2963
|
return true;
|
|
2841
2964
|
return blacklist(null);
|
|
2842
2965
|
};
|
|
2966
|
+
let balancingPasses = 0;
|
|
2843
2967
|
const balance = () => {
|
|
2844
|
-
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
|
+
}
|
|
2845
2982
|
const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
|
|
2846
2983
|
maxLinesPerHeading: balancing.maxLinesPerHeading,
|
|
2847
2984
|
stretchAfterLists: balancing.stretchAfterLists,
|
|
@@ -2854,67 +2991,114 @@ export function buildDocument(content, config, cache, options) {
|
|
|
2854
2991
|
failedLoose,
|
|
2855
2992
|
failedLines,
|
|
2856
2993
|
});
|
|
2857
|
-
|
|
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) {
|
|
2858
3025
|
// No stretch point can absorb the remaining gaps — stable.
|
|
2859
|
-
|
|
3026
|
+
for (const s of active) {
|
|
3027
|
+
s.done = true;
|
|
3028
|
+
s.stable = true;
|
|
3029
|
+
}
|
|
2860
3030
|
break;
|
|
2861
3031
|
}
|
|
2862
|
-
const
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
const hints = {
|
|
2866
|
-
balanceExtraPx: extraPx,
|
|
2867
|
-
balanceLooseness: proposal.loose,
|
|
2868
|
-
balanceLooseBudget: proposal.looseBudget,
|
|
2869
|
-
bandCaps,
|
|
2870
|
-
};
|
|
2871
|
-
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));
|
|
2872
3035
|
passCount++;
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
const
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
const looseFailed = newlyLoose.filter((k) => next.looseOutcome.get(k) === null);
|
|
2885
|
-
for (const k of looseFailed)
|
|
2886
|
-
failedLoose.add(k);
|
|
2887
|
-
const looseWon = newlyLoose.filter((k) => typeof next.looseOutcome.get(k) === 'number');
|
|
2888
|
-
if (score < bestScore) {
|
|
2889
|
-
best = next;
|
|
2890
|
-
bestHints = hints;
|
|
2891
|
-
bestScore = score;
|
|
2892
|
-
applied = {
|
|
2893
|
-
lines: proposal.lines,
|
|
2894
|
-
loose: new Map([...proposal.loose].filter(([k]) => applied.loose.has(k) || looseWon.includes(k))),
|
|
2895
|
-
};
|
|
2896
|
-
converged = score === 0;
|
|
2897
|
-
}
|
|
2898
|
-
else {
|
|
2899
|
-
// Plateau or regression. First contain a cascade: a lever that
|
|
2900
|
-
// moved content across a column break is blacklisted and the loop
|
|
2901
|
-
// retries without it. Otherwise retry when a loose candidate was
|
|
2902
|
-
// just blacklisted (the proposer falls through to the next one), or
|
|
2903
|
-
// when the new loose paragraphs gained their lines yet the layout
|
|
2904
|
-
// did not improve (the gain landed elsewhere — drop them too). A
|
|
2905
|
-
// pure spacing plateau means we're done: keep the best layout found
|
|
2906
|
-
// so far.
|
|
2907
|
-
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--;
|
|
2908
3047
|
continue;
|
|
2909
|
-
|
|
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));
|
|
2910
3070
|
for (const k of looseWon)
|
|
2911
|
-
|
|
2912
|
-
|
|
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);
|
|
2913
3078
|
}
|
|
2914
|
-
|
|
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;
|
|
2915
3096
|
}
|
|
3097
|
+
if (accepted.length > 0)
|
|
3098
|
+
best = spliceSegments(next, accepted);
|
|
2916
3099
|
}
|
|
2917
3100
|
};
|
|
3101
|
+
resetSegments();
|
|
2918
3102
|
balance();
|
|
2919
3103
|
// --- Trailing bands (closing columns cut level) -------------------------
|
|
2920
3104
|
// Once the balancing levers have settled the earlier pages, level the
|
|
@@ -2925,20 +3109,19 @@ export function buildDocument(content, config, cache, options) {
|
|
|
2925
3109
|
// block and the cap applies. A short polish round then lets the levers
|
|
2926
3110
|
// fill what the cut left short (a column ending a line under the cap).
|
|
2927
3111
|
if (balancing.trailing) {
|
|
2928
|
-
const
|
|
3112
|
+
const frozen = hintsFrom(applied.lines, applied.loose);
|
|
3113
|
+
const trailing = resolveTrailingCaps(best, bandCaps, (caps) => runPass({ ...frozen, bandCaps: caps }));
|
|
2929
3114
|
passCount += trailing.passCount;
|
|
2930
3115
|
if (trailing.result !== best) {
|
|
2931
3116
|
best = trailing.result;
|
|
2932
3117
|
for (const [i, cap] of trailing.caps)
|
|
2933
3118
|
bandCaps.set(i, cap);
|
|
2934
|
-
|
|
2935
|
-
bestScore = totalGapLines(best.doc, best.forcedBreakPages);
|
|
2936
|
-
converged = bestScore === 0;
|
|
3119
|
+
resetSegments();
|
|
2937
3120
|
balance();
|
|
2938
3121
|
}
|
|
2939
3122
|
}
|
|
2940
3123
|
best.doc.iterationCount = passCount;
|
|
2941
|
-
best.doc.converged =
|
|
3124
|
+
best.doc.converged = segments.every((s) => s.stable || s.bestScore === 0);
|
|
2942
3125
|
return best.doc;
|
|
2943
3126
|
}
|
|
2944
3127
|
//# sourceMappingURL=build.js.map
|