postext 0.3.35 → 0.3.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/__tests__/columnBalancing.test.js +28 -0
  2. package/dist/__tests__/columnBalancing.test.js.map +1 -1
  3. package/dist/__tests__/continuation.test.js +21 -0
  4. package/dist/__tests__/continuation.test.js.map +1 -1
  5. package/dist/__tests__/exports.test.js +13 -0
  6. package/dist/__tests__/exports.test.js.map +1 -1
  7. package/dist/__tests__/headingStyles.test.d.ts +2 -0
  8. package/dist/__tests__/headingStyles.test.d.ts.map +1 -0
  9. package/dist/__tests__/headingStyles.test.js +134 -0
  10. package/dist/__tests__/headingStyles.test.js.map +1 -0
  11. package/dist/__tests__/parse/containers.test.js +1 -1
  12. package/dist/__tests__/parse/containers.test.js.map +1 -1
  13. package/dist/__tests__/pipeline/segmentBalancing.test.d.ts +2 -0
  14. package/dist/__tests__/pipeline/segmentBalancing.test.d.ts.map +1 -0
  15. package/dist/__tests__/pipeline/segmentBalancing.test.js +70 -0
  16. package/dist/__tests__/pipeline/segmentBalancing.test.js.map +1 -0
  17. package/dist/__tests__/toc.test.d.ts +2 -0
  18. package/dist/__tests__/toc.test.d.ts.map +1 -0
  19. package/dist/__tests__/toc.test.js +199 -0
  20. package/dist/__tests__/toc.test.js.map +1 -0
  21. package/dist/canvas-backend/blockRender.d.ts.map +1 -1
  22. package/dist/canvas-backend/blockRender.js +21 -11
  23. package/dist/canvas-backend/blockRender.js.map +1 -1
  24. package/dist/canvas-backend/index.d.ts.map +1 -1
  25. package/dist/canvas-backend/index.js +9 -0
  26. package/dist/canvas-backend/index.js.map +1 -1
  27. package/dist/defaults/headerFooter.d.ts.map +1 -1
  28. package/dist/defaults/headerFooter.js +17 -0
  29. package/dist/defaults/headerFooter.js.map +1 -1
  30. package/dist/defaults/headingStyles.d.ts +10 -0
  31. package/dist/defaults/headingStyles.d.ts.map +1 -0
  32. package/dist/defaults/headingStyles.js +85 -0
  33. package/dist/defaults/headingStyles.js.map +1 -0
  34. package/dist/defaults/headings.d.ts +6 -1
  35. package/dist/defaults/headings.d.ts.map +1 -1
  36. package/dist/defaults/headings.js +32 -0
  37. package/dist/defaults/headings.js.map +1 -1
  38. package/dist/defaults/index.d.ts +2 -0
  39. package/dist/defaults/index.d.ts.map +1 -1
  40. package/dist/defaults/index.js +18 -0
  41. package/dist/defaults/index.js.map +1 -1
  42. package/dist/defaults/paragraphStyles.d.ts.map +1 -1
  43. package/dist/defaults/paragraphStyles.js +4 -1
  44. package/dist/defaults/paragraphStyles.js.map +1 -1
  45. package/dist/defaults/toc.d.ts +26 -0
  46. package/dist/defaults/toc.d.ts.map +1 -0
  47. package/dist/defaults/toc.js +167 -0
  48. package/dist/defaults/toc.js.map +1 -0
  49. package/dist/design/layout.d.ts +15 -3
  50. package/dist/design/layout.d.ts.map +1 -1
  51. package/dist/design/layout.js +66 -1
  52. package/dist/design/layout.js.map +1 -1
  53. package/dist/html-backend.d.ts.map +1 -1
  54. package/dist/html-backend.js +16 -5
  55. package/dist/html-backend.js.map +1 -1
  56. package/dist/index.d.ts +5 -5
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +2 -2
  59. package/dist/index.js.map +1 -1
  60. package/dist/numbering.d.ts +5 -1
  61. package/dist/numbering.d.ts.map +1 -1
  62. package/dist/numbering.js +7 -1
  63. package/dist/numbering.js.map +1 -1
  64. package/dist/parse/blockParser.d.ts.map +1 -1
  65. package/dist/parse/blockParser.js +1 -1
  66. package/dist/parse/blockParser.js.map +1 -1
  67. package/dist/parse/index.d.ts +1 -1
  68. package/dist/parse/index.d.ts.map +1 -1
  69. package/dist/parse/index.js.map +1 -1
  70. package/dist/parse/types.d.ts +24 -1
  71. package/dist/parse/types.d.ts.map +1 -1
  72. package/dist/pipeline/build.d.ts.map +1 -1
  73. package/dist/pipeline/build.js +459 -142
  74. package/dist/pipeline/build.js.map +1 -1
  75. package/dist/pipeline/buildBlockKind.d.ts +4 -0
  76. package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
  77. package/dist/pipeline/buildBlockKind.js +2 -2
  78. package/dist/pipeline/buildBlockKind.js.map +1 -1
  79. package/dist/pipeline/columnBalancing.d.ts +27 -5
  80. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  81. package/dist/pipeline/columnBalancing.js +51 -10
  82. package/dist/pipeline/columnBalancing.js.map +1 -1
  83. package/dist/pipeline/config.d.ts.map +1 -1
  84. package/dist/pipeline/config.js +3 -1
  85. package/dist/pipeline/config.js.map +1 -1
  86. package/dist/pipeline/continuation.d.ts +10 -1
  87. package/dist/pipeline/continuation.d.ts.map +1 -1
  88. package/dist/pipeline/continuation.js +16 -1
  89. package/dist/pipeline/continuation.js.map +1 -1
  90. package/dist/pipeline/headerFooter.d.ts +5 -2
  91. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  92. package/dist/pipeline/headerFooter.js +90 -15
  93. package/dist/pipeline/headerFooter.js.map +1 -1
  94. package/dist/pipeline/headingStyles.d.ts +58 -0
  95. package/dist/pipeline/headingStyles.d.ts.map +1 -0
  96. package/dist/pipeline/headingStyles.js +157 -0
  97. package/dist/pipeline/headingStyles.js.map +1 -0
  98. package/dist/pipeline/index.d.ts +2 -1
  99. package/dist/pipeline/index.d.ts.map +1 -1
  100. package/dist/pipeline/index.js +2 -1
  101. package/dist/pipeline/index.js.map +1 -1
  102. package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
  103. package/dist/pipeline/measureContentBlock.js +4 -0
  104. package/dist/pipeline/measureContentBlock.js.map +1 -1
  105. package/dist/pipeline/outline.d.ts +37 -0
  106. package/dist/pipeline/outline.d.ts.map +1 -0
  107. package/dist/pipeline/outline.js +146 -0
  108. package/dist/pipeline/outline.js.map +1 -0
  109. package/dist/pipeline/pageRoles.d.ts +2 -1
  110. package/dist/pipeline/pageRoles.d.ts.map +1 -1
  111. package/dist/pipeline/pageRoles.js +5 -4
  112. package/dist/pipeline/pageRoles.js.map +1 -1
  113. package/dist/pipeline/placeholders.d.ts.map +1 -1
  114. package/dist/pipeline/placeholders.js +6 -0
  115. package/dist/pipeline/placeholders.js.map +1 -1
  116. package/dist/pipeline/resourceNumbering.d.ts +3 -1
  117. package/dist/pipeline/resourceNumbering.d.ts.map +1 -1
  118. package/dist/pipeline/resourceNumbering.js +4 -2
  119. package/dist/pipeline/resourceNumbering.js.map +1 -1
  120. package/dist/pipeline/styles.d.ts +5 -2
  121. package/dist/pipeline/styles.d.ts.map +1 -1
  122. package/dist/pipeline/styles.js +7 -3
  123. package/dist/pipeline/styles.js.map +1 -1
  124. package/dist/pipeline/toc.d.ts +22 -0
  125. package/dist/pipeline/toc.d.ts.map +1 -0
  126. package/dist/pipeline/toc.js +249 -0
  127. package/dist/pipeline/toc.js.map +1 -0
  128. package/dist/types.d.ts +273 -6
  129. package/dist/types.d.ts.map +1 -1
  130. package/dist/vdt.d.ts +37 -2
  131. package/dist/vdt.d.ts.map +1 -1
  132. package/dist/vdt.js.map +1 -1
  133. package/package.json +1 -1
