postext 0.3.36 → 0.3.38

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 (126) hide show
  1. package/dist/__tests__/continuation.test.js +44 -0
  2. package/dist/__tests__/continuation.test.js.map +1 -1
  3. package/dist/__tests__/exports.test.js +13 -0
  4. package/dist/__tests__/exports.test.js.map +1 -1
  5. package/dist/__tests__/headingStyles.test.d.ts +2 -0
  6. package/dist/__tests__/headingStyles.test.d.ts.map +1 -0
  7. package/dist/__tests__/headingStyles.test.js +134 -0
  8. package/dist/__tests__/headingStyles.test.js.map +1 -0
  9. package/dist/__tests__/parse/containers.test.js +1 -1
  10. package/dist/__tests__/parse/containers.test.js.map +1 -1
  11. package/dist/__tests__/toc.test.d.ts +2 -0
  12. package/dist/__tests__/toc.test.d.ts.map +1 -0
  13. package/dist/__tests__/toc.test.js +210 -0
  14. package/dist/__tests__/toc.test.js.map +1 -0
  15. package/dist/canvas-backend/blockRender.d.ts.map +1 -1
  16. package/dist/canvas-backend/blockRender.js +21 -11
  17. package/dist/canvas-backend/blockRender.js.map +1 -1
  18. package/dist/canvas-backend/index.d.ts.map +1 -1
  19. package/dist/canvas-backend/index.js +9 -0
  20. package/dist/canvas-backend/index.js.map +1 -1
  21. package/dist/defaults/headerFooter.d.ts.map +1 -1
  22. package/dist/defaults/headerFooter.js +17 -0
  23. package/dist/defaults/headerFooter.js.map +1 -1
  24. package/dist/defaults/headingStyles.d.ts +10 -0
  25. package/dist/defaults/headingStyles.d.ts.map +1 -0
  26. package/dist/defaults/headingStyles.js +85 -0
  27. package/dist/defaults/headingStyles.js.map +1 -0
  28. package/dist/defaults/headings.d.ts +6 -1
  29. package/dist/defaults/headings.d.ts.map +1 -1
  30. package/dist/defaults/headings.js +32 -0
  31. package/dist/defaults/headings.js.map +1 -1
  32. package/dist/defaults/index.d.ts +2 -0
  33. package/dist/defaults/index.d.ts.map +1 -1
  34. package/dist/defaults/index.js +18 -0
  35. package/dist/defaults/index.js.map +1 -1
  36. package/dist/defaults/paragraphStyles.d.ts.map +1 -1
  37. package/dist/defaults/paragraphStyles.js +4 -1
  38. package/dist/defaults/paragraphStyles.js.map +1 -1
  39. package/dist/defaults/toc.d.ts +26 -0
  40. package/dist/defaults/toc.d.ts.map +1 -0
  41. package/dist/defaults/toc.js +167 -0
  42. package/dist/defaults/toc.js.map +1 -0
  43. package/dist/design/layout.d.ts +15 -3
  44. package/dist/design/layout.d.ts.map +1 -1
  45. package/dist/design/layout.js +66 -1
  46. package/dist/design/layout.js.map +1 -1
  47. package/dist/html-backend.d.ts.map +1 -1
  48. package/dist/html-backend.js +16 -5
  49. package/dist/html-backend.js.map +1 -1
  50. package/dist/index.d.ts +5 -5
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +2 -2
  53. package/dist/index.js.map +1 -1
  54. package/dist/numbering.d.ts +5 -1
  55. package/dist/numbering.d.ts.map +1 -1
  56. package/dist/numbering.js +7 -1
  57. package/dist/numbering.js.map +1 -1
  58. package/dist/parse/blockParser.d.ts.map +1 -1
  59. package/dist/parse/blockParser.js +1 -1
  60. package/dist/parse/blockParser.js.map +1 -1
  61. package/dist/parse/index.d.ts +1 -1
  62. package/dist/parse/index.d.ts.map +1 -1
  63. package/dist/parse/index.js.map +1 -1
  64. package/dist/parse/types.d.ts +26 -1
  65. package/dist/parse/types.d.ts.map +1 -1
  66. package/dist/pipeline/build.d.ts.map +1 -1
  67. package/dist/pipeline/build.js +196 -57
  68. package/dist/pipeline/build.js.map +1 -1
  69. package/dist/pipeline/buildBlockKind.d.ts +4 -0
  70. package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
  71. package/dist/pipeline/buildBlockKind.js +2 -2
  72. package/dist/pipeline/buildBlockKind.js.map +1 -1
  73. package/dist/pipeline/columnBalancing.d.ts.map +1 -1
  74. package/dist/pipeline/columnBalancing.js +3 -1
  75. package/dist/pipeline/columnBalancing.js.map +1 -1
  76. package/dist/pipeline/config.d.ts.map +1 -1
  77. package/dist/pipeline/config.js +3 -1
  78. package/dist/pipeline/config.js.map +1 -1
  79. package/dist/pipeline/continuation.d.ts +10 -1
  80. package/dist/pipeline/continuation.d.ts.map +1 -1
  81. package/dist/pipeline/continuation.js +16 -1
  82. package/dist/pipeline/continuation.js.map +1 -1
  83. package/dist/pipeline/headerFooter.d.ts +5 -2
  84. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  85. package/dist/pipeline/headerFooter.js +90 -15
  86. package/dist/pipeline/headerFooter.js.map +1 -1
  87. package/dist/pipeline/headingStyles.d.ts +58 -0
  88. package/dist/pipeline/headingStyles.d.ts.map +1 -0
  89. package/dist/pipeline/headingStyles.js +157 -0
  90. package/dist/pipeline/headingStyles.js.map +1 -0
  91. package/dist/pipeline/index.d.ts +2 -1
  92. package/dist/pipeline/index.d.ts.map +1 -1
  93. package/dist/pipeline/index.js +2 -1
  94. package/dist/pipeline/index.js.map +1 -1
  95. package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
  96. package/dist/pipeline/measureContentBlock.js +4 -0
  97. package/dist/pipeline/measureContentBlock.js.map +1 -1
  98. package/dist/pipeline/outline.d.ts +37 -0
  99. package/dist/pipeline/outline.d.ts.map +1 -0
  100. package/dist/pipeline/outline.js +148 -0
  101. package/dist/pipeline/outline.js.map +1 -0
  102. package/dist/pipeline/pageRoles.d.ts +2 -1
  103. package/dist/pipeline/pageRoles.d.ts.map +1 -1
  104. package/dist/pipeline/pageRoles.js +5 -4
  105. package/dist/pipeline/pageRoles.js.map +1 -1
  106. package/dist/pipeline/placeholders.d.ts.map +1 -1
  107. package/dist/pipeline/placeholders.js +6 -0
  108. package/dist/pipeline/placeholders.js.map +1 -1
  109. package/dist/pipeline/resourceNumbering.d.ts +3 -1
  110. package/dist/pipeline/resourceNumbering.d.ts.map +1 -1
  111. package/dist/pipeline/resourceNumbering.js +4 -2
  112. package/dist/pipeline/resourceNumbering.js.map +1 -1
  113. package/dist/pipeline/styles.d.ts +5 -2
  114. package/dist/pipeline/styles.d.ts.map +1 -1
  115. package/dist/pipeline/styles.js +7 -3
  116. package/dist/pipeline/styles.js.map +1 -1
  117. package/dist/pipeline/toc.d.ts +22 -0
  118. package/dist/pipeline/toc.d.ts.map +1 -0
  119. package/dist/pipeline/toc.js +251 -0
  120. package/dist/pipeline/toc.js.map +1 -0
  121. package/dist/types.d.ts +277 -6
  122. package/dist/types.d.ts.map +1 -1
  123. package/dist/vdt.d.ts +42 -2
  124. package/dist/vdt.d.ts.map +1 -1
  125. package/dist/vdt.js.map +1 -1
  126. 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';
