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.
@@ -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
@@ -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 the page bottom (or no
2784
- // further adjustment is possible). Each retry recomputes the remaining
2785
- // gaps on the freshly placed document, so split/keep-with-next decisions
2786
- // that shift under the new spacing are accounted for. The best layout
2787
- // (fewest leftover gap lines) always wins — a retry that regresses is
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
- let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
2793
- let applied = { lines: new Map(), loose: new Map() };
2794
- const failedLoose = new Set();
2795
- const failedLines = new Set();
2796
- 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
+ };
2797
2927
  /**
2798
- * A rejected pass moved content across a column break somewhere (a
2799
- * split paragraph whose head no longer fits, a float that lost its slot,
2800
- * a lead-in that left with its list…): every page after that point is
2801
- * re-flowed, gaps open elsewhere and a span cap may miss its band. The
2802
- * levers are meant to be local, so contain the damage: find the first
2803
- * column whose content changed and blacklist the levers this pass newly
2804
- * applied there (failing that, on its page; failing that, everywhere), so
2805
- * the next proposal keeps the working levers before it and tries again
2806
- * 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.
2807
2938
  */
2808
- const containCascade = (next, proposal) => {
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 inColumn = gaps
2832
- .filter((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)
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
- const onPage = gaps
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 (!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
+ }
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
- 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) {
2858
3025
  // No stretch point can absorb the remaining gaps — stable.
2859
- converged = true;
3026
+ for (const s of active) {
3027
+ s.done = true;
3028
+ s.stable = true;
3029
+ }
2860
3030
  break;
2861
3031
  }
2862
- const extraPx = new Map();
2863
- for (const [idx, n] of proposal.lines)
2864
- extraPx.set(idx, n * best.doc.baselineGrid);
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
- // Band caps ride along unchanged; a retry that unsettles one (its span
2874
- // block no longer lands in the capped band, or a levelled closing band
2875
- // spills past its cut) counts as a regression — capped columns without
2876
- // their box are not a layout we may keep.
2877
- const capsDelivered = [...bandCaps.keys()].every((i) => next.spanPlacedInBand.has(i));
2878
- const score = capsDelivered ? totalGapLines(next.doc, next.forcedBreakPages) : Infinity;
2879
- // Loose paragraphs that gained no line at any tracking rung are
2880
- // blacklisted whatever the score did, and never counted as applied.
2881
- // Candidates the pass never tried (their column's budget was met
2882
- // first) stay eligible for a later proposal.
2883
- const newlyLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
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
- 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));
2910
3070
  for (const k of looseWon)
2911
- failedLoose.add(k);
2912
- 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);
2913
3078
  }
2914
- 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;
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 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 }));
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
- bestHints = { ...bestHints, bandCaps };
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 = converged || bestScore === 0;
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