@@ -6,7 +6,10 @@ import { parseMarkdownMemo } from '../parse';
6
6
  import { buildPageLabels, computeHeadingNumbers, } from '../numbering';
7
7
  import { extractFrontmatter } from '../frontmatter';
8
8
  import { initHyphenator } from '../measure';
9
- import { resolveAllConfig, computeBaselineGrid, buildHeadingLevelMap } from './config';
9
+ import { resolveAllConfig, computeBaselineGrid } from './config';
10
+ import { createHeadingLevelResolver, deriveSectionGeometryConfig, deriveSectionMeasureContext, headingIsNumbered, headingStyleOf, planHeadingSections, } from './headingStyles';
11
+ import { computeOutline, hasTocDirective, outlineFromDoc, sameOutline } from './outline';
12
+ import { expandTocDirectives } from './toc';
10
13
  import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
11
14
  import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
12
15
  import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, pageIsOccupied, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
@@ -22,7 +25,7 @@ import { enumerateCurrentPageSlots, measureFloatBand, columnHasFloatBand, fitsSt
22
25
  import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
23
26
  import { defaultResourceTypes } from '../defaults/resourceTypes';
24
27
  import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
25
- import { totalGapLines, proposeBalanceLines, collectColumnGaps, firstDivergentColumn, MAX_BALANCING_PASSES, balanceKey } from './columnBalancing';
28
+ import { proposeBalanceLines, collectColumnGaps, firstDivergentColumn, gapLinesIn, pageSegments, MAX_BALANCING_PASSES, MAX_BALANCING_PASSES_PER_DOCUMENT, balanceKey } from './columnBalancing';
26
29
  import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, bandCapLinesAroundZone, } from './bandCaps';
27
30
  import { raggedLooseLines } from './raggedLines';
28
31
  /** Tolerance for "does this block fit" checks against a column's free
@@ -97,7 +100,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
97
100
  const looseOutcome = new Map();
98
101
  // Lines gained so far per column budget group (see `LooseBudget`).
99
102
  const looseGained = new Map();
100
- const headingLevelByNumber = buildHeadingLevelMap(resolved);
103
+ // Level configs with heading-style overrides merged in (`{style="…"}`).
104
+ const headingLevels = createHeadingLevelResolver(resolved);
101
105
  const dpi = resolved.page.dpi;
102
106
  // Initialize hyphenator if needed (body text, or any justified paragraph
103
107
  // style that hyphenates — they share the document locale).
@@ -123,7 +127,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
123
127
  if (continuation?.part)
124
128
  doc.partStart = continuation.part;
125
129
  const pageMetrics = computePageMetrics(resolved);
126
- const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = pageMetrics;
130
+ const { pageWidthPx, pageHeightPx, trimOffset } = pageMetrics;
131
+ // The geometry pages are opened with: the document's, or — inside a
132
+ // styled section with its own margins / layout — the section's.
133
+ let contentArea = pageMetrics.contentArea;
134
+ let geomResolved = resolved;
127
135
  // Page/bleed frames for design elements anchored to `'page'` / `'bleed'`.
128
136
  const designFrames = { page: pageMetrics.trimBox, bleed: pageMetrics.bleedBox };
129
137
  doc.trimOffset = trimOffset;
@@ -133,22 +141,29 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
133
141
  // Extract frontmatter, then parse the remaining markdown body
134
142
  const { metadata: frontmatterMeta, content: markdownBody, contentOffset: bodyOffset } = extractFrontmatter(content.markdown);
135
143
  doc.metadata = { ...(content.metadata ?? {}), ...frontmatterMeta };
136
- const contentBlocks = parseMarkdownMemo(markdownBody);
144
+ const parsedBlocks = parseMarkdownMemo(markdownBody);
145
+ const headingStart = continuation?.headings;
146
+ // `:::toc` expands into the entries of the book's outline — the one the
147
+ // host supplied, else this document's own (page labels unknown on the
148
+ // first pass; `buildDocument` lays the document out again with them).
149
+ const outline = content.outline
150
+ ?? (hasTocDirective(parsedBlocks) ? computeOutline(parsedBlocks, resolved, headingStart) : undefined);
151
+ const contentBlocks = expandTocDirectives(parsedBlocks, outline, resolved);
152
+ const isNumbered = (b) => headingIsNumbered(b, resolved);
137
153
  const headingTemplates = {};
138
154
  for (const lvl of resolved.headings.levels) {
139
155
  if (lvl.numberingTemplate && lvl.numberingTemplate.length > 0) {
140
156
  headingTemplates[lvl.level] = lvl.numberingTemplate;
141
157
  }
142
158
  }
143
- const headingStart = continuation?.headings;
144
- const headingPrefixes = computeHeadingNumbers(contentBlocks, headingTemplates, headingStart ? [headingStart.h1, headingStart.h2, headingStart.h3, headingStart.h4, headingStart.h5, headingStart.h6] : undefined);
159
+ const headingPrefixes = computeHeadingNumbers(contentBlocks, headingTemplates, headingStart ? [headingStart.h1, headingStart.h2, headingStart.h3, headingStart.h4, headingStart.h5, headingStart.h6] : undefined, isNumbered);
145
160
  // Resource numbering — computed up front (before the placement loop) so that
146
161
  // captions and inline `:ref`s can resolve their rendered number strings
147
162
  // before measurement. Numbering follows order of first reference in the
148
163
  // document.
149
164
  const resourceTypes = config?.resourceTypes ?? defaultResourceTypes();
150
165
  const resources = content.resources ?? [];
151
- const headingContext = computeHeadingContext(contentBlocks, headingStart);
166
+ const headingContext = computeHeadingContext(contentBlocks, headingStart, isNumbered);
152
167
  const resourceNumbering = computeResourceNumbering(contentBlocks, resourceTypes, resources, headingContext, continuation ? { counters: continuation.resourceCounters, numbered: continuation.resourceNumbers } : undefined);
153
168
  // Lookups threaded into block-kind resolution + measurement.
154
169
  const resourceById = new Map();
@@ -173,6 +188,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
173
188
  const calloutPlan = planCallouts(contentBlocks);
174
189
  // `:::part` ranges: start/end marker indices and the enclosed blocks.
175
190
  const partPlan = planParts(contentBlocks);
191
+ // Styled sections (`{style="…"}` headings): geometry, running heads and
192
+ // body typography per content-block index.
193
+ const sectionPlan = planHeadingSections(contentBlocks, resolved);
176
194
  // --- Float planning (issue #49 — resources float to page bands) ----------
177
195
  // A resource is incorporated by its first reference (an inline `:ref` or a
178
196
  // `::resource` directive, whichever comes first in reading order). Floated
@@ -837,7 +855,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
837
855
  const before = floatsPlaced;
838
856
  const startPageIndex = cursor.pageIndex;
839
857
  do {
840
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
858
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
841
859
  } while (cursor.pageIndex === startPageIndex);
842
860
  if (floatsPlaced === before)
843
861
  break; // safety: no progress
@@ -846,6 +864,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
846
864
  // Everything per-block measurement needs that is constant for this pass.
847
865
  const measureCtx = {
848
866
  resolved,
867
+ headingLevels,
849
868
  bodyStyle,
850
869
  blockquoteStyle,
851
870
  headingPrefixes,
@@ -869,6 +888,59 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
869
888
  : measureCtx;
870
889
  // Placement cursor
871
890
  const cursor = { pageIndex: 0, columnIndex: 0 };
891
+ // Blocks inside a styled section with a body style measure with it.
892
+ const sectionMeasureCtxs = new Map();
893
+ const sectionMeasureCtx = (style) => {
894
+ if (!style?.bodyStyle)
895
+ return measureCtx;
896
+ let ctx = sectionMeasureCtxs.get(style);
897
+ if (!ctx) {
898
+ ctx = deriveSectionMeasureContext(measureCtx, style);
899
+ sectionMeasureCtxs.set(style, ctx);
900
+ }
901
+ return ctx;
902
+ };
903
+ /** The styled section whose geometry the pages opened from now on take. */
904
+ let currentSection;
905
+ const enterSection = (style) => {
906
+ currentSection = style;
907
+ geomResolved = style ? deriveSectionGeometryConfig(resolved, style) : resolved;
908
+ contentArea = geomResolved === resolved ? pageMetrics.contentArea : computePageMetrics(geomResolved).contentArea;
909
+ // A page still empty takes the geometry right away — the document (or
910
+ // a chapter laid out on its own) opening with a styled heading.
911
+ const page = doc.pages[cursor.pageIndex];
912
+ if (page && !page.partInfo && cursor.columnIndex === 0
913
+ && !pageHasContent(page) && !(page.floats && page.floats.length > 0)) {
914
+ const fresh = createPageWithColumns(page.index, geomResolved, contentArea, pageWidthPx, pageHeightPx, pageIndexOffset);
915
+ if (page.blankForParity)
916
+ fresh.blankForParity = true;
917
+ if (page.blankForForce)
918
+ fresh.blankForForce = true;
919
+ doc.pages[cursor.pageIndex] = fresh;
920
+ }
921
+ };
922
+ /** Heading-style and contents-row stamps a placed block carries for the
923
+ * running heads (`computeSectionStyles`) and the contents' part rows. */
924
+ const stampBlockExtras = (blk, raw) => {
925
+ if (raw.type === 'heading') {
926
+ const style = headingStyleOf(raw, resolved);
927
+ if (style) {
928
+ blk.headingStyleId = style.id;
929
+ if (!style.numbered)
930
+ blk.unnumbered = true;
931
+ }
932
+ }
933
+ if (raw.toc?.kind === 'entry')
934
+ blk.tocEntry = true;
935
+ if (raw.toc?.kind === 'part') {
936
+ blk.tocPart = {
937
+ number: raw.toc.number,
938
+ title: raw.toc.title ?? '',
939
+ pageLabel: raw.toc.pageLabel ?? '',
940
+ ...(raw.toc.palette ? { palette: raw.toc.palette } : {}),
941
+ };
942
+ }
943
+ };
872
944
  let blockIdCounter = 0;