@@ -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,61 @@ 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 = raw.toc.pageIndex !== undefined ? { pageIndex: raw.toc.pageIndex } : {};
935
+ }
936
+ if (raw.toc?.kind === 'part') {
937
+ blk.tocPart = {
938
+ number: raw.toc.number,
939
+ title: raw.toc.title ?? '',
940
+ pageLabel: raw.toc.pageLabel ?? '',
941
+ ...(raw.toc.palette ? { palette: raw.toc.palette } : {}),
942
+ ...(raw.toc.pageIndex !== undefined ? { pageIndex: raw.toc.pageIndex } : {}),
943
+ };
944
+ }
945
+ };
872
946
  let blockIdCounter = 0;
873
947
  let pendingSpacing = 0;
874
948
  // Pages whose break into the next page is explicit rather than natural
@@ -1029,11 +1103,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1029
1103
  if (curPage.partInfo && !pageHasContent(curPage)) {
1030
1104
  const startPageIndex = cursor.pageIndex;
1031
1105
  do {
1032
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
1106
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx);
1033
1107
  } while (cursor.pageIndex === startPageIndex);
1034
1108
  return;
1035
1109
  }
1036
- advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
1110
+ advanceToNextPageBoundary(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx);
1037
1111
  };
1038
1112
  // Page-numbering segments. The implicit first segment comes from
