postext 0.3.15 → 0.3.17

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 (175) hide show
  1. package/dist/__tests__/createLayout.test.js +12 -13
  2. package/dist/__tests__/createLayout.test.js.map +1 -1
  3. package/dist/__tests__/defaults/resourceTypes.test.d.ts +2 -0
  4. package/dist/__tests__/defaults/resourceTypes.test.d.ts.map +1 -0
  5. package/dist/__tests__/defaults/resourceTypes.test.js +69 -0
  6. package/dist/__tests__/defaults/resourceTypes.test.js.map +1 -0
  7. package/dist/__tests__/exports.test.js +54 -0
  8. package/dist/__tests__/exports.test.js.map +1 -1
  9. package/dist/__tests__/parse/inlineRef.test.d.ts +2 -0
  10. package/dist/__tests__/parse/inlineRef.test.d.ts.map +1 -0
  11. package/dist/__tests__/parse/inlineRef.test.js +83 -0
  12. package/dist/__tests__/parse/inlineRef.test.js.map +1 -0
  13. package/dist/__tests__/parse/resourceDirective.test.d.ts +2 -0
  14. package/dist/__tests__/parse/resourceDirective.test.d.ts.map +1 -0
  15. package/dist/__tests__/parse/resourceDirective.test.js +55 -0
  16. package/dist/__tests__/parse/resourceDirective.test.js.map +1 -0
  17. package/dist/__tests__/pipeline/floatPlacement.test.d.ts +2 -0
  18. package/dist/__tests__/pipeline/floatPlacement.test.d.ts.map +1 -0
  19. package/dist/__tests__/pipeline/floatPlacement.test.js +141 -0
  20. package/dist/__tests__/pipeline/floatPlacement.test.js.map +1 -0
  21. package/dist/__tests__/pipeline/inlineRefRender.test.d.ts +2 -0
  22. package/dist/__tests__/pipeline/inlineRefRender.test.d.ts.map +1 -0
  23. package/dist/__tests__/pipeline/inlineRefRender.test.js +107 -0
  24. package/dist/__tests__/pipeline/inlineRefRender.test.js.map +1 -0
  25. package/dist/__tests__/pipeline/resourceNumbering.test.d.ts +2 -0
  26. package/dist/__tests__/pipeline/resourceNumbering.test.d.ts.map +1 -0
  27. package/dist/__tests__/pipeline/resourceNumbering.test.js +186 -0
  28. package/dist/__tests__/pipeline/resourceNumbering.test.js.map +1 -0
  29. package/dist/__tests__/table/model.test.d.ts +2 -0
  30. package/dist/__tests__/table/model.test.d.ts.map +1 -0
  31. package/dist/__tests__/table/model.test.js +187 -0
  32. package/dist/__tests__/table/model.test.js.map +1 -0
  33. package/dist/canvas-backend/blockRender.d.ts.map +1 -1
  34. package/dist/canvas-backend/blockRender.js +25 -6
  35. package/dist/canvas-backend/blockRender.js.map +1 -1
  36. package/dist/canvas-backend/headerFooter.d.ts +3 -2
  37. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  38. package/dist/canvas-backend/headerFooter.js +63 -2
  39. package/dist/canvas-backend/headerFooter.js.map +1 -1
  40. package/dist/canvas-backend/index.d.ts +2 -0
  41. package/dist/canvas-backend/index.d.ts.map +1 -1
  42. package/dist/canvas-backend/index.js +11 -0
  43. package/dist/canvas-backend/index.js.map +1 -1
  44. package/dist/canvas-backend/renderResourceBlock.d.ts +28 -0
  45. package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -0
  46. package/dist/canvas-backend/renderResourceBlock.js +146 -0
  47. package/dist/canvas-backend/renderResourceBlock.js.map +1 -0
  48. package/dist/defaults/bodyText.d.ts.map +1 -1
  49. package/dist/defaults/bodyText.js +30 -0
  50. package/dist/defaults/bodyText.js.map +1 -1
  51. package/dist/defaults/captionStyle.d.ts +9 -0
  52. package/dist/defaults/captionStyle.d.ts.map +1 -0
  53. package/dist/defaults/captionStyle.js +74 -0
  54. package/dist/defaults/captionStyle.js.map +1 -0
  55. package/dist/defaults/debug.d.ts.map +1 -1
  56. package/dist/defaults/debug.js +6 -0
  57. package/dist/defaults/debug.js.map +1 -1
  58. package/dist/defaults/headerFooter.d.ts +20 -18
  59. package/dist/defaults/headerFooter.d.ts.map +1 -1
  60. package/dist/defaults/headerFooter.js +269 -165
  61. package/dist/defaults/headerFooter.js.map +1 -1
  62. package/dist/defaults/headings.d.ts.map +1 -1
  63. package/dist/defaults/headings.js +29 -6
  64. package/dist/defaults/headings.js.map +1 -1
  65. package/dist/defaults/index.d.ts +3 -0
  66. package/dist/defaults/index.d.ts.map +1 -1
  67. package/dist/defaults/index.js +19 -0
  68. package/dist/defaults/index.js.map +1 -1
  69. package/dist/defaults/resourceTypes.d.ts +8 -0
  70. package/dist/defaults/resourceTypes.d.ts.map +1 -0
  71. package/dist/defaults/resourceTypes.js +43 -0
  72. package/dist/defaults/resourceTypes.js.map +1 -0
  73. package/dist/defaults/shared.d.ts.map +1 -1
  74. package/dist/defaults/shared.js +30 -0
  75. package/dist/defaults/shared.js.map +1 -1
  76. package/dist/defaults/tableStyle.d.ts +11 -0
  77. package/dist/defaults/tableStyle.d.ts.map +1 -0
  78. package/dist/defaults/tableStyle.js +114 -0
  79. package/dist/defaults/tableStyle.js.map +1 -0
  80. package/dist/design/layout.d.ts +94 -0
  81. package/dist/design/layout.d.ts.map +1 -0
  82. package/dist/design/layout.js +642 -0
  83. package/dist/design/layout.js.map +1 -0
  84. package/dist/design/placeholders.d.ts +29 -0
  85. package/dist/design/placeholders.d.ts.map +1 -0
  86. package/dist/design/placeholders.js +126 -0
  87. package/dist/design/placeholders.js.map +1 -0
  88. package/dist/html-backend.d.ts.map +1 -1
  89. package/dist/html-backend.js +91 -1
  90. package/dist/html-backend.js.map +1 -1
  91. package/dist/index.d.ts +12 -5
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +6 -2
  94. package/dist/index.js.map +1 -1
  95. package/dist/knuthPlass/richAdapter.d.ts +1 -0
  96. package/dist/knuthPlass/richAdapter.d.ts.map +1 -1
  97. package/dist/knuthPlass/richAdapter.js +2 -1
  98. package/dist/knuthPlass/richAdapter.js.map +1 -1
  99. package/dist/measure/cache.js +1 -1
  100. package/dist/measure/cache.js.map +1 -1
  101. package/dist/measure/rich.d.ts +7 -0
  102. package/dist/measure/rich.d.ts.map +1 -1
  103. package/dist/measure/rich.js +30 -0
  104. package/dist/measure/rich.js.map +1 -1
  105. package/dist/numbering.d.ts +16 -0
  106. package/dist/numbering.d.ts.map +1 -1
  107. package/dist/numbering.js +28 -18
  108. package/dist/numbering.js.map +1 -1
  109. package/dist/parse/blockParser.d.ts.map +1 -1
  110. package/dist/parse/blockParser.js +37 -9
  111. package/dist/parse/blockParser.js.map +1 -1
  112. package/dist/parse/inlineFormatting.d.ts +38 -0
  113. package/dist/parse/inlineFormatting.d.ts.map +1 -1
  114. package/dist/parse/inlineFormatting.js +84 -0
  115. package/dist/parse/inlineFormatting.js.map +1 -1
  116. package/dist/parse/sourceMapping.d.ts.map +1 -1
  117. package/dist/parse/sourceMapping.js +20 -0
  118. package/dist/parse/sourceMapping.js.map +1 -1
  119. package/dist/parse/types.d.ts +20 -1
  120. package/dist/parse/types.d.ts.map +1 -1
  121. package/dist/pipeline/build.d.ts.map +1 -1
  122. package/dist/pipeline/build.js +389 -17
  123. package/dist/pipeline/build.js.map +1 -1
  124. package/dist/pipeline/buildBlockKind.d.ts +14 -0
  125. package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
  126. package/dist/pipeline/buildBlockKind.js +16 -1
  127. package/dist/pipeline/buildBlockKind.js.map +1 -1
  128. package/dist/pipeline/buildHelpers.d.ts.map +1 -1
  129. package/dist/pipeline/buildHelpers.js +7 -1
  130. package/dist/pipeline/buildHelpers.js.map +1 -1
  131. package/dist/pipeline/buildMeasurement.d.ts +17 -1
  132. package/dist/pipeline/buildMeasurement.d.ts.map +1 -1
  133. package/dist/pipeline/buildMeasurement.js +32 -0
  134. package/dist/pipeline/buildMeasurement.js.map +1 -1
  135. package/dist/pipeline/config.d.ts.map +1 -1
  136. package/dist/pipeline/config.js +3 -1
  137. package/dist/pipeline/config.js.map +1 -1
  138. package/dist/pipeline/floatPlacement.d.ts +45 -0
  139. package/dist/pipeline/floatPlacement.d.ts.map +1 -0
  140. package/dist/pipeline/floatPlacement.js +68 -0
  141. package/dist/pipeline/floatPlacement.js.map +1 -0
  142. package/dist/pipeline/headerFooter.d.ts +23 -7
  143. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  144. package/dist/pipeline/headerFooter.js +258 -100
  145. package/dist/pipeline/headerFooter.js.map +1 -1
  146. package/dist/pipeline/placeholders.d.ts +6 -0
  147. package/dist/pipeline/placeholders.d.ts.map +1 -1
  148. package/dist/pipeline/placeholders.js +46 -0
  149. package/dist/pipeline/placeholders.js.map +1 -1
  150. package/dist/pipeline/placement.d.ts +15 -2
  151. package/dist/pipeline/placement.d.ts.map +1 -1
  152. package/dist/pipeline/placement.js +38 -3
  153. package/dist/pipeline/placement.js.map +1 -1
  154. package/dist/pipeline/resourceLayout.d.ts +58 -0
  155. package/dist/pipeline/resourceLayout.d.ts.map +1 -0
  156. package/dist/pipeline/resourceLayout.js +338 -0
  157. package/dist/pipeline/resourceLayout.js.map +1 -0
  158. package/dist/pipeline/resourceNumbering.d.ts +54 -0
  159. package/dist/pipeline/resourceNumbering.d.ts.map +1 -0
  160. package/dist/pipeline/resourceNumbering.js +218 -0
  161. package/dist/pipeline/resourceNumbering.js.map +1 -0
  162. package/dist/pipeline/styles.d.ts +6 -0
  163. package/dist/pipeline/styles.d.ts.map +1 -1
  164. package/dist/pipeline/styles.js +1 -1
  165. package/dist/pipeline/styles.js.map +1 -1
  166. package/dist/table/model.d.ts +53 -0
  167. package/dist/table/model.d.ts.map +1 -0
  168. package/dist/table/model.js +253 -0
  169. package/dist/table/model.js.map +1 -0
  170. package/dist/types.d.ts +397 -41
  171. package/dist/types.d.ts.map +1 -1
  172. package/dist/vdt.d.ts +165 -18
  173. package/dist/vdt.d.ts.map +1 -1
  174. package/dist/vdt.js.map +1 -1
  175. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { dimensionToPx } from '../units';