873
945
  let pendingSpacing = 0;
874
946
  // Pages whose break into the next page is explicit rather than natural
@@ -1029,11 +1101,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1029
1101
  if (curPage.partInfo && !pageHasContent(curPage)) {
1030
1102
  const startPageIndex = cursor.pageIndex;
1031
1103
  do {
1032
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
1104
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx);
1033
1105
  } while (cursor.pageIndex === startPageIndex);
1034
1106
  return;
1035
1107
  }
1036
- advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
1108
+ advanceToNextPageBoundary(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx);
1037
1109
  };
1038
1110
  // Page-numbering segments. The implicit first segment comes from
1039
1111
  // `cfg.page.pageNumbering`; `:::numbering` directives append more,
@@ -1049,18 +1121,27 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1049
1121
  let pendingNumberingChange = null;
1050
1122
  let lastSeenPageIndex = 0;
1051
1123
  /** Commits any pending `:::numbering` change once we've crossed into a
1052
- * new page. Called after every block iteration. */
1124
+ * new page — or while the current page is still empty: the directive at
1125
+ * the head of a chapter (or right after a page break) numbers the page
1126
+ * it opens, not the one after. Called after every block iteration. */
1053
1127
  const flushPendingNumberingAtBoundary = () => {
1054
- if (cursor.pageIndex > lastSeenPageIndex) {
1055
- if (pendingNumberingChange) {
1128
+ if (pendingNumberingChange) {
1129
+ const page = doc.pages[cursor.pageIndex];
1130
+ const pageStillEmpty = !pageHasContent(page) && !(page.floats && page.floats.length > 0);
1131
+ if (cursor.pageIndex > lastSeenPageIndex || pageStillEmpty) {
1132
+ // A change already recorded for this page is replaced.
1133
+ const last = pageNumberSegments[pageNumberSegments.length - 1];
1134
+ if (last.startPageIndex === cursor.pageIndex && pageNumberSegments.length > 1)
1135
+ pageNumberSegments.pop();
1056
1136
  pageNumberSegments.push({
1057
1137
  startPageIndex: cursor.pageIndex,
1058
1138
  ...pendingNumberingChange,
1059
1139
  });
1060
1140
  pendingNumberingChange = null;
1061
1141
  }
1062
- lastSeenPageIndex = cursor.pageIndex;
1063
1142
  }
1143
+ if (cursor.pageIndex > lastSeenPageIndex)
1144
+ lastSeenPageIndex = cursor.pageIndex;
1064
1145
  };
1065
1146
  /** Heading blocks that are not part of a callout — the only ones the
1066
1147
  * keep-with-next rollbacks may pull along (a callout is one unbreakable
@@ -1508,7 +1589,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1508
1589
  frameId = `block-${blockIdCounter++}`;
1509
1590
  const startPageIndex = cursor.pageIndex;
1510
1591
  do {
1511
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1592
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1512
1593
  } while (cursor.pageIndex === startPageIndex);
1513
1594
  forceHere = true;
1514
1595
  continue;
@@ -1580,7 +1661,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1580
1661
  pendingSpacing = 0;
1581
1662
  const startPageIndex = cursor.pageIndex;
1582
1663
  do {
1583
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1664
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1584
1665
  } while (cursor.pageIndex === startPageIndex);
1585
1666
  page = doc.pages[cursor.pageIndex];
1586
1667
  }
@@ -1610,7 +1691,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1610
1691
  continue;
1611
1692
  }
1612
1693
  if (b.type === 'heading' && b.level) {
1613
- const level = headingLevelByNumber.get(b.level);
1694
+ const level = headingLevels.forBlock(b);
1614
1695
  return level?.breakBefore?.enabled === true || level?.span === 'page';
1615
1696
  }
1616
1697
  return false;
@@ -1716,7 +1797,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1716
1797
  pendingSpacing = 0;
1717
1798
  const startPageIndex = cursor.pageIndex;
1718
1799
  do {
1719
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1800
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1720
1801
  } while (cursor.pageIndex === startPageIndex);
1721
1802
  page = doc.pages[cursor.pageIndex];
1722
1803
  fit = attempt(page, false) ?? attempt(page, true);
@@ -1854,10 +1935,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1854
1935
  pendingSpacing = 0;
1855
1936
  if (part === 0 && resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
1856
1937
  const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
1857
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1938
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1858
1939
  return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
1859
1940
  }
1860
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1941
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1861
1942
  // Try the next column afresh: it may be short too (a float band
1862
1943
  // reserved on the page it opened), or of another width (oneAndHalf).
1863
1944
  continue;
@@ -1893,7 +1974,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1893
1974
  curCol.availableHeight = Math.max(0, curCol.availableHeight - balanceBefore);
1894
1975
  }
1895
1976
  enterBand(startIdx, 0);
1896
- placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
1977
+ placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, geomResolved, contentArea, pageWidthPx, pageHeightPx);
1897
1978
  enterBand(startIdx, 0);
1898
1979
  curCol = currentColumn(doc, cursor);
1899
1980
  commitCallout(placed, startIdx, plan, curCol, part > 0 || fragment ? fragmentRange(L, from, to) : undefined);
@@ -1912,7 +1993,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1912
1993
  from = to;
1913
1994
  part++;
1914
1995
  frameId = `block-${blockIdCounter++}`;
1915
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1996
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1916
1997
  }
1917
1998
  };
1918
1999
  for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
@@ -1920,6 +2001,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1920
2001
  throw new BuildCancelledError();
1921
2002
  options?.onProgress?.({ pass: 1, blocks: blockIdx, totalBlocks: contentBlocks.length, pages: doc.pages.length });
1922
2003
  const rawBlock = contentBlocks[blockIdx];
2004
+ // The styled section this block sits in: its geometry applies to the
2005
+ // pages opened from here (a heading with `breakBefore` opens one).
2006
+ const blockSection = sectionPlan.byBlock[blockIdx];
2007
+ if (blockSection !== currentSection)
2008
+ enterSection(blockSection);
1923
2009
  // Floats whose reference landed in an earlier iteration take the first
1924
2010
  // free slot of the current page now — after their reference in reading
1925
2011
  // order. Then enqueue the floats first-referenced in this block, so the
@@ -1934,13 +2020,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1934
2020
  if (name === 'pagebreak') {
1935
2021
  pendingSpacing = 0;
1936
2022
  markForcedBreak();
1937
- advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
2023
+ advanceToNextPageBoundary(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx);
1938
2024
  const parity = attrs.parity;
1939
2025
  if (parity === 'odd'
1940
2026
  || parity === 'even'
1941
2027
  || parity === 'always-odd'
1942
2028
  || parity === 'always-even') {
1943
- enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
2029
+ enforcePageParity(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, parity);
1944
2030
  }
1945
2031
  // Pending floats land on the page that follows the break — after
1946
2032
  // parity padding, so a blank parity page never carries a float.
@@ -1959,7 +2045,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1959
2045
  const page = doc.pages[cursor.pageIndex];
1960
2046
  if (cursor.columnIndex === page.columns.length - 1)
1961
2047
  markForcedBreak();
1962
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2048
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1963
2049
  flushPendingNumberingAtBoundary();
1964
2050
  }
1965
2051
  }
@@ -1975,6 +2061,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1975
2061
  }
1976
2062
  if (Object.keys(change).length > 0)
1977
2063
  pendingNumberingChange = change;
2064
+ // A page opened just before (a `:::pagebreak`, the chapter's head)
2065
+ // is still empty: it takes the change now. Otherwise the change
2066
+ // waits for the next page boundary.
2067
+ flushPendingNumberingAtBoundary();
1978
2068
  }
1979
2069
  continue;
1980
2070
  }
@@ -1997,7 +2087,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1997
2087
  pendingSpacing = 0;
1998
2088
  closeFlowSegment(blockIdx);
1999
2089
  leaveCurrentPage();
2000
- enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, resolved.parts.breakBefore.parity);
2090
+ enforcePageParity(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, resolved.parts.breakBefore.parity);
2001
2091
  cursor.columnIndex = 0;
2002
2092
  // Map the opener's title back to the `title="…"` attribute of the
2003
2093
  // fence so the editor can place the cursor from a click on the band.
@@ -2034,7 +2124,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2034
2124
  pendingSpacing = 0;
2035
2125
  closeFlowSegment(blockIdx);
2036
2126
  leaveCurrentPage();
2037
- enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
2127
+ enforcePageParity(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, parity);
2038
2128
  flushPendingNumberingAtBoundary();
2039
2129
  }
2040
2130
  if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'callout') {
@@ -2082,14 +2172,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2082
2172
  }
2083
2173
  // --- Heading `breakBefore` ----------------------------------------
2084
2174
  if (rawBlock.type === 'heading' && rawBlock.level) {
2085
- const level = headingLevelByNumber.get(rawBlock.level);
2175
+ const level = headingLevels.forBlock(rawBlock);
2086
2176
  const bb = level?.breakBefore;
2087
2177
  if (bb && bb.enabled) {
2088
2178
  pendingSpacing = 0;
2089
2179
  closeFlowSegment(blockIdx);
2090
- advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
2180
+ advanceToNextPageBoundary(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx);
2091
2181
  if (bb.parity !== 'any') {
2092
- enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, bb.parity);
2182
+ enforcePageParity(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, bb.parity);
2093
2183
  }
2094
2184
  flushPendingNumberingAtBoundary();
2095
2185
  }
@@ -2100,7 +2190,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2100
2190
  if (level?.span === 'page') {
2101
2191
  pendingSpacing = 0;
2102
2192
  closeFlowSegment(blockIdx);
2103
- advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
2193
+ advanceToNextPageBoundary(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx);
2104
2194
  cursor.columnIndex = 0;
2105
2195
  flushPendingNumberingAtBoundary();
2106
2196
  }
@@ -2117,7 +2207,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2117
2207
  // Measure against the current column width. `null` means there is nothing
2118
2208
  // to place inline (empty text, unknown resource id, floated resource).
2119
2209
  const col = currentColumn(doc, cursor);
2120
- const blockMeasureCtx = partPlan.byBlock[blockIdx] ? partMeasureCtx : measureCtx;
2210
+ const blockMeasureCtx = partPlan.byBlock[blockIdx] ? partMeasureCtx : sectionMeasureCtx(sectionPlan.byBlock[blockIdx]);
2121
2211
  const styleOverride = paragraphContainer
2122
2212
  ? (isContainerTail ? paragraphContainer.tailStyle : paragraphContainer.style)
2123
2213
  : undefined;
@@ -2151,6 +2241,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2151
2241
  const groupHeight = measured.totalHeight;
2152
2242
  const blk = createVDTBlock(id, 'resource', style.fontString, style.color, style.textAlign);
2153
2243
  blk.contentIndex = blockIdx;
2244
+ stampBlockExtras(blk, rawBlock);
2154
2245
  blk.resourceBlock = resourceBlock;
2155
2246
  blk.dirty = false;
2156
2247
  blk.snappedToGrid = false;
@@ -2168,7 +2259,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2168
2259
  }];
2169
2260
  const spacingBefore = pendingSpacing;
2170
2261
  enterBand(blockIdx, 0);
2171
- placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
2262
+ placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, geomResolved, contentArea, pageWidthPx, pageHeightPx);
2172
2263
  enterBand(blockIdx, 0);
2173
2264
  // `placeBlockInColumn` (inside placeAtomicBlock) shifts `blk.lines`; the
2174
2265
  // resource's own caption/table lines live on `resourceBlock` and must be
@@ -2234,10 +2325,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2234
2325
  // paragraph of a `:::paragraphs` container, whose leading and spacing
2235
2326
  // are off-grid by design.
2236
2327
  const nextIsHeading = nextBlock?.type === 'heading';
2237
- const shouldSnapToGrid = (vdtType === 'heading' && !nextIsHeading) ||
2328
+ // The contents (`:::toc`) keep their own rhythm: an entry set as a list
2329
+ // item is not a list tail to realign the text after it.
2330
+ const shouldSnapToGrid = rawBlock.toc === undefined && ((vdtType === 'heading' && !nextIsHeading) ||
2238
2331
  (vdtType === 'listItem' && !nextIsListItem) ||
2239
2332
  (vdtType === 'paragraph' && isContainerTail) ||
2240
- vdtType === 'mathDisplay';
2333
+ vdtType === 'mathDisplay');
2241
2334
  // Place block, splitting across columns/pages if needed.
2242
2335
  // List items may split too — orphan/widow protection per-list is gated by
2243
2336
  // `avoidOrphansInLists` / `avoidWidowsInLists`; bullet stays on first part.
@@ -2290,6 +2383,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2290
2383
  spacingBefore = Math.max(spacingBefore, style.marginTopPx);
2291
2384
  }
2292
2385
  }
2386
+ else if (rawBlock.toc) {
2387
+ // A part row or an unnumbered entry of the contents: its top
2388
+ // margin (`toc.parts.marginTop`, the level's `marginTop`) applies
2389
+ // like a heading's.
2390
+ spacingBefore = Math.max(spacingBefore, style.marginTopPx);
2391
+ }
2293
2392
  // Column balancing: extra grid lines above a non-heading balance
2294
2393
  // target — the first block after a list end. Heading targets are
2295
2394
  // handled inside the heading branch above (after margin collapsing).
@@ -2328,7 +2427,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2328
2427
  // subsequent marginBottom + grid snap) starts from there.
2329
2428
  let effectiveRemainHeight = totalRemainHeight;
2330
2429
  if (vdtType === 'heading' && headingLevel !== undefined && partIndex === 0) {
2331
- const lvl = headingLevelByNumber.get(headingLevel);
2430
+ const lvl = headingLevels.forBlock(rawBlock);
2332
2431
  if (lvl) {
2333
2432
  const full = remainingLines
2334
2433
  .map((ln) => (ln.segments ?? []).map((s) => s.text).join(''))
@@ -2382,6 +2481,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2382
2481
  if (letterSpacingPx !== undefined)
2383
2482
  blk.letterSpacing = letterSpacingPx;
2384
2483
  blk.contentIndex = blockIdx;
2484
+ stampBlockExtras(blk, rawBlock);
2385
2485
  blk.headingLevel = headingLevel;
2386
2486
  if (numberPrefix)
2387
2487
  blk.numberPrefix = numberPrefix;
@@ -2397,7 +2497,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2397
2497
  remainingLines = remainingLines.slice(splitAt);
2398
2498
  partIndex++;
2399
2499
  pendingSpacing = 0;
2400
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2500
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2401
2501
  continue;
2402
2502
  }
2403
2503
  // Can't cleanly split the colon line off — would create a widow.
@@ -2429,12 +2529,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2429
2529
  // rolled-back heading (marker blocks in between are replayed).
2430
2530
  blockIdx = (popped[0].contentIndex ?? blockIdx - headingRunCount) - 1;
2431
2531
  pendingSpacing = 0;
2432
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2532
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2433
2533
  break;
2434
2534
  }
2435
2535
  if (headingRunCount === 0) {
2436
2536
  pendingSpacing = 0;
2437
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2537
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2438
2538
  continue;
2439
2539
  }
2440
2540
  // headingRunCount === curCol.blocks.length: fall through to place.
@@ -2490,11 +2590,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2490
2590
  // rolled-back heading (marker blocks in between are replayed).
2491
2591
  blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
2492
2592
  pendingSpacing = 0;
2493
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2593
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2494
2594
  break;
2495
2595
  }
2496
2596
  pendingSpacing = 0;
2497
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2597
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2498
2598
  continue;
2499
2599
  }
2500
2600
  }
@@ -2508,6 +2608,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2508
2608
  if (letterSpacingPx !== undefined)
2509
2609
  blk.letterSpacing = letterSpacingPx;
2510
2610
  blk.contentIndex = blockIdx;
2611
+ stampBlockExtras(blk, rawBlock);
2511
2612
  if (partIndex === 0) {
2512
2613
  blk.headingLevel = headingLevel;
2513
2614
  if (numberPrefix)
@@ -2573,7 +2674,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2573
2674
  // other column on this page so body text under the opener band
2574
2675
  // starts below it in ALL columns, not just the one it was placed in.
2575
2676
  if (vdtType === 'heading' && headingLevel !== undefined) {
2576
- const lvl = headingLevelByNumber.get(headingLevel);
2677
+ const lvl = headingLevels.forBlock(rawBlock);
2577
2678
  if (lvl?.span === 'page') {
2578
2679
  const page = doc.pages[cursor.pageIndex];
2579
2680
  for (const otherCol of page.columns) {
@@ -2633,6 +2734,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2633
2734
  if (letterSpacingPx !== undefined)
2634
2735
  blk.letterSpacing = letterSpacingPx;
2635
2736
  blk.contentIndex = blockIdx;
2737
+ stampBlockExtras(blk, rawBlock);
2636
2738
  if (partIndex === 0) {
2637
2739
  blk.headingLevel = headingLevel;
2638
2740
  if (numberPrefix)
@@ -2654,7 +2756,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2654
2756
  remainingLines = remainingLines.slice(choice.splitAt);
2655
2757
  partIndex++;
2656
2758
  pendingSpacing = 0;
2657
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2759
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2658
2760
  continue;
2659
2761
  }
2660
2762
  // choice.splitAt === 0: fall through to push whole paragraph to next column
@@ -2679,12 +2781,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2679
2781
  if (rolledBack.length > 0) {
2680
2782
  blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
2681
2783
  pendingSpacing = 0;
2682
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2784
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2683
2785
  break;
2684
2786
  }
2685
2787
  }
2686
2788
  pendingSpacing = 0;
2687
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2789
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2688
2790
  continue;
2689
2791
  }
2690
2792
  // Empty column with less than a line of room (a band cap cutting right
@@ -2692,7 +2794,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2692
2794
  // go here — move on. The next column, or a fresh page, has room.
2693
2795
  if (curCol.availableHeight < style.lineHeightPx - 0.01 && totalRemainHeight > curCol.availableHeight + 0.01) {
2694
2796
  pendingSpacing = 0;
2695
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2797
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2696
2798
  continue;
2697
2799
  }
2698
2800
  // Empty column but block still doesn't fit (block taller than page) — place anyway
@@ -2702,6 +2804,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2702
2804
  if (letterSpacingPx !== undefined)
2703
2805
  blk.letterSpacing = letterSpacingPx;
2704
2806
  blk.contentIndex = blockIdx;
2807
+ stampBlockExtras(blk, rawBlock);
2705
2808
  if (partIndex === 0)
2706
2809
  blk.headingLevel = headingLevel;
2707
2810
  if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrs)
@@ -2750,12 +2853,43 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2750
2853
  page.pageLabel = info.label;
2751
2854
  page.pageNumberFormat = info.format;
2752
2855
  }
2753
- buildHeadersAndFooters(doc);
2856
+ const restarts = pageNumberSegments
2857
+ .filter((s, i) => i > 0 && s.startAt !== undefined && s.startPageIndex < doc.pages.length)
2858
+ .map((s) => s.startPageIndex);
2859
+ if (restarts.length > 0)
2860
+ doc.pageNumberRestarts = restarts;
2861
+ buildHeadersAndFooters(doc, resourceById);
2754
2862
  doc.converged = true;
2755
2863
  doc.iterationCount = 1;
2756
2864
  return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome };
2757
2865
  }
2866
+ /** Passes a document printing its own contents gets at most, beyond the
2867
+ * first, for the page labels it prints to settle. */
2868
+ const MAX_TOC_ROUNDS = 3;
2758
2869
  export function buildDocument(content, config, cache, options) {
2870
+ // A document printing its own table of contents (`:::toc` with no
2871
+ // host-supplied outline) is laid out with the page labels of the previous
2872
+ // build until they no longer change: the contents' own length moves what
2873
+ // follows, and a numbering restart after the front matter usually settles
2874
+ // it in one extra round.
2875
+ if (content.outline === undefined) {
2876
+ const parsed = parseMarkdownMemo(extractFrontmatter(content.markdown).content);
2877
+ if (hasTocDirective(parsed)) {
2878
+ let outline = computeOutline(parsed, resolveAllConfig(config), content.continuation?.headings);
2879
+ let doc = buildDocumentBalanced({ ...content, outline }, config, cache, options);
2880
+ for (let round = 0; round < MAX_TOC_ROUNDS; round++) {
2881
+ const after = outlineFromDoc(doc, outline);
2882
+ if (sameOutline(after, outline))
2883
+ break;
2884
+ outline = after;
2885
+ doc = buildDocumentBalanced({ ...content, outline }, config, cache, options);
2886
+ }
2887
+ return doc;
2888
+ }
2889
+ }
2890
+ return buildDocumentBalanced(content, config, cache, options);
2891
+ }
2892
+ function buildDocumentBalanced(content, config, cache, options) {
2759
2893
  // Each pass reports its own progress, numbered in build order.
2760
2894
  let passIndex = 0;
2761
2895
  const onProgress = options?.onProgress;
@@ -2776,72 +2910,209 @@ export function buildDocument(content, config, cache, options) {
2776
2910
  const bandCaps = bands.bandCaps;
2777
2911
  let passCount = bands.passCount;
2778
2912
  best.doc.iterationCount = passCount;
2779
- /** Hints that produced `best` (replayed by the trailing-cap passes). */
2780
- let bestHints = { bandCaps };
2781
2913
  // --- Column balancing (vertical justification) ------------------------
2782
2914
  // 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.
2915
+ // (and the other levers) until every balanceable column ends flush with
2916
+ // the page bottom, or no further adjustment is possible. Each retry
2917
+ // recomputes the remaining gaps on the freshly placed document, so
2918
+ // split / keep-with-next decisions that shift under the new spacing are
2919
+ // accounted for. The best layout (fewest leftover gap lines) always wins
2920
+ // — a retry that regresses is discarded.
2921
+ //
2922
+ // The pages between explicit breaks (chapter openers, `:::pagebreak`)
2923
+ // are laid out independently of one another — nothing flows across such
2924
+ // a break, so a lever inside one run of pages cannot move a line of any
2925
+ // other. The loop therefore judges every run (a *segment*, see
2926
+ // `pageSegments`) on its own: a pass places the whole document, but each
2927
+ // segment keeps or rejects its share of the levers by its own score,
2928
+ // blacklists its own cascades, plateaus on its own and spends its own
2929
+ // budget of attempts; a rejected segment gets its pages back from the
2930
+ // best pass it had (`spliceSegments`). A book of thirty chapters thus
2931
+ // balances exactly as its chapters would one by one, instead of one
2932
+ // cascade anywhere costing every page of the book a pass.
2789
2933
  const balancing = best.doc.config.headings.balancing;
2790
2934
  if (!balancing.enabled)
2791
2935
  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;
2936
+ /** Levers accepted so far, over every segment (keyed by content index /
2937
+ * `balanceKey`; each key belongs to exactly one segment). */
2938
+ const applied = { lines: new Map(), loose: new Map() };
2939
+ let segments = [];
2940
+ /** (Re)derive the segments of the current best layout, keeping the
2941
+ * attempts and blacklists of the segment at the same position. */
2942
+ const resetSegments = () => {
2943
+ const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
2944
+ const prev = segments;
2945
+ segments = pageSegments(best.doc.pages.length, best.forcedBreakPages).map((range, i) => {
2946
+ const score = gapLinesIn(gaps, range);
2947
+ const old = prev[i];
2948
+ return {
2949
+ range,
2950
+ bestScore: score,
2951
+ attempts: old?.attempts ?? 0,
2952
+ done: score === 0,
2953
+ stable: score === 0,
2954
+ failedLoose: old?.failedLoose ?? new Set(),
2955
+ failedLines: old?.failedLines ?? new Set(),
2956
+ };
2957
+ });
2958
+ };
2959
+ const hintsFrom = (lines, loose, looseBudget) => {
2960
+ const extraPx = new Map();
2961
+ for (const [idx, n] of lines)
2962
+ if (n > 0)
2963
+ extraPx.set(idx, n * best.doc.baselineGrid);
2964
+ return {
2965
+ balanceExtraPx: extraPx,
2966
+ balanceLooseness: loose,
2967
+ ...(looseBudget ? { balanceLooseBudget: looseBudget } : {}),
2968
+ bandCaps,
2969
+ };
2970
+ };
2971
+ const segmentAt = (pageIndex) => segments.findIndex((s) => pageIndex >= s.range.from && pageIndex <= s.range.to);
2972
+ /** Segment owning each lever key of the current best layout. */
2973
+ const keyOwners = (gaps) => {
2974
+ const owner = new Map();
2975
+ for (const g of gaps) {
2976
+ const si = segmentAt(g.pageIndex);
2977
+ if (si < 0)
2978
+ continue;
2979
+ for (const c of g.candidates)
2980
+ owner.set(balanceKey(c.contentIndex, c.part ?? 0), si);
2981
+ }
2982
+ return owner;
2983
+ };
2984
+ /** First page each content index was placed on. */
2985
+ const pageOfContent = (doc) => {
2986
+ const m = new Map();
2987
+ for (const b of doc.blocks) {
2988
+ if (b.contentIndex !== undefined && b.pageIndex !== undefined && !m.has(b.contentIndex))
2989
+ m.set(b.contentIndex, b.pageIndex);
2990
+ }
2991
+ return m;
2992
+ };
2993
+ const inRange = (r, p) => p !== undefined && p >= r.from && p <= r.to;
2994
+ /** Whether the explicit breaks before / around a segment fell on the same
2995
+ * pages in `next` as in the best layout — the segment's pages then line
2996
+ * up and can be compared or spliced. `before` checks only the pages
2997
+ * ahead of it (an earlier segment's cascade shifts everything after). */
2998
+ const breaksMatch = (next, upTo) => {
2999
+ for (let p = 0; p < upTo; p++) {
3000
+ if (best.forcedBreakPages.has(p) !== next.forcedBreakPages.has(p))
3001
+ return false;
3002
+ }
3003
+ return true;
3004
+ };
3005
+ const startIntact = (next, s) => breaksMatch(next, s.range.from);
3006
+ const wholeIntact = (next, s, last) => {
3007
+ if (!breaksMatch(next, s.range.to + 1))
3008
+ return false;
3009
+ if (next.doc.pages.length <= s.range.to)
3010
+ return false;
3011
+ return !last || next.doc.pages.length === best.doc.pages.length;
3012
+ };
3013
+ /**
3014
+ * The best layout with the pages of `ranges` taken from `next` (whose
3015
+ * breaks line up with it there): pages, the blocks and warnings on them,
3016
+ * and the pass report entries whose block sits on them.
3017
+ */
3018
+ const spliceSegments = (next, ranges) => {
3019
+ const taken = (p) => ranges.some((r) => inRange(r, p));
3020
+ const pages = best.doc.pages.map((pg, i) => (taken(i) ? next.doc.pages[i] : pg));
3021
+ const blocks = [
3022
+ ...best.doc.blocks.filter((b) => !taken(b.pageIndex)),
3023
+ ...next.doc.blocks.filter((b) => taken(b.pageIndex)),
3024
+ ].sort((a, b) => (a.pageIndex ?? -1) - (b.pageIndex ?? -1));
3025
+ const warnings = [
3026
+ ...(best.doc.warnings ?? []).filter((w) => !taken(w.pageIndex)),
3027
+ ...(next.doc.warnings ?? []).filter((w) => taken(w.pageIndex)),
3028
+ ].sort((a, b) => a.pageIndex - b.pageIndex);
3029
+ const doc = { ...best.doc, pages, blocks, ...(warnings.length > 0 ? { warnings } : { warnings: undefined }) };
3030
+ const pageBest = pageOfContent(best.doc);
3031
+ const pageNext = pageOfContent(next.doc);
3032
+ const mergeMap = (a, b) => {
3033
+ const out = new Map();
3034
+ for (const [k, v] of a)
3035
+ if (!taken(pageBest.get(k)))
3036
+ out.set(k, v);
3037
+ for (const [k, v] of b)
3038
+ if (taken(pageNext.get(k)))
3039
+ out.set(k, v);
3040
+ return out;
3041
+ };
3042
+ const mergeSet = (a, b) => {
3043
+ const out = new Set();
3044
+ for (const k of a)
3045
+ if (!taken(pageBest.get(k)))
3046
+ out.add(k);
3047
+ for (const k of b)
3048
+ if (taken(pageNext.get(k)))
3049
+ out.add(k);
3050
+ return out;
3051
+ };
3052
+ return {
3053
+ doc,
3054
+ forcedBreakPages: best.forcedBreakPages,
3055
+ bandCapProposals: mergeMap(best.bandCapProposals, next.bandCapProposals),
3056
+ spanPlacedInBand: mergeSet(best.spanPlacedInBand, next.spanPlacedInBand),
3057
+ bandCapsApplied: mergeSet(best.bandCapsApplied, next.bandCapsApplied),
3058
+ looseOutcome: mergeMap(best.looseOutcome, next.looseOutcome),
3059
+ };
3060
+ };
2797
3061
  /**
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.
3062
+ * A rejected segment moved content across a column break somewhere in
3063
+ * its pages (a split paragraph whose head no longer fits, a float that
3064
+ * lost its slot, a lead-in that left with its list…): the pages after
3065
+ * that point re-flow, gaps open elsewhere and a span cap may miss its
3066
+ * band. The levers are meant to be local, so contain the damage: find
3067
+ * the first column of the segment whose content changed and blacklist
3068
+ * the levers this pass newly applied there (failing that, on its page;
3069
+ * failing that, in the whole segment), so the next proposal keeps the
3070
+ * working levers before it and tries again without the one that
3071
+ * cascaded. Returns whether anything was blacklisted.
2807
3072
  */
2808
- const containCascade = (next, proposal) => {
2809
- const div = firstDivergentColumn(best.doc, next.doc);
3073
+ const containCascade = (next, s, newLines, newLoose, gaps) => {
3074
+ const div = firstDivergentColumn(best.doc, next.doc, s.range);
2810
3075
  if (!div)
2811
3076
  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
3077
  if (newLines.length === 0 && newLoose.length === 0)
2815
3078
  return false;
2816
- const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
2817
3079
  const blacklist = (cands) => {
2818
3080
  let hit = false;
2819
3081
  for (const k of newLines)
2820
3082
  if (!cands || cands.has(k)) {
2821
- failedLines.add(k);
3083
+ s.failedLines.add(k);
2822
3084
  hit = true;
2823
3085
  }
2824
3086
  for (const k of newLoose)
2825
3087
  if (!cands || cands.has(k)) {
2826
- failedLoose.add(k);
3088
+ s.failedLoose.add(k);
2827
3089
  hit = true;
2828
3090
  }
2829
3091
  return hit;
2830
3092
  };
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)))
3093
+ const keysOf = (pick) => new Set(gaps.filter(pick).flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0))));
3094
+ if (blacklist(keysOf((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)))
2835
3095
  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)))