1039
1113
  // `cfg.page.pageNumbering`; `:::numbering` directives append more,
@@ -1047,20 +1121,34 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1047
1121
  },
1048
1122
  ];
1049
1123
  let pendingNumberingChange = null;
1050
- let lastSeenPageIndex = 0;
1051
- /** Commits any pending `:::numbering` change once we've crossed into a
1052
- * new page. Called after every block iteration. */
1124
+ /** The earliest page a pending change may number: the page the directive
1125
+ * was met on while still empty, else the page after it. */
1126
+ let pendingNumberingPage = 0;
1127
+ /** A page holding something the reader sees: column content, a float, a
1128
+ * part opener. A parity blank is not one. */
1129
+ const pageTakesNumbering = (page) => pageHasContent(page) || (page.floats?.length ?? 0) > 0 || page.partInfo !== undefined;
1130
+ /** Commits a pending `:::numbering` change to the first page from the
1131
+ * directive on that receives content: the page the directive opens (the
1132
+ * head of a chapter, right after a page break) or, when that page was
1133
+ * already written on, the next one — never a blank page padding parity
1134
+ * in between. Called after every block iteration and page advance. */
1053
1135
  const flushPendingNumberingAtBoundary = () => {
1054
- if (cursor.pageIndex > lastSeenPageIndex) {
1055
- if (pendingNumberingChange) {
1056
- pageNumberSegments.push({
1057
- startPageIndex: cursor.pageIndex,
1058
- ...pendingNumberingChange,
1059
- });
1060
- pendingNumberingChange = null;
1061
- }
1062
- lastSeenPageIndex = cursor.pageIndex;
1063
- }
1136
+ if (!pendingNumberingChange)
1137
+ return;
1138
+ if (cursor.pageIndex < pendingNumberingPage)
1139
+ return;
1140
+ const page = doc.pages[cursor.pageIndex];
1141
+ if (!pageTakesNumbering(page))
1142
+ return;
1143
+ // A change already recorded for this page is replaced.
1144
+ const last = pageNumberSegments[pageNumberSegments.length - 1];
1145
+ if (last.startPageIndex === cursor.pageIndex && pageNumberSegments.length > 1)
1146
+ pageNumberSegments.pop();
1147
+ pageNumberSegments.push({
1148
+ startPageIndex: cursor.pageIndex,
1149
+ ...pendingNumberingChange,
1150
+ });
1151
+ pendingNumberingChange = null;
1064
1152
  };