2
- import { createVDTDocument, createVDTBlock, } from '../vdt';
2
+ import { createVDTDocument, createVDTBlock, createBoundingBox, } from '../vdt';
3
3
  import { parseMarkdownMemo } from '../parse';
4
4
  import { buildPageLabels, computeHeadingNumbers, } from '../numbering';
5
5
  import { extractFrontmatter } from '../frontmatter';
@@ -7,12 +7,16 @@ import { initHyphenator } from '../measure';
7
7
  import { resolveAllConfig, computeBaselineGrid } from './config';
8
8
  import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
9
9
  import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
10
- import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, } from './placement';
10
+ import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeResourceBlock, } from './placement';
11
11
  import { chooseParagraphSplit } from './orphanWidow';
12
12
  import { applyStyleAttrs, computeMeasureViewport, computePageMetrics, enrichMathSpans, rollbackTrailingBlocks, stampSourceRanges, } from './buildHelpers';
13
13
  import { resolveBlockKind } from './buildBlockKind';
14
14
  import { runMeasurement } from './buildMeasurement';
15
- import { buildHeadersAndFooters } from './headerFooter';
15
+ import { resolveRefSpans, layoutResourceBlock } from './resourceLayout';
16
+ import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
17
+ import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
18
+ import { defaultResourceTypes } from '../defaults/resourceTypes';
19
+ import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
16
20
  export class BuildCancelledError extends Error {
17
21
  constructor() {
18
22
  super('Build cancelled');
@@ -46,12 +50,209 @@ export function buildDocument(content, config, cache, options) {
46
50
  }
47
51
  }
48
52
  const headingPrefixes = computeHeadingNumbers(contentBlocks, headingTemplates);
53
+ // Resource numbering — computed up front (before the placement loop) so that
54
+ // captions and inline `:ref`s can resolve their rendered number strings
55
+ // before measurement. Numbering follows order of first reference in the
56
+ // document.
57
+ const resourceTypes = config?.resourceTypes ?? defaultResourceTypes();
58
+ const resources = content.resources ?? [];
59
+ const headingContext = computeHeadingContext(contentBlocks);
60
+ const resourceNumbering = computeResourceNumbering(contentBlocks, resourceTypes, resources, headingContext);
61
+ // Lookups threaded into block-kind resolution + measurement.
62
+ const resourceById = new Map();
63
+ for (const r of resources)
64
+ resourceById.set(r.id, r);
65
+ const resourceTypeById = new Map();
66
+ for (const t of resourceTypes)
67
+ resourceTypeById.set(t.id, t);
68
+ const resourceNumberById = new Map();
69
+ for (const [id, entry] of Object.entries(resourceNumbering)) {
70
+ resourceNumberById.set(id, entry.number);
71
+ }
49
72
  // Resolve styles
50
73
  const bodyStyle = resolveBodyStyle(resolved);
51
74
  const blockquoteStyle = resolveBlockquoteStyle(resolved);
52
75
  const listLevelIndentsPx = computeLevelIndentsPx(resolved, bodyStyle.fontSizePx);
53
76
  const orderedMetrics = computeOrderedListRunMetrics(contentBlocks, resolved, bodyStyle.fontSizePx);
54
77
  const orderedLevelIndentsPx = computeOrderedLevelIndentsPx(resolved, bodyStyle.fontSizePx, orderedMetrics.maxWidthByDepth);
78
+ // --- Float planning (issue #49 — resources float to page bands) ----------
79
+ // A resource is incorporated by its first reference (an inline `:ref` or a
80
+ // `::resource` directive, whichever comes first in reading order). Floated
81
+ // resources detach from the running text and reserve a band at the top or
82
+ // bottom of the next page opened after that reference; the text flows past
83
+ // the reference uninterrupted. `position: 'here'` resources keep inline
84
+ // `::resource` placement and are not floated.
85
+ const floatPlan = computeFloatPlan(contentBlocks, resources, resourceTypes);
86
+ const floatedIds = floatedResourceIds(floatPlan);
87
+ const floatsByFirstBlock = new Map();
88
+ for (const f of floatPlan) {
89
+ const list = floatsByFirstBlock.get(f.firstBlockIdx);
90
+ if (list)
91
+ list.push(f);
92
+ else
93
+ floatsByFirstBlock.set(f.firstBlockIdx, [f]);
94
+ }
95
+ // Floats whose first reference has been passed but which are not yet placed
96
+ // into a page band, in reading order.
97
+ const pendingFloats = [];
98
+ const floatGapPx = bodyStyle.lineHeightPx;
99
+ const minTextPx = bodyStyle.lineHeightPx * 3;
100
+ /** Offset a resolved resource block's caption/table geometry from
101
+ * block-relative to absolute page coordinates (mirrors inline placement). */
102
+ const offsetResourceBlockToAbsolute = (rb, ox, oy) => {
103
+ for (const ln of rb.captionLines) {
104
+ ln.bbox.x += ox;
105
+ ln.bbox.y += oy;
106
+ ln.baseline += oy;
107
+ }
108
+ if (rb.table) {
109
+ for (const cell of rb.table.cells) {
110
+ cell.rect.x += ox;
111
+ cell.rect.y += oy;
112
+ for (const cl of cell.lines) {
113
+ cl.bbox.x += ox;
114
+ cl.bbox.y += oy;
115
+ cl.baseline += oy;
116
+ }
117
+ }
118
+ }
119
+ };
120
+ /** Measure + build a float block at horizontal offset `x` (y = 0), or null
121
+ * when the resource id is unknown. Caller offsets it to its final `y`. */
122
+ const buildFloatBlock = (resourceId, x, width) => {
123
+ const resource = resourceById.get(resourceId);
124
+ if (!resource)
125
+ return null;
126
+ const { block: rb, totalHeight } = layoutResourceBlock({
127
+ resource,
128
+ resourceType: resourceTypeById.get(resource.typeId),
129
+ number: resourceNumberById.get(resourceId) ?? '',
130
+ resolved,
131
+ columnWidth: width,
132
+ resourceNumbering,
133
+ resourceTypes,
134
+ resources,
135
+ });
136
+ const blk = createVDTBlock(`float-${resourceId}`, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
137
+ blk.resourceBlock = rb;
138
+ blk.dirty = false;
139
+ blk.snappedToGrid = false;
140
+ blk.bbox = createBoundingBox(x, 0, width, totalHeight);
141
+ blk.lines = [];
142
+ offsetResourceBlockToAbsolute(rb, x, 0);
143
+ return { block: blk, height: totalHeight };
144
+ };
145
+ /** Reserve top/bottom bands on a freshly opened page and position as many
146
+ * pending floats as fit, shrinking the affected columns so body text flows
147
+ * around them. Preserves reading order: stops at the first float that does
148
+ * not fit (so figures never reorder relative to their references), except
149
+ * on a band that is still all-text, where a dominating/oversized float is
150
+ * force-placed so the queue always makes progress. */
151
+ const flushFloatsIntoPage = (page) => {
152
+ if (pendingFloats.length === 0)
153
+ return;
154
+ const topUsed = page.columns.map(() => 0);
155
+ const botUsed = page.columns.map(() => 0);
156
+ const floats = page.floats ?? [];
157
+ /** Try to place one float on this page. Returns whether it was placed,
158
+ * must be deferred (does not fit), or skipped (unknown id). Only mutates
159
+ * page geometry when it actually places. */
160
+ const attemptFloat = (f) => {
161
+ const pageSpan = f.span === 'page' && page.columns.length > 1;
162
+ let targetCols;
163
+ if (pageSpan) {
164
+ targetCols = page.columns.map((_, i) => i);
165
+ }
166
+ else {
167
+ // Single-column float: pick the column with the most room left.
168
+ let best = 0;
169
+ for (let i = 1; i < page.columns.length; i++) {
170
+ if (topUsed[i] + botUsed[i] < topUsed[best] + botUsed[best])
171
+ best = i;
172
+ }
173
+ targetCols = [best];
174
+ }
175
+ const firstCol = page.columns[targetCols[0]];
176
+ const width = pageSpan ? contentArea.width : firstCol.bbox.width;
177
+ const xLeft = pageSpan ? contentArea.x : firstCol.bbox.x;
178
+ const built = buildFloatBlock(f.resourceId, xLeft, width);
179
+ if (!built)
180
+ return 'skip';
181
+ const need = built.height + floatGapPx;
182
+ let minAvail = Infinity;
183
+ let anyReserved = false;
184
+ for (const c of targetCols) {
185
+ minAvail = Math.min(minAvail, page.columns[c].availableHeight);
186
+ if (topUsed[c] > 0 || botUsed[c] > 0)
187
+ anyReserved = true;
188
+ }
189
+ // Keep some text room, unless this band is still all-text (then a
190
+ // dominating / oversized float is force-placed so the queue progresses).
191
+ if (need > minAvail - minTextPx && anyReserved)
192
+ return 'defer';
193
+ let y = 0;
194
+ for (const c of targetCols) {
195
+ const col = page.columns[c];
196
+ if (f.position === 'top') {
197
+ y = col.bbox.y; // float sits at the current top edge
198
+ col.bbox.y += need; // push column content below the band
199
+ col.bbox.height = Math.max(0, col.bbox.height - need);
200
+ topUsed[c] += need;
201
+ }
202
+ else {
203
+ col.bbox.height = Math.max(0, col.bbox.height - need);
204
+ y = col.bbox.y + col.bbox.height + floatGapPx; // flush toward the bottom
205
+ botUsed[c] += need;
206
+ }
207
+ col.availableHeight = Math.max(0, col.availableHeight - need);
208
+ }
209
+ offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, y);
210
+ built.block.bbox = createBoundingBox(xLeft, y, width, built.height);
211
+ built.block.pageIndex = page.index;
212
+ built.block.columnIndex = targetCols[0];
213
+ floats.push(built.block);
214
+ return 'placed';
215
+ };
216
+ // Full-width (page-span) floats reserve the outermost bands first, so a
217
+ // later single-column float nests inside the remaining column space rather
218
+ // than overlapping a full-width band. Within each pass, stop at the first
219
+ // float that does not fit to preserve reading order.
220
+ for (const pageSpanPass of [true, false]) {
221
+ let i = 0;
222
+ while (i < pendingFloats.length) {
223
+ const f = pendingFloats[i];
224
+ const isPageSpan = f.span === 'page' && page.columns.length > 1;
225
+ if (isPageSpan !== pageSpanPass) {
226
+ i++;
227
+ continue;
228
+ }
229
+ const r = attemptFloat(f);
230
+ if (r === 'placed' || r === 'skip')
231
+ pendingFloats.splice(i, 1);
232
+ else
233
+ break; // defer: leave this and the rest of the pass for a later page
234
+ }
235
+ }
236
+ if (floats.length > 0)
237
+ page.floats = floats;
238
+ };
239
+ /** Drain floats still pending after body placement (referenced on the last
240
+ * page, or never followed by a content-overflow page break) onto freshly
241
+ * appended pages. Each new page force-places at least one float. */
242
+ const finalizeFloats = () => {
243
+ let guard = 0;
244
+ while (pendingFloats.length > 0 && guard++ < 1000) {
245
+ const before = pendingFloats.length;
246
+ const page = createPageWithColumns(doc.pages.length, resolved, contentArea, pageWidthPx, pageHeightPx);
247
+ doc.pages.push(page);
248
+ flushFloatsIntoPage(page);
249
+ if (pendingFloats.length === before)
250
+ break; // safety: no progress
251
+ }
252
+ };
253
+ /** Reserve floats on each freshly opened content page. Passed only to the
254
+ * content-flow column advances — parity / force-blank pages never get it. */
255
+ const onNewPage = (page) => flushFloatsIntoPage(page);
55
256
  // Placement cursor
56
257
  const cursor = { pageIndex: 0, columnIndex: 0 };
57
258
  let blockIdCounter = 0;
@@ -93,6 +294,13 @@ export function buildDocument(content, config, cache, options) {
93
294
  if (options?.shouldCancel?.())
94
295
  throw new BuildCancelledError();
95
296
  const rawBlock = contentBlocks[blockIdx];
297
+ // Enqueue floats first-referenced in this block so the next page opened
298
+ // while placing it (or any later block) reserves their band. Done before
299
+ // placement so a reference near a column/page boundary still floats onto
300
+ // the page that follows it.
301
+ const floatsHere = floatsByFirstBlock.get(blockIdx);
302
+ if (floatsHere)
303
+ pendingFloats.push(...floatsHere);
96
304
  // --- Directives ----------------------------------------------------
97
305
  if (rawBlock.type === 'directive') {
98
306
  const name = rawBlock.directiveName;
@@ -136,6 +344,16 @@ export function buildDocument(content, config, cache, options) {
136
344
  }
137
345
  flushPendingNumberingAtBoundary();
138
346
  }
347
+ // `span: 'page'` headings open a chapter band across the full content
348
+ // width. Always start on a fresh page boundary so the band sits at the
349
+ // page top, and reset the cursor to column 0 so all other columns will
350
+ // have their availableHeight reduced symmetrically after placement.
351
+ if (level?.span === 'page') {
352
+ pendingSpacing = 0;
353
+ advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
354
+ cursor.columnIndex = 0;
355
+ flushPendingNumberingAtBoundary();
356
+ }
139
357
  }
140
358
  const id = `block-${blockIdCounter++}`;
141
359
  const kind = resolveBlockKind(rawBlock, {
@@ -147,24 +365,140 @@ export function buildDocument(content, config, cache, options) {
147
365
  listLevelIndentsPx,
148
366
  orderedLevelIndentsPx,
149
367
  orderedMetrics,
368
+ resourceById,
369
+ resourceTypeById,
370
+ resourceNumberById,
150
371
  });
151
372
  const { style, vdtType, headingLevel, numberPrefix, listBullet, listDepth, listKind, bulletXOffsetInColumn, strikethroughText } = kind;
152
373
  let contentBlock = kind.contentBlock;
374
+ // --- Resource blocks (image / svg / table + caption) -----------------
375
+ // Measured and placed atomically (kept-together) — no mid-content split
376
+ // for v1. An unknown resource id produces no output (warnings handle it).
377
+ if (vdtType === 'resource') {
378
+ if (!kind.resource) {
379
+ flushPendingNumberingAtBoundary();
380
+ continue;
381
+ }
382
+ // Floated resources are not placed inline at their `::resource`
383
+ // directive — the directive is just an anchor (already enqueued above);
384
+ // the float lands in a page band. Only `position: 'here'` resources fall
385
+ // through to inline placement.
386
+ if (floatedIds.has(kind.resource.id)) {
387
+ flushPendingNumberingAtBoundary();
388
+ continue;
389
+ }
390
+ const rCol = currentColumn(doc, cursor);
391
+ const { resourceBlock, measured } = runMeasurement({
392
+ vdtType,
393
+ rawBlock,
394
+ contentBlock,
395
+ style,
396
+ measureMaxWidth: rCol.bbox.width,
397
+ measureOptions: { textAlign: style.textAlign },
398
+ mathEnabled: resolved.math.enabled,
399
+ useRich: false,
400
+ resolved,
401
+ resources,
402
+ resourceTypes,
403
+ resourceNumbering,
404
+ resource: kind.resource,
405
+ resourceType: kind.resourceType,
406
+ resourceNumber: kind.resourceNumber,
407
+ });
408
+ if (!resourceBlock) {
409
+ flushPendingNumberingAtBoundary();
410
+ continue;
411
+ }
412
+ const groupHeight = measured.totalHeight;
413
+ const blk = createVDTBlock(id, 'resource', style.fontString, style.color, style.textAlign);
414
+ blk.resourceBlock = resourceBlock;
415
+ blk.dirty = false;
416
+ blk.snappedToGrid = false;
417
+ blk.sourceStart = rawBlock.sourceStart + bodyOffset;
418
+ blk.sourceEnd = rawBlock.sourceEnd + bodyOffset;
419
+ // The single placeholder line carries the group height; caption lines are
420
+ // carried on `resourceBlock` and offset to absolute coords below.
421
+ blk.lines = [{
422
+ text: '',
423
+ bbox: { x: 0, y: 0, width: resourceBlock.bodyRect.width, height: groupHeight },
424
+ baseline: 0,
425
+ hyphenated: false,
426
+ segments: [],
427
+ isLastLine: true,
428
+ }];
429
+ const spacingBefore = pendingSpacing;
430
+ placeResourceBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
431
+ // `placeBlockInColumn` (inside placeResourceBlock) shifts `blk.lines`; the
432
+ // resource's own caption/table lines live on `resourceBlock` and must be
433
+ // offset to absolute page coordinates here using the placed bbox origin.
434
+ const ox = blk.bbox.x;
435
+ const oy = blk.bbox.y;
436
+ for (const ln of resourceBlock.captionLines) {
437
+ ln.bbox.x += ox;
438
+ ln.bbox.y += oy;
439
+ ln.baseline += oy;
440
+ }
441
+ if (resourceBlock.table) {
442
+ for (const cell of resourceBlock.table.cells) {
443
+ cell.rect.x += ox;
444
+ cell.rect.y += oy;
445
+ for (const cl of cell.lines) {
446
+ cl.bbox.x += ox;
447
+ cl.bbox.y += oy;
448
+ cl.baseline += oy;
449
+ }
450
+ }
451
+ }
452
+ doc.blocks.push(blk);
453
+ pendingSpacing = style.marginBottomPx;
454
+ flushPendingNumberingAtBoundary();
455
+ continue;
456
+ }
153
457
  // Measure text — use rich measurement for blocks with bold spans
154
458
  const col = currentColumn(doc, cursor);
155
459
  // Resolve inline math on spans (no-op when the block has no math).
156
460
  const mathEnabled = resolved.math.enabled;
157
461
  contentBlock = enrichMathSpans(contentBlock, style, resolved);
158
- const hasRichSpans = contentBlock.spans.some((s) => s.bold || s.italic || s.mathRender);
462
+ // Resolve inline `:ref{…}` spans to their computed label so references
463
+ // print their number in the running text. Each label becomes one atomic,
464
+ // non-breaking token tagged with its `refResourceId` (handled by the
465
+ // rich-text measurer), so we always take the rich path for ref blocks.
466
+ if (contentBlock.spans.some((s) => s.ref)) {
467
+ contentBlock = {
468
+ ...contentBlock,
469
+ spans: resolveRefSpans(contentBlock.spans, resourceNumbering, resourceTypes, resources, {
470
+ bold: bodyStyle.referenceBold ?? true,
471
+ italic: bodyStyle.referenceItalic ?? false,
472
+ }),
473
+ };
474
+ }
475
+ const hasRichSpans = contentBlock.spans.some((s) => s.bold || s.italic || s.mathRender || s.ref);
159
476
  // List items reserve horizontal space for indent + bullet + gap.
160
477
  const { measureMaxWidth, lineXShift, measureFirstLineIndent, measureHangingIndent, } = computeMeasureViewport(col.bbox.width, style, listBullet);
478
+ // First-paragraph-after-heading: typographic convention used in many
479
+ // scientific publications and book styles where the paragraph that
480
+ // immediately follows a heading is rendered without first-line indent.
481
+ // Only applies to regular paragraphs without hanging indent; list items
482
+ // and hanging-indent paragraphs are unaffected.
483
+ let effectiveFirstLineIndent = measureFirstLineIndent;
484
+ if (vdtType === 'paragraph'
485
+ && !resolved.bodyText.indentAfterHeading
486
+ && !resolved.bodyText.hangingIndent
487
+ && blockIdx > 0) {
488
+ let prevIdx = blockIdx - 1;
489
+ while (prevIdx >= 0 && contentBlocks[prevIdx].type === 'directive')
490
+ prevIdx--;
491
+ if (prevIdx >= 0 && contentBlocks[prevIdx].type === 'heading') {
492
+ effectiveFirstLineIndent = 0;
493
+ }
494
+ }
161
495
  const runtActive = resolved.bodyText.avoidRunts
162
496
  && (vdtType === 'paragraph'
163
497
  || (vdtType === 'listItem' && resolved.bodyText.avoidRuntsInLists));
164
498
  const measureOptions = {
165
499
  textAlign: style.textAlign,
166
500
  hyphenate: style.hyphenate,
167
- firstLineIndentPx: measureFirstLineIndent,
501
+ firstLineIndentPx: effectiveFirstLineIndent,
168
502
  hangingIndent: measureHangingIndent,
169
503
  optimal: resolved.bodyText.optimalLineBreaking,
170
504
  maxStretchRatio: resolved.bodyText.maxWordSpacing,
@@ -264,6 +598,27 @@ export function buildDocument(content, config, cache, options) {
264
598
  const totalRemainHeight = vdtType === 'mathDisplay'
265
599
  ? (remainingLines[0]?.bbox.height ?? style.lineHeightPx)
266
600
  : remainingLines.length * style.lineHeightPx;
601
+ // For headings with an enabled advanced-design slot, the rendered
602
+ // overlay may extend below the natural text bottom. Measure the slot's
603
+ // actual content bottom so the reserved block height (and the
604
+ // subsequent marginBottom + grid snap) starts from there.
605
+ let effectiveRemainHeight = totalRemainHeight;
606
+ if (vdtType === 'heading' && headingLevel !== undefined && partIndex === 0) {
607
+ const lvl = resolved.headings.levels.find((l) => l.level === headingLevel);
608
+ if (lvl) {
609
+ const full = remainingLines
610
+ .map((ln) => (ln.segments ?? []).map((s) => s.text).join(''))
611
+ .join(' ');
612
+ const pref = numberPrefix ?? '';
613
+ const title = pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full;
614
+ // Span-page openers lay out across the full content area (both
615
+ // columns); in-column headings use just the column width.
616
+ const measureWidth = lvl.span === 'page' ? contentArea.width : curCol.bbox.width;
617
+ const designBottom = measureHeadingAdvancedDesignHeight(lvl, { titleText: title, formattedNumber: pref, chapterNumber: pref }, measureWidth, resolved.page.dpi, doc.metadata, cursor.pageIndex);
618
+ if (designBottom > effectiveRemainHeight)
619
+ effectiveRemainHeight = designBottom;
620
+ }
621
+ }
267
622
  // Keep-with-list: if this colon-paragraph would fit but would leave no
268
623
  // room for the first list item, split off the colon line (or push the
269
624
  // whole paragraph when it is a single line or the split would leave a
@@ -309,7 +664,7 @@ export function buildDocument(content, config, cache, options) {
309
664
  remainingLines = remainingLines.slice(splitAt);
310
665
  partIndex++;
311
666
  pendingSpacing = 0;
312
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
667
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
313
668
  continue;
314
669
  }
315
670
  // Can't cleanly split the colon line off — would create a widow.
@@ -339,19 +694,19 @@ export function buildDocument(content, config, cache, options) {
339
694
  }
340
695
  blockIdx -= headingRunCount + 1;
341
696
  pendingSpacing = 0;
342
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
697
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
343
698
  break;
344
699
  }
345
700
  if (headingRunCount === 0) {
346
701
  pendingSpacing = 0;
347
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
702
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
348
703
  continue;
349
704
  }
350
705
  // headingRunCount === curCol.blocks.length: fall through to place.
351
706
  }
352
707
  }
353
708
  // Block fits in current column
354
- if (totalRemainHeight <= effectiveAvailable) {
709
+ if (effectiveRemainHeight <= effectiveAvailable) {
355
710
  // Heading keep-with-next: never leave a heading as the last block of a
356
711
  // column. If the following (non-heading) block wouldn't have room to
357
712
  // place at least its widow-minimum number of lines after this heading,
@@ -368,7 +723,7 @@ export function buildDocument(content, config, cache, options) {
368
723
  && nextBlock !== null
369
724
  && curCol.blocks.length > 0) {
370
725
  const wouldUsedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
371
- const naturalBottom = wouldUsedHeight + totalRemainHeight + style.marginBottomPx;
726
+ const naturalBottom = wouldUsedHeight + effectiveRemainHeight + style.marginBottomPx;
372
727
  const snappedBottom = shouldSnapToGrid
373
728
  ? Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid
374
729
  : naturalBottom;
@@ -386,11 +741,11 @@ export function buildDocument(content, config, cache, options) {
386
741
  // rolled-back heading.
387
742
  blockIdx -= rollbackCount + 1;
388
743
  pendingSpacing = 0;
389
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
744
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
390
745
  break;
391
746
  }
392
747
  pendingSpacing = 0;
393
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
748
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
394
749
  continue;
395
750
  }
396
751
  }
@@ -428,7 +783,7 @@ export function buildDocument(content, config, cache, options) {
428
783
  }
429
784
  blk.sourceMap = absoluteSourceMap;
430
785
  blk.plainPrefixLen = prefixLen;
431
- let h = totalRemainHeight;
786
+ let h = effectiveRemainHeight;
432
787
  if (shouldSnapToGrid && partIndex === 0) {
433
788
  // Snap using the absolute position in the column so that the block
434
789
  // bottom lands on a baseline grid line. This accounts for off-grid
@@ -436,7 +791,7 @@ export function buildDocument(content, config, cache, options) {
436
791
  // the minimum marginBottom — the grid always wins, but the margin
437
792
  // below is guaranteed to be at least marginBottomPx.
438
793
  const usedHeight = curCol.bbox.height - curCol.availableHeight;
439
- const naturalBottom = usedHeight + totalRemainHeight + style.marginBottomPx;
794
+ const naturalBottom = usedHeight + effectiveRemainHeight + style.marginBottomPx;
440
795
  // Tolerance guards against FP drift: if naturalBottom is already on
441
796
  // the grid (e.g. marginBottom is an exact multiple of baselineGrid),
442
797
  // don't round up to the next line.
@@ -446,6 +801,20 @@ export function buildDocument(content, config, cache, options) {
446
801
  placeBlockInColumn(blk, h, curCol, cursor);
447
802
  finalizeListItem(blk, partIndex === 0);
448
803
  doc.blocks.push(blk);
804
+ // Page-spanning heading: reserve the same vertical band in every
805
+ // other column on this page so body text under the opener band
806
+ // starts below it in ALL columns, not just the one it was placed in.
807
+ if (vdtType === 'heading' && headingLevel !== undefined) {
808
+ const lvl = resolved.headings.levels.find((l) => l.level === headingLevel);
809
+ if (lvl?.span === 'page') {
810
+ const page = doc.pages[cursor.pageIndex];
811
+ for (const otherCol of page.columns) {
812
+ if (otherCol !== curCol) {
813
+ otherCol.availableHeight = Math.max(0, otherCol.availableHeight - h);
814
+ }
815
+ }
816
+ }
817
+ }
449
818
  // For snapped headings/list-tails the margin is baked into the snap;
450
819
  // for unsnapped ones (consecutive) track it for collapsing
451
820
  if (vdtType === 'listItem' && nextIsListItem) {
@@ -502,7 +871,7 @@ export function buildDocument(content, config, cache, options) {
502
871
  remainingLines = remainingLines.slice(choice.splitAt);
503
872
  partIndex++;
504
873
  pendingSpacing = 0;
505
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
874
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
506
875
  continue;
507
876
  }
508
877
  // choice.splitAt === 0: fall through to push whole paragraph to next column
@@ -518,12 +887,12 @@ export function buildDocument(content, config, cache, options) {
518
887
  if (rollbackCount > 0) {
519
888
  blockIdx -= rollbackCount + 1;
520
889
  pendingSpacing = 0;
521
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
890
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
522
891
  break;
523
892
  }
524
893
  }
525
894
  pendingSpacing = 0;
526
- advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
895
+ advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
527
896
  continue;
528
897
  }
529
898
  // Empty column but block still doesn't fit (block taller than page) — place anyway
@@ -554,6 +923,9 @@ export function buildDocument(content, config, cache, options) {
554
923
  }
555
924
  flushPendingNumberingAtBoundary();
556
925
  }
926
+ // Place any floats still pending (referenced on the last page, or never
927
+ // followed by a content-overflow page break) onto freshly appended pages.
928
+ finalizeFloats();
557
929
  // Stamp page-number info onto every page (including blank parity pages).
558
930
  const labels = buildPageLabels(doc.pages.length, pageNumberSegments);
559
931
  for (let i = 0; i < doc.pages.length; i++) {