3096
+ if (blacklist(keysOf((g) => g.pageIndex === div.pageIndex)))
2840
3097
  return true;
2841
3098
  return blacklist(null);
2842
3099
  };
3100
+ let balancingPasses = 0;
2843
3101
  const balance = () => {
2844
- while (!converged && passCount < MAX_BALANCING_PASSES) {
3102
+ while (balancingPasses < MAX_BALANCING_PASSES_PER_DOCUMENT) {
3103
+ const active = segments.filter((s) => !s.done);
3104
+ if (active.length === 0)
3105
+ break;
3106
+ const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
3107
+ const owner = keyOwners(gaps);
3108
+ const failedLoose = new Set();
3109
+ const failedLines = new Set();
3110
+ for (const s of segments) {
3111
+ for (const k of s.failedLoose)
3112
+ failedLoose.add(k);
3113
+ for (const k of s.failedLines)
3114
+ failedLines.add(k);
3115
+ }
2845
3116
  const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
2846
3117
  maxLinesPerHeading: balancing.maxLinesPerHeading,
2847
3118
  stretchAfterLists: balancing.stretchAfterLists,
@@ -2854,67 +3125,114 @@ export function buildDocument(content, config, cache, options) {
2854
3125
  failedLoose,
2855
3126
  failedLines,
2856
3127
  });
2857
- if (!proposal.changed) {
3128
+ // The levers newly proposed, by segment; those of a segment that is
3129
+ // done (plateaued, out of attempts) are withdrawn from the pass.
3130
+ const newLines = [];
3131
+ const newLoose = [];
3132
+ for (const [k, n] of proposal.lines) {
3133
+ const cur = applied.lines.get(k) ?? 0;
3134
+ if (n <= cur)
3135
+ continue;
3136
+ const si = owner.get(k);
3137
+ if (si === undefined || segments[si].done) {
3138
+ if (cur > 0)
3139
+ proposal.lines.set(k, cur);
3140
+ else
3141
+ proposal.lines.delete(k);
3142
+ continue;
3143
+ }
3144
+ newLines.push(k);
3145
+ }
3146
+ for (const k of [...proposal.loose.keys()]) {
3147
+ if (applied.loose.has(k))
3148
+ continue;
3149
+ const si = owner.get(k);
3150
+ if (si === undefined || segments[si].done) {
3151
+ proposal.loose.delete(k);
3152
+ proposal.looseBudget.delete(k);
3153
+ continue;
3154
+ }
3155
+ newLoose.push(k);
3156
+ }
3157
+ const trying = new Set([...newLines, ...newLoose].map((k) => owner.get(k)));
3158
+ if (trying.size === 0) {
2858
3159
  // No stretch point can absorb the remaining gaps — stable.
2859
- converged = true;
3160
+ for (const s of active) {
3161
+ s.done = true;
3162
+ s.stable = true;
3163
+ }
2860
3164
  break;
2861
3165
  }
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);
3166
+ for (const si of trying)
3167
+ segments[si].attempts++;
3168
+ const next = runPass(hintsFrom(proposal.lines, proposal.loose, proposal.looseBudget));
2872
3169
  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))