1065
1153
  /** Heading blocks that are not part of a callout — the only ones the
1066
1154
  * keep-with-next rollbacks may pull along (a callout is one unbreakable
@@ -1508,7 +1596,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1508
1596
  frameId = `block-${blockIdCounter++}`;
1509
1597
  const startPageIndex = cursor.pageIndex;
1510
1598
  do {
1511
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1599
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1512
1600
  } while (cursor.pageIndex === startPageIndex);
1513
1601
  forceHere = true;
1514
1602
  continue;
@@ -1580,7 +1668,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1580
1668
  pendingSpacing = 0;
1581
1669
  const startPageIndex = cursor.pageIndex;
1582
1670
  do {
1583
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1671
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1584
1672
  } while (cursor.pageIndex === startPageIndex);
1585
1673
  page = doc.pages[cursor.pageIndex];
1586
1674
  }
@@ -1610,7 +1698,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1610
1698
  continue;
1611
1699
  }
1612
1700
  if (b.type === 'heading' && b.level) {
1613
- const level = headingLevelByNumber.get(b.level);
1701
+ const level = headingLevels.forBlock(b);
1614
1702
  return level?.breakBefore?.enabled === true || level?.span === 'page';
1615
1703
  }
1616
1704
  return false;
@@ -1716,7 +1804,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1716
1804
  pendingSpacing = 0;
1717
1805
  const startPageIndex = cursor.pageIndex;
1718
1806
  do {
1719
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1807
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1720
1808
  } while (cursor.pageIndex === startPageIndex);
1721
1809
  page = doc.pages[cursor.pageIndex];
1722
1810
  fit = attempt(page, false) ?? attempt(page, true);
@@ -1854,10 +1942,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1854
1942
  pendingSpacing = 0;
1855
1943
  if (part === 0 && resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
1856
1944
  const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
1857
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1945
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1858
1946
  return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
1859
1947
  }
1860
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1948
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1861
1949
  // Try the next column afresh: it may be short too (a float band
1862
1950
  // reserved on the page it opened), or of another width (oneAndHalf).
1863
1951
  continue;
@@ -1893,7 +1981,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1893
1981
  curCol.availableHeight = Math.max(0, curCol.availableHeight - balanceBefore);
1894
1982
  }
1895
1983
  enterBand(startIdx, 0);
1896
- placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
1984
+ placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, geomResolved, contentArea, pageWidthPx, pageHeightPx);
1897
1985
  enterBand(startIdx, 0);
1898
1986
  curCol = currentColumn(doc, cursor);
1899
1987
  commitCallout(placed, startIdx, plan, curCol, part > 0 || fragment ? fragmentRange(L, from, to) : undefined);
@@ -1912,7 +2000,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1912
2000
  from = to;
1913
2001
  part++;
1914
2002
  frameId = `block-${blockIdCounter++}`;
1915
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2003
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1916
2004
  }
1917
2005
  };
1918
2006
  for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
@@ -1920,6 +2008,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1920
2008
  throw new BuildCancelledError();
1921
2009
  options?.onProgress?.({ pass: 1, blocks: blockIdx, totalBlocks: contentBlocks.length, pages: doc.pages.length });
1922
2010
  const rawBlock = contentBlocks[blockIdx];
2011
+ // The styled section this block sits in: its geometry applies to the
2012
+ // pages opened from here (a heading with `breakBefore` opens one).
2013
+ const blockSection = sectionPlan.byBlock[blockIdx];
2014
+ if (blockSection !== currentSection)
2015
+ enterSection(blockSection);
1923
2016
  // Floats whose reference landed in an earlier iteration take the first
1924
2017
  // free slot of the current page now — after their reference in reading
1925
2018
  // order. Then enqueue the floats first-referenced in this block, so the
@@ -1934,13 +2027,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1934
2027
  if (name === 'pagebreak') {
1935
2028
  pendingSpacing = 0;
1936
2029
  markForcedBreak();
1937
- advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
2030
+ advanceToNextPageBoundary(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx);
1938
2031
  const parity = attrs.parity;
1939
2032
  if (parity === 'odd'
1940
2033
  || parity === 'even'
1941
2034
  || parity === 'always-odd'
1942
2035
  || parity === 'always-even') {
1943
- enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
2036
+ enforcePageParity(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, parity);
1944
2037
  }
1945
2038
  // Pending floats land on the page that follows the break — after
1946
2039
  // parity padding, so a blank parity page never carries a float.
@@ -1959,7 +2052,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1959
2052
  const page = doc.pages[cursor.pageIndex];
1960
2053
  if (cursor.columnIndex === page.columns.length - 1)
1961
2054
  markForcedBreak();
1962
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2055
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
1963
2056
  flushPendingNumberingAtBoundary();
1964
2057
  }
1965
2058
  }
@@ -1973,8 +2066,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1973
2066
  if (Number.isInteger(n) && n >= 1)
1974
2067
  change.startAt = n;
1975
2068
  }
1976
- if (Object.keys(change).length > 0)
2069
+ if (Object.keys(change).length > 0) {
1977
2070
  pendingNumberingChange = change;
2071
+ pendingNumberingPage = pageTakesNumbering(doc.pages[cursor.pageIndex]) ? cursor.pageIndex + 1 : cursor.pageIndex;
2072
+ }
1978
2073
  }
1979
2074
  continue;
1980
2075
  }
@@ -1997,7 +2092,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1997
2092
  pendingSpacing = 0;
1998
2093
  closeFlowSegment(blockIdx);
1999
2094
  leaveCurrentPage();
2000
- enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, resolved.parts.breakBefore.parity);
2095
+ enforcePageParity(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, resolved.parts.breakBefore.parity);
2001
2096
  cursor.columnIndex = 0;
2002
2097
  // Map the opener's title back to the `title="…"` attribute of the
2003
2098
  // fence so the editor can place the cursor from a click on the band.
@@ -2034,7 +2129,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2034
2129
  pendingSpacing = 0;
2035
2130
  closeFlowSegment(blockIdx);
2036
2131
  leaveCurrentPage();
2037
- enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
2132
+ enforcePageParity(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, parity);
2038
2133
  flushPendingNumberingAtBoundary();
2039
2134
  }
2040
2135
  if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'callout') {
@@ -2082,14 +2177,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2082
2177
  }
2083
2178
  // --- Heading `breakBefore` ----------------------------------------
2084
2179
  if (rawBlock.type === 'heading' && rawBlock.level) {
2085
- const level = headingLevelByNumber.get(rawBlock.level);
2180
+ const level = headingLevels.forBlock(rawBlock);
2086
2181
  const bb = level?.breakBefore;
2087
2182
  if (bb && bb.enabled) {
2088
2183
  pendingSpacing = 0;
2089
2184
  closeFlowSegment(blockIdx);
2090
- advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
2185
+ advanceToNextPageBoundary(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx);
2091
2186
  if (bb.parity !== 'any') {
2092
- enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, bb.parity);
2187
+ enforcePageParity(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, bb.parity);
2093
2188
  }
2094
2189
  flushPendingNumberingAtBoundary();
2095
2190
  }
@@ -2100,7 +2195,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2100
2195
  if (level?.span === 'page') {
2101
2196
  pendingSpacing = 0;
2102
2197
  closeFlowSegment(blockIdx);
2103
- advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
2198
+ advanceToNextPageBoundary(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx);
2104
2199
  cursor.columnIndex = 0;
2105
2200
  flushPendingNumberingAtBoundary();
2106
2201
  }
@@ -2117,7 +2212,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2117
2212
  // Measure against the current column width. `null` means there is nothing
2118
2213
  // to place inline (empty text, unknown resource id, floated resource).
2119
2214
  const col = currentColumn(doc, cursor);
2120
- const blockMeasureCtx = partPlan.byBlock[blockIdx] ? partMeasureCtx : measureCtx;
2215
+ const blockMeasureCtx = partPlan.byBlock[blockIdx] ? partMeasureCtx : sectionMeasureCtx(sectionPlan.byBlock[blockIdx]);
2121
2216
  const styleOverride = paragraphContainer
2122
2217
  ? (isContainerTail ? paragraphContainer.tailStyle : paragraphContainer.style)
2123
2218
  : undefined;
@@ -2151,6 +2246,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2151
2246
  const groupHeight = measured.totalHeight;
2152
2247
  const blk = createVDTBlock(id, 'resource', style.fontString, style.color, style.textAlign);
2153
2248
  blk.contentIndex = blockIdx;
2249
+ stampBlockExtras(blk, rawBlock);
2154
2250
  blk.resourceBlock = resourceBlock;
2155
2251
  blk.dirty = false;
2156
2252
  blk.snappedToGrid = false;
@@ -2168,7 +2264,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2168
2264
  }];
2169
2265
  const spacingBefore = pendingSpacing;
2170
2266
  enterBand(blockIdx, 0);
2171
- placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
2267
+ placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, geomResolved, contentArea, pageWidthPx, pageHeightPx);
2172
2268
  enterBand(blockIdx, 0);
2173
2269
  // `placeBlockInColumn` (inside placeAtomicBlock) shifts `blk.lines`; the
2174
2270
  // resource's own caption/table lines live on `resourceBlock` and must be
@@ -2234,10 +2330,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2234
2330
  // paragraph of a `:::paragraphs` container, whose leading and spacing
2235
2331
  // are off-grid by design.
2236
2332
  const nextIsHeading = nextBlock?.type === 'heading';
2237
- const shouldSnapToGrid = (vdtType === 'heading' && !nextIsHeading) ||
2333
+ // The contents (`:::toc`) keep their own rhythm: an entry set as a list
2334
+ // item is not a list tail to realign the text after it.
2335
+ const shouldSnapToGrid = rawBlock.toc === undefined && ((vdtType === 'heading' && !nextIsHeading) ||
2238
2336
  (vdtType === 'listItem' && !nextIsListItem) ||
2239
2337
  (vdtType === 'paragraph' && isContainerTail) ||
2240
- vdtType === 'mathDisplay';
2338
+ vdtType === 'mathDisplay');
2241
2339
  // Place block, splitting across columns/pages if needed.
2242
2340
  // List items may split too — orphan/widow protection per-list is gated by
2243
2341
  // `avoidOrphansInLists` / `avoidWidowsInLists`; bullet stays on first part.
@@ -2290,6 +2388,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2290
2388
  spacingBefore = Math.max(spacingBefore, style.marginTopPx);
2291
2389
  }
2292
2390
  }
2391
+ else if (rawBlock.toc) {
2392
+ // A part row or an unnumbered entry of the contents: its top
2393
+ // margin (`toc.parts.marginTop`, the level's `marginTop`) applies
2394
+ // like a heading's.
2395
+ spacingBefore = Math.max(spacingBefore, style.marginTopPx);
2396
+ }
2293
2397
  // Column balancing: extra grid lines above a non-heading balance
2294
2398
  // target — the first block after a list end. Heading targets are
2295
2399
  // handled inside the heading branch above (after margin collapsing).
@@ -2328,7 +2432,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2328
2432
  // subsequent marginBottom + grid snap) starts from there.
2329
2433
  let effectiveRemainHeight = totalRemainHeight;
2330
2434
  if (vdtType === 'heading' && headingLevel !== undefined && partIndex === 0) {
2331
- const lvl = headingLevelByNumber.get(headingLevel);
2435
+ const lvl = headingLevels.forBlock(rawBlock);
2332
2436
  if (lvl) {
2333
2437
  const full = remainingLines
2334
2438
  .map((ln) => (ln.segments ?? []).map((s) => s.text).join(''))
@@ -2382,6 +2486,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2382
2486
  if (letterSpacingPx !== undefined)
2383
2487
  blk.letterSpacing = letterSpacingPx;
2384
2488
  blk.contentIndex = blockIdx;
2489
+ stampBlockExtras(blk, rawBlock);
2385
2490
  blk.headingLevel = headingLevel;
2386
2491
  if (numberPrefix)
2387
2492
  blk.numberPrefix = numberPrefix;
@@ -2397,7 +2502,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2397
2502
  remainingLines = remainingLines.slice(splitAt);
2398
2503
  partIndex++;
2399
2504
  pendingSpacing = 0;
2400
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2505
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2401
2506
  continue;
2402
2507
  }
2403
2508
  // Can't cleanly split the colon line off — would create a widow.
@@ -2429,12 +2534,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2429
2534
  // rolled-back heading (marker blocks in between are replayed).
2430
2535
  blockIdx = (popped[0].contentIndex ?? blockIdx - headingRunCount) - 1;
2431
2536
  pendingSpacing = 0;
2432
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2537
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2433
2538
  break;
2434
2539
  }
2435
2540
  if (headingRunCount === 0) {
2436
2541
  pendingSpacing = 0;
2437
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2542
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2438
2543
  continue;
2439
2544
  }
2440
2545
  // headingRunCount === curCol.blocks.length: fall through to place.
@@ -2490,11 +2595,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2490
2595
  // rolled-back heading (marker blocks in between are replayed).
2491
2596
  blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
2492
2597
  pendingSpacing = 0;
2493
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2598
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2494
2599
  break;
2495
2600
  }
2496
2601
  pendingSpacing = 0;
2497
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2602
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2498
2603
  continue;
2499
2604
  }
2500
2605
  }
@@ -2508,6 +2613,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2508
2613
  if (letterSpacingPx !== undefined)
2509
2614
  blk.letterSpacing = letterSpacingPx;
2510
2615
  blk.contentIndex = blockIdx;
2616
+ stampBlockExtras(blk, rawBlock);
2511
2617
  if (partIndex === 0) {
2512
2618
  blk.headingLevel = headingLevel;
2513
2619
  if (numberPrefix)
@@ -2573,7 +2679,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2573
2679
  // other column on this page so body text under the opener band
2574
2680
  // starts below it in ALL columns, not just the one it was placed in.
2575
2681
  if (vdtType === 'heading' && headingLevel !== undefined) {
2576
- const lvl = headingLevelByNumber.get(headingLevel);
2682
+ const lvl = headingLevels.forBlock(rawBlock);
2577
2683
  if (lvl?.span === 'page') {
2578
2684
  const page = doc.pages[cursor.pageIndex];
2579
2685
  for (const otherCol of page.columns) {
@@ -2633,6 +2739,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2633
2739
  if (letterSpacingPx !== undefined)
2634
2740
  blk.letterSpacing = letterSpacingPx;
2635
2741
  blk.contentIndex = blockIdx;
2742
+ stampBlockExtras(blk, rawBlock);
2636
2743
  if (partIndex === 0) {
2637
2744
  blk.headingLevel = headingLevel;
2638
2745
  if (numberPrefix)
@@ -2654,7 +2761,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2654
2761
  remainingLines = remainingLines.slice(choice.splitAt);
2655
2762
  partIndex++;
2656
2763
  pendingSpacing = 0;
2657
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2764
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2658
2765
  continue;
2659
2766
  }
2660
2767
  // choice.splitAt === 0: fall through to push whole paragraph to next column
@@ -2679,12 +2786,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2679
2786
  if (rolledBack.length > 0) {
2680
2787
  blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
2681
2788
  pendingSpacing = 0;
2682
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2789
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2683
2790
  break;
2684
2791
  }
2685
2792
  }
2686
2793
  pendingSpacing = 0;
2687
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2794
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2688
2795
  continue;
2689
2796
  }
2690
2797
  // Empty column with less than a line of room (a band cap cutting right
@@ -2692,7 +2799,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2692
2799
  // go here — move on. The next column, or a fresh page, has room.
2693
2800
  if (curCol.availableHeight < style.lineHeightPx - 0.01 && totalRemainHeight > curCol.availableHeight + 0.01) {
2694
2801
  pendingSpacing = 0;
2695
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2802
+ advanceToNextColumn(doc, cursor, geomResolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
2696
2803
  continue;
2697
2804
  }
2698
2805
  // Empty column but block still doesn't fit (block taller than page) — place anyway
@@ -2702,6 +2809,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2702
2809
  if (letterSpacingPx !== undefined)
2703
2810
  blk.letterSpacing = letterSpacingPx;
2704
2811
  blk.contentIndex = blockIdx;
2812
+ stampBlockExtras(blk, rawBlock);
2705
2813
  if (partIndex === 0)
2706
2814
  blk.headingLevel = headingLevel;
2707
2815
  if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrs)
@@ -2750,12 +2858,43 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
2750
2858
  page.pageLabel = info.label;
2751
2859
  page.pageNumberFormat = info.format;
2752
2860
  }
2753
- buildHeadersAndFooters(doc);
2861
+ const restarts = pageNumberSegments
2862
+ .filter((s, i) => i > 0 && s.startAt !== undefined && s.startPageIndex < doc.pages.length)
2863
+ .map((s) => s.startPageIndex);
2864
+ if (restarts.length > 0)
2865
+ doc.pageNumberRestarts = restarts;
2866
+ buildHeadersAndFooters(doc, resourceById);
2754
2867
  doc.converged = true;
2755
2868
  doc.iterationCount = 1;
2756
2869
  return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome };
2757
2870
  }
2871
+ /** Passes a document printing its own contents gets at most, beyond the
2872
+ * first, for the page labels it prints to settle. */
2873
+ const MAX_TOC_ROUNDS = 3;
2758
2874
  export function buildDocument(content, config, cache, options) {
2875
+ // A document printing its own table of contents (`:::toc` with no
2876
+ // host-supplied outline) is laid out with the page labels of the previous
2877
+ // build until they no longer change: the contents' own length moves what
2878
+ // follows, and a numbering restart after the front matter usually settles
2879
+ // it in one extra round.
2880
+ if (content.outline === undefined) {
2881
+ const parsed = parseMarkdownMemo(extractFrontmatter(content.markdown).content);
2882
+ if (hasTocDirective(parsed)) {
2883
+ let outline = computeOutline(parsed, resolveAllConfig(config), content.continuation?.headings);
2884
+ let doc = buildDocumentBalanced({ ...content, outline }, config, cache, options);
2885
+ for (let round = 0; round < MAX_TOC_ROUNDS; round++) {
2886
+ const after = outlineFromDoc(doc, outline);
2887
+ if (sameOutline(after, outline))
2888
+ break;
2889
+ outline = after;
2890
+ doc = buildDocumentBalanced({ ...content, outline }, config, cache, options);
2891
+ }
2892
+ return doc;
2893
+ }
2894
+ }
2895
+ return buildDocumentBalanced(content, config, cache, options);
2896
+ }
2897
+ function buildDocumentBalanced(content, config, cache, options) {
2759
2898
  // Each pass reports its own progress, numbered in build order.
2760
2899
  let passIndex = 0;
2761
2900
  const onProgress = options?.onProgress;