3170
+ balancingPasses++;
3171
+ const nextGaps = collectColumnGaps(next.doc, next.forcedBreakPages);
3172
+ const capPage = pageOfContent(best.doc);
3173
+ const accepted = [];
3174
+ for (const si of trying) {
3175
+ const s = segments[si];
3176
+ // An earlier segment's cascade shifted this one's pages: the pass
3177
+ // says nothing about its levers. They are offered again once the
3178
+ // culprit is blacklisted.
3179
+ if (!startIntact(next, s)) {
3180
+ s.attempts--;
2908
3181
  continue;
2909
- if (looseFailed.length > 0 || looseWon.length > 0) {
3182
+ }
3183
+ const keysLines = newLines.filter((k) => owner.get(k) === si);
3184
+ const keysLoose = newLoose.filter((k) => owner.get(k) === si);
3185
+ // Band caps ride along unchanged; a retry that unsettles one of the
3186
+ // segment's (its span block no longer lands in the capped band, or
3187
+ // a levelled closing band spills past its cut) is a regression —
3188
+ // capped columns without their box are not a layout we may keep.
3189
+ const capsDelivered = [...bandCaps.keys()].every((i) => !inRange(s.range, capPage.get(i)) || next.spanPlacedInBand.has(i));
3190
+ const score = capsDelivered && wholeIntact(next, s, si === segments.length - 1)
3191
+ ? gapLinesIn(nextGaps, s.range)
3192
+ : Infinity;
3193
+ // Loose paragraphs that gained no line at any tracking rung are
3194
+ // blacklisted whatever the score did, and never counted as applied.
3195
+ // Candidates the pass never tried (their column's budget was met
3196
+ // first) stay eligible for a later proposal.
3197
+ const looseFailed = keysLoose.filter((k) => next.looseOutcome.get(k) === null);
3198
+ for (const k of looseFailed)
3199
+ s.failedLoose.add(k);
3200
+ const looseWon = keysLoose.filter((k) => typeof next.looseOutcome.get(k) === 'number');
3201
+ if (score < s.bestScore) {
3202
+ for (const k of keysLines)
3203
+ applied.lines.set(k, proposal.lines.get(k));
2910
3204
  for (const k of looseWon)
2911
- failedLoose.add(k);
2912
- continue;
3205
+ applied.loose.set(k, proposal.loose.get(k));
3206
+ s.bestScore = score;
3207
+ if (score === 0) {
3208
+ s.done = true;
3209
+ s.stable = true;
3210
+ }
3211
+ accepted.push(s.range);
2913
3212
  }
2914
- break;
3213
+ else if (!containCascade(next, s, keysLines, keysLoose, gaps)) {
3214
+ // Plateau or regression without a cascade to contain: retry when
3215
+ // a loose candidate was just blacklisted (the proposer falls
3216
+ // through to the next one), or when the new loose paragraphs
3217
+ // gained their lines yet the segment did not improve (the gain
3218
+ // landed elsewhere — drop them too). A pure spacing plateau means
3219
+ // the segment is done: it keeps the best layout found so far.
3220
+ if (looseFailed.length > 0 || looseWon.length > 0) {
3221
+ for (const k of looseWon)
3222
+ s.failedLoose.add(k);
3223
+ }
3224
+ else {
3225
+ s.done = true;
3226
+ }
3227
+ }
3228
+ if (!s.done && s.attempts >= MAX_BALANCING_PASSES)
3229
+ s.done = true;
2915
3230
  }
3231
+ if (accepted.length > 0)
3232
+ best = spliceSegments(next, accepted);
2916
3233
  }
2917
3234
  };
3235
+ resetSegments();
2918
3236
  balance();
2919
3237
  // --- Trailing bands (closing columns cut level) -------------------------
2920
3238
  // Once the balancing levers have settled the earlier pages, level the
@@ -2925,20 +3243,19 @@ export function buildDocument(content, config, cache, options) {
2925
3243
  // block and the cap applies. A short polish round then lets the levers
2926
3244
  // fill what the cut left short (a column ending a line under the cap).
2927
3245
  if (balancing.trailing) {
2928
- const trailing = resolveTrailingCaps(best, bandCaps, (caps) => runPass({ ...bestHints, bandCaps: caps }));
3246
+ const frozen = hintsFrom(applied.lines, applied.loose);
3247
+ const trailing = resolveTrailingCaps(best, bandCaps, (caps) => runPass({ ...frozen, bandCaps: caps }));
2929
3248
  passCount += trailing.passCount;
2930
3249
  if (trailing.result !== best) {
2931
3250
  best = trailing.result;
2932
3251
  for (const [i, cap] of trailing.caps)
2933
3252
  bandCaps.set(i, cap);
2934
- bestHints = { ...bestHints, bandCaps };
2935
- bestScore = totalGapLines(best.doc, best.forcedBreakPages);
2936
- converged = bestScore === 0;
3253
+ resetSegments();
2937
3254
  balance();
2938
3255
  }
2939
3256
  }
2940
3257
  best.doc.iterationCount = passCount;
2941
- best.doc.converged = converged || bestScore === 0;
3258
+ best.doc.converged = segments.every((s) => s.stable || s.bestScore === 0);
2942
3259
  return best.doc;
2943
3260
  }
2944
3261
  //# sourceMappingURL=build.js.map