postext 0.3.21 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/columnBalancing.test.js +3 -2
- package/dist/__tests__/columnBalancing.test.js.map +1 -1
- package/dist/__tests__/defaults/htmlViewerOverrides.test.d.ts +2 -0
- package/dist/__tests__/defaults/htmlViewerOverrides.test.d.ts.map +1 -0
- package/dist/__tests__/defaults/htmlViewerOverrides.test.js +95 -0
- package/dist/__tests__/defaults/htmlViewerOverrides.test.js.map +1 -0
- package/dist/__tests__/design/runningHeads.test.d.ts +2 -0
- package/dist/__tests__/design/runningHeads.test.d.ts.map +1 -0
- package/dist/__tests__/design/runningHeads.test.js +94 -0
- package/dist/__tests__/design/runningHeads.test.js.map +1 -0
- package/dist/__tests__/exports.test.js +5 -0
- package/dist/__tests__/exports.test.js.map +1 -1
- package/dist/__tests__/html-backend.test.d.ts +2 -0
- package/dist/__tests__/html-backend.test.d.ts.map +1 -0
- package/dist/__tests__/html-backend.test.js +76 -0
- package/dist/__tests__/html-backend.test.js.map +1 -0
- package/dist/__tests__/parse/inlineSnippet.test.d.ts +2 -0
- package/dist/__tests__/parse/inlineSnippet.test.d.ts.map +1 -0
- package/dist/__tests__/parse/inlineSnippet.test.js +61 -0
- package/dist/__tests__/parse/inlineSnippet.test.js.map +1 -0
- package/dist/__tests__/pipeline/calloutFixed.test.d.ts +2 -0
- package/dist/__tests__/pipeline/calloutFixed.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/calloutFixed.test.js +99 -0
- package/dist/__tests__/pipeline/calloutFixed.test.js.map +1 -0
- package/dist/__tests__/pipeline/captionLayout.test.js +16 -0
- package/dist/__tests__/pipeline/captionLayout.test.js.map +1 -1
- package/dist/__tests__/pipeline/floatFirstSlot.test.d.ts +2 -0
- package/dist/__tests__/pipeline/floatFirstSlot.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/floatFirstSlot.test.js +180 -0
- package/dist/__tests__/pipeline/floatFirstSlot.test.js.map +1 -0
- package/dist/__tests__/pipeline/floatPlacement.test.js +1 -1
- package/dist/__tests__/pipeline/floatPlacement.test.js.map +1 -1
- package/dist/__tests__/pipeline/floatSlots.test.d.ts +2 -0
- package/dist/__tests__/pipeline/floatSlots.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/floatSlots.test.js +99 -0
- package/dist/__tests__/pipeline/floatSlots.test.js.map +1 -0
- package/dist/__tests__/pipeline/spanBands.test.js +5 -3
- package/dist/__tests__/pipeline/spanBands.test.js.map +1 -1
- package/dist/__tests__/pipeline/spanBlocks.test.js +23 -14
- package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
- package/dist/__tests__/pipeline/trailingBand.test.d.ts +2 -0
- package/dist/__tests__/pipeline/trailingBand.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/trailingBand.test.js +93 -0
- package/dist/__tests__/pipeline/trailingBand.test.js.map +1 -0
- package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
- package/dist/canvas-backend/headerFooter.js +13 -1
- package/dist/canvas-backend/headerFooter.js.map +1 -1
- package/dist/canvas-backend/index.d.ts.map +1 -1
- package/dist/canvas-backend/index.js +5 -5
- package/dist/canvas-backend/index.js.map +1 -1
- package/dist/defaults/calloutStyles.d.ts +9 -1
- package/dist/defaults/calloutStyles.d.ts.map +1 -1
- package/dist/defaults/calloutStyles.js +35 -0
- package/dist/defaults/calloutStyles.js.map +1 -1
- package/dist/defaults/headings.d.ts +1 -0
- package/dist/defaults/headings.d.ts.map +1 -1
- package/dist/defaults/headings.js +7 -0
- package/dist/defaults/headings.js.map +1 -1
- package/dist/defaults/htmlViewer.d.ts +9 -1
- package/dist/defaults/htmlViewer.d.ts.map +1 -1
- package/dist/defaults/htmlViewer.js +72 -0
- package/dist/defaults/htmlViewer.js.map +1 -1
- package/dist/defaults/index.d.ts +1 -1
- package/dist/defaults/index.d.ts.map +1 -1
- package/dist/defaults/index.js +1 -1
- package/dist/defaults/index.js.map +1 -1
- package/dist/design/layout.d.ts +18 -1
- package/dist/design/layout.d.ts.map +1 -1
- package/dist/design/layout.js +38 -28
- package/dist/design/layout.js.map +1 -1
- package/dist/design/placeholders.d.ts.map +1 -1
- package/dist/design/placeholders.js +1 -0
- package/dist/design/placeholders.js.map +1 -1
- package/dist/html-backend.d.ts +2 -0
- package/dist/html-backend.d.ts.map +1 -1
- package/dist/html-backend.js +12 -7
- package/dist/html-backend.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/parse/index.d.ts +3 -0
- package/dist/parse/index.d.ts.map +1 -1
- package/dist/parse/index.js +2 -0
- package/dist/parse/index.js.map +1 -1
- package/dist/parse/inlineSnippet.d.ts +33 -0
- package/dist/parse/inlineSnippet.d.ts.map +1 -0
- package/dist/parse/inlineSnippet.js +30 -0
- package/dist/parse/inlineSnippet.js.map +1 -0
- package/dist/parse/sourceMapping.d.ts +8 -0
- package/dist/parse/sourceMapping.d.ts.map +1 -1
- package/dist/parse/sourceMapping.js +3 -2
- package/dist/parse/sourceMapping.js.map +1 -1
- package/dist/pipeline/bandCaps.d.ts +41 -9
- package/dist/pipeline/bandCaps.d.ts.map +1 -1
- package/dist/pipeline/bandCaps.js +99 -14
- package/dist/pipeline/bandCaps.js.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +568 -215
- package/dist/pipeline/build.js.map +1 -1
- package/dist/pipeline/calloutLayout.js +1 -1
- package/dist/pipeline/calloutLayout.js.map +1 -1
- package/dist/pipeline/floatPlacement.d.ts +10 -6
- package/dist/pipeline/floatPlacement.d.ts.map +1 -1
- package/dist/pipeline/floatPlacement.js +7 -5
- package/dist/pipeline/floatPlacement.js.map +1 -1
- package/dist/pipeline/floatSlots.d.ts +61 -0
- package/dist/pipeline/floatSlots.d.ts.map +1 -0
- package/dist/pipeline/floatSlots.js +100 -0
- package/dist/pipeline/floatSlots.js.map +1 -0
- package/dist/pipeline/headerFooter.d.ts.map +1 -1
- package/dist/pipeline/headerFooter.js +3 -0
- package/dist/pipeline/headerFooter.js.map +1 -1
- package/dist/pipeline/placeholders.d.ts.map +1 -1
- package/dist/pipeline/placeholders.js +20 -4
- package/dist/pipeline/placeholders.js.map +1 -1
- package/dist/pipeline/placement.d.ts +4 -0
- package/dist/pipeline/placement.d.ts.map +1 -1
- package/dist/pipeline/placement.js +26 -11
- package/dist/pipeline/placement.js.map +1 -1
- package/dist/pipeline/resourceLayout.d.ts.map +1 -1
- package/dist/pipeline/resourceLayout.js +9 -10
- package/dist/pipeline/resourceLayout.js.map +1 -1
- package/dist/types.d.ts +64 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +9 -5
- package/dist/vdt.d.ts.map +1 -1
- package/dist/vdt.js +3 -0
- package/dist/vdt.js.map +1 -1
- package/package.json +1 -1
package/dist/pipeline/build.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { applyTitleBreaks } from '../parse/inlineFormatting';
|
|
2
2
|
import { dimensionToPx } from '../units';
|
|
3
3
|
import { createVDTDocument, createVDTBlock, createBoundingBox, } from '../vdt';
|
|
4
|
+
import { anchorBox } from '../design/layout';
|
|
4
5
|
import { parseMarkdownMemo } from '../parse';
|
|
5
6
|
import { buildPageLabels, computeHeadingNumbers, } from '../numbering';
|
|
6
7
|
import { extractFrontmatter } from '../frontmatter';
|
|
@@ -8,7 +9,7 @@ import { initHyphenator } from '../measure';
|
|
|
8
9
|
import { resolveAllConfig, computeBaselineGrid, buildHeadingLevelMap } from './config';
|
|
9
10
|
import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
|
|
10
11
|
import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
|
|
11
|
-
import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
|
|
12
|
+
import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, pageIsOccupied, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
|
|
12
13
|
import { chooseParagraphSplit } from './orphanWidow';
|
|
13
14
|
import { applyStyleAttrs, computePageMetrics, nextNonMarkerBlock, prevNonMarkerBlock, rollbackTrailingBlocks, } from './buildHelpers';
|
|
14
15
|
import { measureContentBlock } from './measureContentBlock';
|
|
@@ -17,11 +18,12 @@ import { planParts, derivePartMeasureContext } from './parts';
|
|
|
17
18
|
import { layoutCallout, offsetCalloutToAbsolute, pickCalloutStyle, planCallouts, resolveCalloutAttrs, } from './calloutLayout';
|
|
18
19
|
import { layoutResourceBlock } from './resourceLayout';
|
|
19
20
|
import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
|
|
21
|
+
import { enumerateCurrentPageSlots, measureFloatBand, columnHasFloatBand, fitsStrict, trueBottom, } from './floatSlots';
|
|
20
22
|
import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
|
|
21
23
|
import { defaultResourceTypes } from '../defaults/resourceTypes';
|
|
22
24
|
import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
|
|
23
25
|
import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES, } from './columnBalancing';
|
|
24
|
-
import { applyBandCap, uncapBand, columnBottom, bandCapLines, resolveBandCaps, } from './bandCaps';
|
|
26
|
+
import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, } from './bandCaps';
|
|
25
27
|
export class BuildCancelledError extends Error {
|
|
26
28
|
constructor() {
|
|
27
29
|
super('Build cancelled');
|
|
@@ -157,9 +159,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
157
159
|
// --- Float planning (issue #49 — resources float to page bands) ----------
|
|
158
160
|
// A resource is incorporated by its first reference (an inline `:ref` or a
|
|
159
161
|
// `::resource` directive, whichever comes first in reading order). Floated
|
|
160
|
-
// resources detach from the running text and
|
|
161
|
-
//
|
|
162
|
-
//
|
|
162
|
+
// resources detach from the running text and take the first free slot
|
|
163
|
+
// after that reference (`floatSlots.ts`); the text flows past the
|
|
164
|
+
// reference uninterrupted. `position: 'here'` resources keep inline
|
|
163
165
|
// `::resource` placement and are not floated.
|
|
164
166
|
const floatPlan = computeFloatPlan(contentBlocks, resources, resourceTypes);
|
|
165
167
|
const floatedIds = floatedResourceIds(floatPlan);
|
|
@@ -174,6 +176,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
174
176
|
// Floats whose first reference has been passed but which are not yet placed
|
|
175
177
|
// into a page band, in reading order.
|
|
176
178
|
const pendingFloats = [];
|
|
179
|
+
/** Resource ids already enqueued in this pass — a keep-with-next rewind
|
|
180
|
+
* replays the loop top for the rolled-back blocks and must not enqueue
|
|
181
|
+
* (and later place) the same float twice. */
|
|
182
|
+
const enqueuedFloatIds = new Set();
|
|
183
|
+
const enqueueFloatsFor = (blockIdx) => {
|
|
184
|
+
const fl = floatsByFirstBlock.get(blockIdx);
|
|
185
|
+
if (!fl)
|
|
186
|
+
return;
|
|
187
|
+
for (const f of fl) {
|
|
188
|
+
if (enqueuedFloatIds.has(f.resourceId))
|
|
189
|
+
continue;
|
|
190
|
+
enqueuedFloatIds.add(f.resourceId);
|
|
191
|
+
pendingFloats.push(f);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
177
194
|
const floatGapPx = bodyStyle.lineHeightPx;
|
|
178
195
|
const minTextPx = bodyStyle.lineHeightPx * 3;
|
|
179
196
|
/** Offset a resolved resource block's caption/table geometry from
|
|
@@ -205,13 +222,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
205
222
|
}
|
|
206
223
|
}
|
|
207
224
|
};
|
|
208
|
-
|
|
209
|
-
* when the resource id is unknown. Caller offsets it to its final `y`. */
|
|
210
|
-
const buildFloatBlock = (resourceId, x, width) => {
|
|
225
|
+
const layoutFloat = (resourceId, width) => {
|
|
211
226
|
const resource = resourceById.get(resourceId);
|
|
212
227
|
if (!resource)
|
|
213
228
|
return null;
|
|
214
|
-
|
|
229
|
+
return layoutResourceBlock({
|
|
215
230
|
resource,
|
|
216
231
|
resourceType: resourceTypeById.get(resource.typeId),
|
|
217
232
|
number: resourceNumberById.get(resourceId) ?? '',
|
|
@@ -221,6 +236,46 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
221
236
|
resourceTypes,
|
|
222
237
|
resources,
|
|
223
238
|
});
|
|
239
|
+
};
|
|
240
|
+
/** Height (and caption baseline) of a float at a given width, memoised —
|
|
241
|
+
* fit checks run for every pending float on every loop iteration. */
|
|
242
|
+
const floatMeasureMemo = new Map();
|
|
243
|
+
const measureFloat = (resourceId, width) => {
|
|
244
|
+
const key = `${resourceId}:${width.toFixed(2)}`;
|
|
245
|
+
const memo = floatMeasureMemo.get(key);
|
|
246
|
+
if (memo !== undefined)
|
|
247
|
+
return memo;
|
|
248
|
+
const laid = layoutFloat(resourceId, width);
|
|
249
|
+
let m = null;
|
|
250
|
+
if (laid) {
|
|
251
|
+
// A bottom band aligns the float's LAST text line to the grid: the
|
|
252
|
+
// caption's (or the note's) last line when it sits under the body. A
|
|
253
|
+
// caption set above the body (caption bars) leaves the body's bottom
|
|
254
|
+
// edge as the visual bottom instead.
|
|
255
|
+
const rb = laid.block;
|
|
256
|
+
const bodyBottom = rb.bodyRect.y + rb.bodyRect.height;
|
|
257
|
+
let lastBaseline;
|
|
258
|
+
for (const ln of [...rb.captionLines, ...rb.noteLines]) {
|
|
259
|
+
if (ln.bbox.y < bodyBottom - 0.5)
|
|
260
|
+
continue;
|
|
261
|
+
if (lastBaseline === undefined || ln.baseline > lastBaseline)
|
|
262
|
+
lastBaseline = ln.baseline;
|
|
263
|
+
}
|
|
264
|
+
m = {
|
|
265
|
+
height: laid.totalHeight,
|
|
266
|
+
...(lastBaseline !== undefined ? { lastCaptionBaseline: lastBaseline } : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
floatMeasureMemo.set(key, m);
|
|
270
|
+
return m;
|
|
271
|
+
};
|
|
272
|
+
/** Measure + build a float block at horizontal offset `x` (y = 0), or null
|
|
273
|
+
* when the resource id is unknown. Caller offsets it to its final `y`. */
|
|
274
|
+
const buildFloatBlock = (resourceId, x, width) => {
|
|
275
|
+
const laid = layoutFloat(resourceId, width);
|
|
276
|
+
if (!laid)
|
|
277
|
+
return null;
|
|
278
|
+
const { block: rb, totalHeight } = laid;
|
|
224
279
|
const blk = createVDTBlock(`float-${resourceId}`, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
|
|
225
280
|
blk.resourceBlock = rb;
|
|
226
281
|
blk.dirty = false;
|
|
@@ -230,160 +285,185 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
230
285
|
offsetResourceBlockToAbsolute(rb, x, 0);
|
|
231
286
|
return { block: blk, height: totalHeight };
|
|
232
287
|
};
|
|
233
|
-
/**
|
|
234
|
-
*
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
*
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (!built)
|
|
268
|
-
return 'skip';
|
|
288
|
+
/** Float bands reserved per column in this pass (the fresh-page flush
|
|
289
|
+
* sends single-column floats to the least reserved column). */
|
|
290
|
+
const floatReserved = new Map();
|
|
291
|
+
const reservedOf = (col) => floatReserved.get(col) ?? { top: 0, bottom: 0 };
|
|
292
|
+
/** Kind of cap the column is under (`undefined` when uncapped). A cap that
|
|
293
|
+
* cannot be attributed to the active band is treated as a span cap — the
|
|
294
|
+
* conservative reading, which keeps the column's bottom off the slot list. */
|
|
295
|
+
const capKindOf = (col) => {
|
|
296
|
+
if (!uncappedBottoms.has(col))
|
|
297
|
+
return undefined;
|
|
298
|
+
if (activeCap && bandCaps && activeCap.pageIndex === cursor.pageIndex && activeCap.band === (col.band ?? 0)) {
|
|
299
|
+
return bandCaps.get(activeCap.spanIndex)?.kind ?? 'span';
|
|
300
|
+
}
|
|
301
|
+
return 'span';
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* Reserve a float band on `targetCols` (one column, or every text column
|
|
305
|
+
* of the band for a page-span float) and position the float there.
|
|
306
|
+
* `'fresh'` is the freshly-opened-page rule: keep three lines of text room
|
|
307
|
+
* once a band already holds a float, but force-place a dominating float
|
|
308
|
+
* on an all-text band so the queue always progresses. `'strict'` is the
|
|
309
|
+
* current-page rule: the band must fit in each column's remaining height
|
|
310
|
+
* (below its content), keeping the text room only next to another band.
|
|
311
|
+
* Only mutates page geometry when it places.
|
|
312
|
+
*/
|
|
313
|
+
const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode) => {
|
|
314
|
+
const first = targetCols[0];
|
|
315
|
+
const width = pageSpan ? page.contentArea.width : first.bbox.width;
|
|
316
|
+
const xLeft = pageSpan ? page.contentArea.x : first.bbox.x;
|
|
317
|
+
const measure = measureFloat(f.resourceId, width);
|
|
318
|
+
if (!measure)
|
|
319
|
+
return 'skip';
|
|
320
|
+
const { need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms));
|
|
321
|
+
if (mode === 'fresh') {
|
|
269
322
|
let minAvail = Infinity;
|
|
270
323
|
let anyReserved = false;
|
|
271
324
|
for (const c of targetCols) {
|
|
272
|
-
minAvail = Math.min(minAvail,
|
|
273
|
-
|
|
325
|
+
minAvail = Math.min(minAvail, c.availableHeight);
|
|
326
|
+
const r = reservedOf(c);
|
|
327
|
+
if (r.top > 0 || r.bottom > 0)
|
|
274
328
|
anyReserved = true;
|
|
275
329
|
}
|
|
276
|
-
// Both band kinds are corrected against the baseline grid so the text
|
|
277
|
-
// around them — and the facing page — stays on the global rhythm:
|
|
278
|
-
// - top: the band pushes the column start (`col.bbox.y`) downward, and
|
|
279
|
-
// all grid snapping inside the column is anchored at that start. The
|
|
280
|
-
// band height is rounded up to a grid multiple (growing the gap
|
|
281
|
-
// below the float) or every line in the displaced column would land
|
|
282
|
-
// off-grid, visibly misaligned with neighbouring columns.
|
|
283
|
-
// - bottom: the float is anchored so its visual bottom sits on the
|
|
284
|
-
// grid — the caption's last line shares its baseline with the last
|
|
285
|
-
// text line of the other columns (captionless content aligns its
|
|
286
|
-
// bottom edge to the last grid slot). Pages then end at the same
|
|
287
|
-
// height across columns and across facing pages.
|
|
288
|
-
let need;
|
|
289
|
-
let floatY = 0;
|
|
290
|
-
if (f.position === 'top') {
|
|
291
|
-
const rawNeed = built.height + floatGapPx;
|
|
292
|
-
need = Math.ceil((rawNeed - 0.01) / baselineGrid) * baselineGrid;
|
|
293
|
-
}
|
|
294
|
-
else {
|
|
295
|
-
const bottomLimit = Math.min(...targetCols.map((c) => {
|
|
296
|
-
const cb = page.columns[c].bbox;
|
|
297
|
-
return cb.y + cb.height;
|
|
298
|
-
}));
|
|
299
|
-
const gridAlignedBottom = page.contentArea.y
|
|
300
|
-
+ Math.floor((bottomLimit - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
|
|
301
|
-
const capLines = built.block.resourceBlock.captionLines;
|
|
302
|
-
if (capLines.length > 0) {
|
|
303
|
-
// Body baselines sit at 0.2 × grid above each slot bottom; anchor
|
|
304
|
-
// the caption's last baseline there.
|
|
305
|
-
const lastBaseline = capLines[capLines.length - 1].baseline; // block-relative
|
|
306
|
-
floatY = gridAlignedBottom - 0.2 * baselineGrid - lastBaseline;
|
|
307
|
-
}
|
|
308
|
-
else {
|
|
309
|
-
floatY = gridAlignedBottom - built.height;
|
|
310
|
-
}
|
|
311
|
-
need = 0;
|
|
312
|
-
for (const c of targetCols) {
|
|
313
|
-
const col = page.columns[c];
|
|
314
|
-
need = Math.max(need, col.bbox.height - (floatY - floatGapPx - col.bbox.y));
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
// Keep some text room, unless this band is still all-text (then a
|
|
318
|
-
// dominating / oversized float is force-placed so the queue progresses).
|
|
319
330
|
if (need > minAvail - minTextPx && anyReserved)
|
|
320
331
|
return 'defer';
|
|
321
|
-
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
322
334
|
for (const c of targetCols) {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
y
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
335
|
+
if (position === 'bottom' && uncappedBottoms.has(c)) {
|
|
336
|
+
// Trailing cap: the band must lie entirely below the level cut.
|
|
337
|
+
if (y - floatGapPx < c.bbox.y + c.bbox.height - 0.01)
|
|
338
|
+
return 'defer';
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (!fitsStrict(need, c, columnHasFloatBand(page, c), minTextPx))
|
|
342
|
+
return 'defer';
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const built = buildFloatBlock(f.resourceId, xLeft, width);
|
|
346
|
+
if (!built)
|
|
347
|
+
return 'skip';
|
|
348
|
+
for (const col of targetCols) {
|
|
349
|
+
const r = { ...reservedOf(col) };
|
|
350
|
+
if (position === 'top') {
|
|
351
|
+
col.bbox.y += need;
|
|
352
|
+
col.bbox.height = Math.max(0, col.bbox.height - need);
|
|
353
|
+
col.availableHeight = Math.max(0, col.availableHeight - need);
|
|
354
|
+
r.top += need;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const capped = uncappedBottoms.get(col);
|
|
358
|
+
if (capped !== undefined) {
|
|
359
|
+
uncappedBottoms.set(col, capped - need);
|
|
330
360
|
}
|
|
331
361
|
else {
|
|
332
|
-
|
|
333
|
-
const newHeight = Math.max(0, floatY - floatGapPx - col.bbox.y);
|
|
362
|
+
const newHeight = Math.max(0, y - floatGapPx - col.bbox.y);
|
|
334
363
|
const reserved = col.bbox.height - newHeight;
|
|
335
364
|
col.bbox.height = newHeight;
|
|
336
365
|
col.availableHeight = Math.max(0, col.availableHeight - reserved);
|
|
337
|
-
botUsed[c] += reserved;
|
|
338
366
|
}
|
|
367
|
+
r.bottom += need;
|
|
339
368
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
369
|
+
floatReserved.set(col, r);
|
|
370
|
+
}
|
|
371
|
+
offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, y);
|
|
372
|
+
built.block.bbox = createBoundingBox(xLeft, y, width, built.height);
|
|
373
|
+
built.block.pageIndex = page.index;
|
|
374
|
+
built.block.columnIndex = first.index;
|
|
375
|
+
(page.floats ??= []).push(built.block);
|
|
376
|
+
return 'placed';
|
|
377
|
+
};
|
|
378
|
+
const positionsFor = (f) => f.position === 'auto' ? ['top', 'bottom'] : [f.position];
|
|
379
|
+
/** Reserve top/bottom bands on a freshly opened page and position as many
|
|
380
|
+
* pending floats as fit, shrinking the affected columns so body text flows
|
|
381
|
+
* around them. Full-width (page-span) floats reserve the outermost bands
|
|
382
|
+
* first, so a later single-column float nests inside the remaining column
|
|
383
|
+
* space rather than overlapping a full-width band. A float that does not
|
|
384
|
+
* fit never holds up the ones behind it: each takes the first slot it
|
|
385
|
+
* fits (numbering follows first-reference order regardless). */
|
|
386
|
+
const flushFloatsIntoPage = (page) => {
|
|
387
|
+
if (pendingFloats.length === 0)
|
|
388
|
+
return;
|
|
389
|
+
const textCols = page.columns.filter((c) => c.kind !== 'span');
|
|
390
|
+
if (textCols.length === 0)
|
|
391
|
+
return;
|
|
392
|
+
const leastReserved = () => {
|
|
393
|
+
let best = textCols[0];
|
|
394
|
+
for (const c of textCols) {
|
|
395
|
+
const rb = reservedOf(best);
|
|
396
|
+
const rc = reservedOf(c);
|
|
397
|
+
if (rc.top + rc.bottom < rb.top + rb.bottom)
|
|
398
|
+
best = c;
|
|
399
|
+
}
|
|
400
|
+
return best;
|
|
346
401
|
};
|
|
347
|
-
// Full-width (page-span) floats reserve the outermost bands first, so a
|
|
348
|
-
// later single-column float nests inside the remaining column space rather
|
|
349
|
-
// than overlapping a full-width band. Within each pass, stop at the first
|
|
350
|
-
// float that does not fit to preserve reading order.
|
|
351
402
|
for (const pageSpanPass of [true, false]) {
|
|
352
403
|
let i = 0;
|
|
353
404
|
while (i < pendingFloats.length) {
|
|
354
405
|
const f = pendingFloats[i];
|
|
355
|
-
const isPageSpan = f.span === 'page' &&
|
|
406
|
+
const isPageSpan = f.span === 'page' && textCols.length > 1;
|
|
356
407
|
if (isPageSpan !== pageSpanPass) {
|
|
357
408
|
i++;
|
|
358
409
|
continue;
|
|
359
410
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
411
|
+
let r = 'defer';
|
|
412
|
+
for (const pos of positionsFor(f)) {
|
|
413
|
+
const cols = isPageSpan ? textCols : [leastReserved()];
|
|
414
|
+
r = placeFloatInColumns(page, f, cols, pos, isPageSpan, 'fresh');
|
|
415
|
+
if (r !== 'defer')
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
if (r === 'defer')
|
|
419
|
+
i++;
|
|
363
420
|
else
|
|
364
|
-
|
|
421
|
+
pendingFloats.splice(i, 1);
|
|
365
422
|
}
|
|
366
423
|
}
|
|
367
|
-
if (floats.length > 0)
|
|
368
|
-
page.floats = floats;
|
|
369
424
|
};
|
|
370
|
-
/**
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
|
|
425
|
+
/** Offer every pending float the free slots of the current page after the
|
|
426
|
+
* cursor (bottom of the referencing column, top / bottom of the next
|
|
427
|
+
* empty columns; the band bottom for page-span floats). Runs before each
|
|
428
|
+
* block is placed, so a float lands in the first gap after its reference. */
|
|
429
|
+
const tryPlacePendingFloatsOnCurrentPage = () => {
|
|
430
|
+
if (pendingFloats.length === 0)
|
|
431
|
+
return;
|
|
432
|
+
const page = doc.pages[cursor.pageIndex];
|
|
433
|
+
for (let i = 0; i < pendingFloats.length;) {
|
|
434
|
+
const f = pendingFloats[i];
|
|
435
|
+
let r = 'defer';
|
|
436
|
+
for (const slot of enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf)) {
|
|
437
|
+
r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict');
|
|
438
|
+
if (r !== 'defer')
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
if (r === 'defer')
|
|
442
|
+
i++;
|
|
443
|
+
else
|
|
444
|
+
pendingFloats.splice(i, 1);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
/** Reserve floats on each freshly opened content page. Passed only to the
|
|
448
|
+
* content-flow column advances — parity / force-blank pages never get it. */
|
|
449
|
+
const onNewPage = (page) => flushFloatsIntoPage(page);
|
|
450
|
+
/** Chapter barrier: place every pending float before the boundary — in
|
|
451
|
+
* the current page's free slots, then on fresh pages opened ahead of it
|
|
452
|
+
* (each force-places at least one float). The cursor is left on the last
|
|
453
|
+
* float page so the boundary's own page break opens AFTER them. */
|
|
454
|
+
const drainPendingFloats = () => {
|
|
455
|
+
tryPlacePendingFloatsOnCurrentPage();
|
|
374
456
|
let guard = 0;
|
|
375
457
|
while (pendingFloats.length > 0 && guard++ < 1000) {
|
|
376
458
|
const before = pendingFloats.length;
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
459
|
+
const startPageIndex = cursor.pageIndex;
|
|
460
|
+
do {
|
|
461
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
462
|
+
} while (cursor.pageIndex === startPageIndex);
|
|
380
463
|
if (pendingFloats.length === before)
|
|
381
464
|
break; // safety: no progress
|
|
382
465
|
}
|
|
383
466
|
};
|
|
384
|
-
/** Reserve floats on each freshly opened content page. Passed only to the
|
|
385
|
-
* content-flow column advances — parity / force-blank pages never get it. */
|
|
386
|
-
const onNewPage = (page) => flushFloatsIntoPage(page);
|
|
387
467
|
// Everything per-block measurement needs that is constant for this pass.
|
|
388
468
|
const measureCtx = {
|
|
389
469
|
resolved,
|
|
@@ -459,6 +539,64 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
459
539
|
break;
|
|
460
540
|
}
|
|
461
541
|
};
|
|
542
|
+
/**
|
|
543
|
+
* Trailing band balance: when the flow reaches a boundary (chapter opener,
|
|
544
|
+
* `:::part`, a chapter-closing fixed box, end of document) with the
|
|
545
|
+
* current band's text columns uneven, propose a cap that cuts them level
|
|
546
|
+
* — `ceil(Σ used / N / grid)` lines — so the next pass re-places the band
|
|
547
|
+
* and its columns end at the same height, the way a compositor sets a
|
|
548
|
+
* short closing page. Keyed by the boundary block's content index
|
|
549
|
+
* (`contentBlocks.length` at EOF). In the capped pass the boundary reports
|
|
550
|
+
* the cap delivered when it is reached inside the capped band (nothing
|
|
551
|
+
* spilled past the cut); the columns are NOT uncapped afterwards, so
|
|
552
|
+
* column balancing does not stretch them back to the page bottom.
|
|
553
|
+
*/
|
|
554
|
+
const proposeTrailingCap = (boundaryIndex) => {
|
|
555
|
+
const balancingCfg = resolved.headings.balancing;
|
|
556
|
+
if (!balancingCfg.enabled || !balancingCfg.trailing)
|
|
557
|
+
return;
|
|
558
|
+
const page = doc.pages[cursor.pageIndex];
|
|
559
|
+
if (page.columns[cursor.columnIndex]?.kind === 'span' || page.partInfo)
|
|
560
|
+
return;
|
|
561
|
+
const band = currentBand(page, cursor);
|
|
562
|
+
const cols = bandColumns(page, band).filter((c) => c.bbox.height > 0.5);
|
|
563
|
+
if (cols.length < 2 || cols.some((c) => c.forcedBreak))
|
|
564
|
+
return;
|
|
565
|
+
if (bandCaps?.has(boundaryIndex)) {
|
|
566
|
+
if (activeCap && activeCap.spanIndex === boundaryIndex
|
|
567
|
+
&& activeCap.pageIndex === page.index && activeCap.band === band) {
|
|
568
|
+
spanPlacedInBand.add(boundaryIndex);
|
|
569
|
+
}
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (activeCap && activeCap.pageIndex === page.index && activeCap.band === band)
|
|
573
|
+
return;
|
|
574
|
+
if (!cols.some((c) => c.blocks.length > 0))
|
|
575
|
+
return;
|
|
576
|
+
if (!bandStart || !registeredBand || registeredBand.pageIndex !== page.index || registeredBand.band !== band)
|
|
577
|
+
return;
|
|
578
|
+
const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
|
|
579
|
+
if (Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5)
|
|
580
|
+
return;
|
|
581
|
+
bandCapProposals.set(boundaryIndex, {
|
|
582
|
+
kind: 'trailing',
|
|
583
|
+
startContentIndex: bandStart.contentIndex,
|
|
584
|
+
startPart: bandStart.part,
|
|
585
|
+
lines: bandCapLines(cols, baselineGrid),
|
|
586
|
+
retries: 0,
|
|
587
|
+
});
|
|
588
|
+
};
|
|
589
|
+
/** Close the flow at a chapter-level boundary (block `boundaryIndex`):
|
|
590
|
+
* floats take the page's free slots, the closing band is levelled, the
|
|
591
|
+
* page is marked as an explicit break, and every float still pending is
|
|
592
|
+
* drained onto pages opened BEFORE the boundary. The caller then opens
|
|
593
|
+
* the boundary's own page. */
|
|
594
|
+
const closeFlowSegment = (boundaryIndex) => {
|
|
595
|
+
tryPlacePendingFloatsOnCurrentPage();
|
|
596
|
+
proposeTrailingCap(boundaryIndex);
|
|
597
|
+
markForcedBreak();
|
|
598
|
+
drainPendingFloats();
|
|
599
|
+
};
|
|
462
600
|
/** Parity of the page break a closed `:::part` still owes (applied before
|
|
463
601
|
* the next placed block). */
|
|
464
602
|
let pendingPartBreak = null;
|
|
@@ -510,13 +648,18 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
510
648
|
* convert the laid-out box to absolute coordinates at the frame's placed
|
|
511
649
|
* origin, and push frame + children — in that order — to `doc.blocks`
|
|
512
650
|
* and to the column the frame landed in. */
|
|
513
|
-
|
|
514
|
-
|
|
651
|
+
/** Stamp a callout frame's content index and source range (the whole
|
|
652
|
+
* fence, opening to closing marker). */
|
|
653
|
+
const stampCalloutSource = (frame, startIdx, plan) => {
|
|
515
654
|
const startBlock = contentBlocks[startIdx];
|
|
516
655
|
const endBlock = contentBlocks[plan.endIdx];
|
|
517
656
|
frame.contentIndex = startIdx;
|
|
518
657
|
frame.sourceStart = startBlock.sourceStart + bodyOffset;
|
|
519
658
|
frame.sourceEnd = endBlock.sourceEnd + bodyOffset;
|
|
659
|
+
};
|
|
660
|
+
const commitCallout = (result, startIdx, plan, col) => {
|
|
661
|
+
const frame = result.frame;
|
|
662
|
+
stampCalloutSource(frame, startIdx, plan);
|
|
520
663
|
offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
|
|
521
664
|
doc.blocks.push(frame);
|
|
522
665
|
for (const child of result.children) {
|
|
@@ -606,12 +749,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
606
749
|
// room for the box plus the widow minimum of body lines below it.
|
|
607
750
|
const cols = bandColumns(page, currentBand(page, cursor));
|
|
608
751
|
const lines = bandCapLines(cols, baselineGrid);
|
|
609
|
-
const capBottom =
|
|
752
|
+
const capBottom = bandTop(cols) + lines * baselineGrid;
|
|
610
753
|
const spacing = Math.max(pendingSpacing, result.marginTopPx);
|
|
611
754
|
const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
|
|
612
755
|
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
613
756
|
if (capBottom + need + minRoomPx <= bandBottom + 0.01) {
|
|
614
757
|
bandCapProposals.set(startIdx, {
|
|
758
|
+
kind: 'span',
|
|
615
759
|
startContentIndex: bandStart.contentIndex,
|
|
616
760
|
startPart: bandStart.part,
|
|
617
761
|
lines,
|
|
@@ -625,7 +769,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
625
769
|
// left no room — but a truly empty page is kept: the box then simply
|
|
626
770
|
// does not fit a page and is force-placed (overflowing, like inline).
|
|
627
771
|
const curPage = doc.pages[cursor.pageIndex];
|
|
628
|
-
if (
|
|
772
|
+
if (pageIsOccupied(curPage)) {
|
|
629
773
|
pendingSpacing = 0;
|
|
630
774
|
const startPageIndex = cursor.pageIndex;
|
|
631
775
|
do {
|
|
@@ -646,16 +790,174 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
646
790
|
commitCallout(result, startIdx, plan, spanCol);
|
|
647
791
|
// Floats first-referenced inside the box enqueue once it is committed,
|
|
648
792
|
// in reading order (same as the inline path).
|
|
649
|
-
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
650
|
-
|
|
651
|
-
if (fl)
|
|
652
|
-
pendingFloats.push(...fl);
|
|
653
|
-
}
|
|
793
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
794
|
+
enqueueFloatsFor(i);
|
|
654
795
|
// The new band starts on the grid right below the span column; nothing
|
|
655
796
|
// to snap — `need` already bakes in `marginBottom`.
|
|
656
797
|
pendingSpacing = 0;
|
|
657
798
|
return true;
|
|
658
799
|
};
|
|
800
|
+
/** Whether the flow ends at a chapter-level boundary right after block
|
|
801
|
+
* `from` (skipping container markers): a chapter opener, a `:::part`, a
|
|
802
|
+
* float-barrier box, or the end of the document. */
|
|
803
|
+
const nextIsBarrier = (from) => {
|
|
804
|
+
for (let i = from; i < contentBlocks.length; i++) {
|
|
805
|
+
const b = contentBlocks[i];
|
|
806
|
+
if (b.type === 'containerEnd')
|
|
807
|
+
continue;
|
|
808
|
+
if (b.type === 'containerStart') {
|
|
809
|
+
if (b.containerName === 'part')
|
|
810
|
+
return true;
|
|
811
|
+
if (b.containerName === 'callout') {
|
|
812
|
+
const plan = calloutPlan.get(i);
|
|
813
|
+
const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
|
|
814
|
+
return style?.floatBarrier === true;
|
|
815
|
+
}
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (b.type === 'heading' && b.level) {
|
|
819
|
+
const level = headingLevelByNumber.get(b.level);
|
|
820
|
+
return level?.breakBefore?.enabled === true || level?.span === 'page';
|
|
821
|
+
}
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
return true;
|
|
825
|
+
};
|
|
826
|
+
const rectsOverlap = (a, b) => a.x < b.x + b.width - 0.5 && a.x + a.width > b.x + 0.5
|
|
827
|
+
&& a.y < b.y + b.height - 0.5 && a.y + a.height > b.y + 0.5;
|
|
828
|
+
/**
|
|
829
|
+
* Place a `placement: 'fixed'` `:::callout` at page coordinates: the box
|
|
830
|
+
* is anchored (nine-point grid + offset) to the page content area, the
|
|
831
|
+
* trim box or the bleed box of the page where it occurs in the flow, out
|
|
832
|
+
* of the column flow. Text columns whose x-range meets the box give up the
|
|
833
|
+
* zone it covers — cut from the bottom (the zone's centre in the lower
|
|
834
|
+
* half) or from the top (empty columns only) — like a float band. When the
|
|
835
|
+
* zone already meets placed content, a float or a span column, the box
|
|
836
|
+
* moves to the next page and is force-placed there. Frame and children go
|
|
837
|
+
* to `page.floats` (rendered outside the column clip) and `doc.blocks`.
|
|
838
|
+
* A box that closes the chapter first levels the band above it (trailing
|
|
839
|
+
* cap), so the closing columns end at the same height above the box.
|
|
840
|
+
*/
|
|
841
|
+
const placeCalloutFixed = (startIdx, plan, style, layoutAt) => {
|
|
842
|
+
const anchor = style.fixed.anchor;
|
|
843
|
+
const offset = {
|
|
844
|
+
x: dimensionToPx(style.fixed.offset.x, dpi, bodyStyle.fontSizePx),
|
|
845
|
+
y: dimensionToPx(style.fixed.offset.y, dpi, bodyStyle.fontSizePx),
|
|
846
|
+
};
|
|
847
|
+
const snapDown = (page, v) => page.contentArea.y + Math.floor((v - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
|
|
848
|
+
const snapUp = (page, v) => page.contentArea.y + Math.ceil((v - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
|
|
849
|
+
const attempt = (page, force) => {
|
|
850
|
+
const ref = anchor.to === 'page' ? designFrames.page
|
|
851
|
+
: anchor.to === 'bleed' ? designFrames.bleed
|
|
852
|
+
: page.contentArea;
|
|
853
|
+
const band = cursor.pageIndex === page.index ? currentBand(page, cursor) : 0;
|
|
854
|
+
const cols = bandColumns(page, band).filter((c) => c.bbox.height > 0.5);
|
|
855
|
+
let result = layoutAt(page.contentArea.width);
|
|
856
|
+
if (style.width !== 'auto') {
|
|
857
|
+
// `fill`: as wide as the text column under the anchor point.
|
|
858
|
+
const probe = anchorBox(anchor.edge, ref, result.width, result.totalHeight, offset);
|
|
859
|
+
const mid = probe.x + probe.width / 2;
|
|
860
|
+
const under = cols.find((c) => mid >= c.bbox.x - 0.5 && mid <= c.bbox.x + c.bbox.width + 0.5);
|
|
861
|
+
if (under && Math.abs(under.bbox.width - result.width) > 0.01)
|
|
862
|
+
result = layoutAt(under.bbox.width);
|
|
863
|
+
}
|
|
864
|
+
const rect = anchorBox(anchor.edge, ref, result.width, result.totalHeight, offset);
|
|
865
|
+
const zoneTop = snapDown(page, rect.y - result.marginTopPx);
|
|
866
|
+
const zoneBottom = snapUp(page, rect.y + rect.height + result.marginBottomPx);
|
|
867
|
+
const cuts = [];
|
|
868
|
+
for (const col of cols) {
|
|
869
|
+
const meetsX = rect.x < col.bbox.x + col.bbox.width - 0.5 && rect.x + rect.width > col.bbox.x + 0.5;
|
|
870
|
+
if (!meetsX)
|
|
871
|
+
continue;
|
|
872
|
+
const colTop = col.bbox.y;
|
|
873
|
+
const colBottom = trueBottom(col, uncappedBottoms);
|
|
874
|
+
if (zoneTop >= colBottom - 0.5 || zoneBottom <= colTop + 0.5)
|
|
875
|
+
continue;
|
|
876
|
+
const centre = (zoneTop + zoneBottom) / 2;
|
|
877
|
+
if (centre >= colTop + (colBottom - colTop) / 2) {
|
|
878
|
+
const usedBottom = colTop + (col.bbox.height - col.availableHeight);
|
|
879
|
+
const capBottom = uncappedBottoms.has(col) ? colTop + col.bbox.height : usedBottom;
|
|
880
|
+
if (zoneTop < Math.max(usedBottom, capBottom) - 0.01) {
|
|
881
|
+
if (!force)
|
|
882
|
+
return null;
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
cuts.push({ col, kind: 'bottom', edge: zoneTop });
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
if (col.blocks.length > 0) {
|
|
889
|
+
if (!force)
|
|
890
|
+
return null;
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
cuts.push({ col, kind: 'top', edge: zoneBottom });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (!force) {
|
|
897
|
+
for (const fb of page.floats ?? [])
|
|
898
|
+
if (rectsOverlap(rect, fb.bbox))
|
|
899
|
+
return null;
|
|
900
|
+
for (const c of page.columns)
|
|
901
|
+
if (c.kind === 'span' && rectsOverlap(rect, c.bbox))
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
return { rect, result, cuts };
|
|
905
|
+
};
|
|
906
|
+
// A box that closes the chapter levels the columns above it first.
|
|
907
|
+
if (nextIsBarrier(plan.endIdx + 1)) {
|
|
908
|
+
tryPlacePendingFloatsOnCurrentPage();
|
|
909
|
+
proposeTrailingCap(startIdx);
|
|
910
|
+
}
|
|
911
|
+
let page = doc.pages[cursor.pageIndex];
|
|
912
|
+
let fit = attempt(page, false);
|
|
913
|
+
if (!fit) {
|
|
914
|
+
pendingSpacing = 0;
|
|
915
|
+
const startPageIndex = cursor.pageIndex;
|
|
916
|
+
do {
|
|
917
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
918
|
+
} while (cursor.pageIndex === startPageIndex);
|
|
919
|
+
page = doc.pages[cursor.pageIndex];
|
|
920
|
+
fit = attempt(page, false) ?? attempt(page, true);
|
|
921
|
+
}
|
|
922
|
+
for (const cut of fit.cuts) {
|
|
923
|
+
const col = cut.col;
|
|
924
|
+
if (cut.kind === 'bottom') {
|
|
925
|
+
const capped = uncappedBottoms.get(col);
|
|
926
|
+
if (capped !== undefined) {
|
|
927
|
+
uncappedBottoms.set(col, cut.edge);
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
const newHeight = Math.max(0, cut.edge - col.bbox.y);
|
|
931
|
+
const reserved = col.bbox.height - newHeight;
|
|
932
|
+
col.bbox.height = newHeight;
|
|
933
|
+
col.availableHeight = Math.max(0, col.availableHeight - reserved);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
else {
|
|
937
|
+
const shift = Math.max(0, cut.edge - col.bbox.y);
|
|
938
|
+
col.bbox.y += shift;
|
|
939
|
+
col.bbox.height = Math.max(0, col.bbox.height - shift);
|
|
940
|
+
col.availableHeight = Math.max(0, col.availableHeight - shift);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const { result, rect } = fit;
|
|
944
|
+
const frame = result.frame;
|
|
945
|
+
stampCalloutSource(frame, startIdx, plan);
|
|
946
|
+
frame.pageIndex = page.index;
|
|
947
|
+
frame.columnIndex = fit.cuts[0]?.col.index ?? (cursor.pageIndex === page.index ? cursor.columnIndex : 0);
|
|
948
|
+
offsetCalloutToAbsolute(result, rect.x, rect.y);
|
|
949
|
+
const floats = (page.floats ??= []);
|
|
950
|
+
doc.blocks.push(frame);
|
|
951
|
+
floats.push(frame);
|
|
952
|
+
for (const child of result.children) {
|
|
953
|
+
child.pageIndex = frame.pageIndex;
|
|
954
|
+
child.columnIndex = frame.columnIndex;
|
|
955
|
+
doc.blocks.push(child);
|
|
956
|
+
floats.push(child);
|
|
957
|
+
}
|
|
958
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
959
|
+
enqueueFloatsFor(i);
|
|
960
|
+
};
|
|
659
961
|
/**
|
|
660
962
|
* Place a `:::callout` inline at the current column width as one atomic
|
|
661
963
|
* unit: the frame block followed by its children in the same column. The
|
|
@@ -692,6 +994,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
692
994
|
paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
|
|
693
995
|
});
|
|
694
996
|
};
|
|
997
|
+
// Fixed boxes leave the flow entirely.
|
|
998
|
+
if (placement === 'fixed') {
|
|
999
|
+
placeCalloutFixed(startIdx, plan, style, layoutAt);
|
|
1000
|
+
return undefined;
|
|
1001
|
+
}
|
|
695
1002
|
// Page-span boxes split a multi-column page into column bands (stage 1
|
|
696
1003
|
// of span blocks). Floating placements keep the inline fallback.
|
|
697
1004
|
{
|
|
@@ -735,11 +1042,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
735
1042
|
// Floats first-referenced inside the box still enqueue in reading order
|
|
736
1043
|
// (only once the box is committed, so a keep-with-next replay does not
|
|
737
1044
|
// enqueue them twice).
|
|
738
|
-
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
739
|
-
|
|
740
|
-
if (fl)
|
|
741
|
-
pendingFloats.push(...fl);
|
|
742
|
-
}
|
|
1045
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
1046
|
+
enqueueFloatsFor(i);
|
|
743
1047
|
const frame = result.frame;
|
|
744
1048
|
const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
|
|
745
1049
|
enterBand(startIdx, 0);
|
|
@@ -762,13 +1066,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
762
1066
|
if (options?.shouldCancel?.())
|
|
763
1067
|
throw new BuildCancelledError();
|
|
764
1068
|
const rawBlock = contentBlocks[blockIdx];
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1069
|
+
// Floats whose reference landed in an earlier iteration take the first
|
|
1070
|
+
// free slot of the current page now — after their reference in reading
|
|
1071
|
+
// order. Then enqueue the floats first-referenced in this block, so the
|
|
1072
|
+
// next page opened while placing it (or any later block) reserves their
|
|
1073
|
+
// band and the next iteration offers them the slots that follow.
|
|
1074
|
+
tryPlacePendingFloatsOnCurrentPage();
|
|
1075
|
+
enqueueFloatsFor(blockIdx);
|
|
772
1076
|
// --- Directives ----------------------------------------------------
|
|
773
1077
|
if (rawBlock.type === 'directive') {
|
|
774
1078
|
const name = rawBlock.directiveName;
|
|
@@ -784,6 +1088,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
784
1088
|
|| parity === 'always-even') {
|
|
785
1089
|
enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
|
|
786
1090
|
}
|
|
1091
|
+
// Pending floats land on the page that follows the break — after
|
|
1092
|
+
// parity padding, so a blank parity page never carries a float.
|
|
1093
|
+
flushFloatsIntoPage(doc.pages[cursor.pageIndex]);
|
|
787
1094
|
flushPendingNumberingAtBoundary();
|
|
788
1095
|
}
|
|
789
1096
|
else if (name === 'columnbreak') {
|
|
@@ -834,7 +1141,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
834
1141
|
const plan = partPlan.byStart.get(blockIdx);
|
|
835
1142
|
if (plan) {
|
|
836
1143
|
pendingSpacing = 0;
|
|
837
|
-
|
|
1144
|
+
closeFlowSegment(blockIdx);
|
|
838
1145
|
leaveCurrentPage();
|
|
839
1146
|
enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, resolved.parts.breakBefore.parity);
|
|
840
1147
|
cursor.columnIndex = 0;
|
|
@@ -870,7 +1177,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
870
1177
|
const parity = pendingPartBreak;
|
|
871
1178
|
pendingPartBreak = null;
|
|
872
1179
|
pendingSpacing = 0;
|
|
873
|
-
|
|
1180
|
+
closeFlowSegment(blockIdx);
|
|
874
1181
|
leaveCurrentPage();
|
|
875
1182
|
enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
|
|
876
1183
|
flushPendingNumberingAtBoundary();
|
|
@@ -878,6 +1185,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
878
1185
|
if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'callout') {
|
|
879
1186
|
const plan = calloutPlan.get(blockIdx);
|
|
880
1187
|
if (plan && pickCalloutStyle(resolved.calloutStyles, plan.attrs.type)) {
|
|
1188
|
+
// A float barrier box (e.g. a chapter's closing "key points")
|
|
1189
|
+
// takes every pending float first — in the current page's free
|
|
1190
|
+
// slots, else on pages opened ahead of it — so no float escapes
|
|
1191
|
+
// past it. The page stays balanceable (no forced break).
|
|
1192
|
+
if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier) {
|
|
1193
|
+
tryPlacePendingFloatsOnCurrentPage();
|
|
1194
|
+
drainPendingFloats();
|
|
1195
|
+
}
|
|
881
1196
|
// `span: 'page'` boxes in multi-column layouts branch to the
|
|
882
1197
|
// span-block path inside; `placement: 'top' | 'bottom'` still
|
|
883
1198
|
// falls back to inline placement (floating boxes pending).
|
|
@@ -909,7 +1224,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
909
1224
|
const bb = level?.breakBefore;
|
|
910
1225
|
if (bb && bb.enabled) {
|
|
911
1226
|
pendingSpacing = 0;
|
|
912
|
-
|
|
1227
|
+
closeFlowSegment(blockIdx);
|
|
913
1228
|
advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
914
1229
|
if (bb.parity !== 'any') {
|
|
915
1230
|
enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, bb.parity);
|
|
@@ -922,7 +1237,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
922
1237
|
// have their availableHeight reduced symmetrically after placement.
|
|
923
1238
|
if (level?.span === 'page') {
|
|
924
1239
|
pendingSpacing = 0;
|
|
925
|
-
|
|
1240
|
+
closeFlowSegment(blockIdx);
|
|
926
1241
|
advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
927
1242
|
cursor.columnIndex = 0;
|
|
928
1243
|
flushPendingNumberingAtBoundary();
|
|
@@ -1447,6 +1762,14 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1447
1762
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
1448
1763
|
continue;
|
|
1449
1764
|
}
|
|
1765
|
+
// Empty column with less than a line of room (a band cap cutting right
|
|
1766
|
+
// under a float band, a column swallowed by reservations): nothing can
|
|
1767
|
+
// go here — move on. The next column, or a fresh page, has room.
|
|
1768
|
+
if (curCol.availableHeight < style.lineHeightPx - 0.01 && totalRemainHeight > curCol.availableHeight + 0.01) {
|
|
1769
|
+
pendingSpacing = 0;
|
|
1770
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1450
1773
|
// Empty column but block still doesn't fit (block taller than page) — place anyway
|
|
1451
1774
|
const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
|
|
1452
1775
|
const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
|
|
@@ -1488,9 +1811,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1488
1811
|
}
|
|
1489
1812
|
flushPendingNumberingAtBoundary();
|
|
1490
1813
|
}
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
|
|
1814
|
+
// End of the document: level the closing band and place any floats still
|
|
1815
|
+
// pending (referenced on the last page) on pages appended after it.
|
|
1816
|
+
closeFlowSegment(contentBlocks.length);
|
|
1494
1817
|
// Stamp page-number info onto every page (including blank parity pages).
|
|
1495
1818
|
const labels = buildPageLabels(doc.pages.length, pageNumberSegments);
|
|
1496
1819
|
for (let i = 0; i < doc.pages.length; i++) {
|
|
@@ -1508,16 +1831,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1508
1831
|
return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome };
|
|
1509
1832
|
}
|
|
1510
1833
|
export function buildDocument(content, config, cache, options) {
|
|
1834
|
+
const runPass = (hints) => buildDocumentPass(content, config, cache, options, hints);
|
|
1511
1835
|
// --- Band caps (page-span blocks mid-page) -----------------------------
|
|
1512
1836
|
// A span block that arrived in an uneven band proposes a cap; the driver
|
|
1513
1837
|
// re-places the document with it (and grows / drops caps whose band
|
|
1514
1838
|
// overflowed) before balancing runs. Documents without such blocks get
|
|
1515
1839
|
// their first pass back untouched — no extra pass.
|
|
1516
|
-
const bands = resolveBandCaps(
|
|
1840
|
+
const bands = resolveBandCaps(runPass(), (bandCaps) => runPass({ bandCaps }));
|
|
1517
1841
|
let best = bands.result;
|
|
1518
1842
|
const bandCaps = bands.bandCaps;
|
|
1519
1843
|
let passCount = bands.passCount;
|
|
1520
1844
|
best.doc.iterationCount = passCount;
|
|
1845
|
+
/** Hints that produced `best` (replayed by the trailing-cap passes). */
|
|
1846
|
+
let bestHints = { bandCaps };
|
|
1521
1847
|
// --- Column balancing (vertical justification) ------------------------
|
|
1522
1848
|
// Iteratively re-place the document with extra grid lines above headings
|
|
1523
1849
|
// until every balanceable column ends flush with the page bottom (or no
|
|
@@ -1533,66 +1859,93 @@ export function buildDocument(content, config, cache, options) {
|
|
|
1533
1859
|
let applied = { lines: new Map(), loose: new Map() };
|
|
1534
1860
|
const failedLoose = new Set();
|
|
1535
1861
|
let converged = bestScore === 0;
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
passCount++;
|
|
1561
|
-
// Band caps ride along unchanged; a retry that unsettles one (its span
|
|
1562
|
-
// block no longer lands in the capped band) counts as a regression —
|
|
1563
|
-
// capped columns without their box are not a layout we may keep.
|
|
1564
|
-
const capsDelivered = [...bandCaps.keys()].every((i) => next.spanPlacedInBand.has(i));
|
|
1565
|
-
const score = capsDelivered ? totalGapLines(next.doc, next.forcedBreakPages) : Infinity;
|
|
1566
|
-
// Loose paragraphs that gained no line at any tracking rung are
|
|
1567
|
-
// blacklisted whatever the score did, and never counted as applied.
|
|
1568
|
-
// Candidates the pass never tried (their column's budget was met
|
|
1569
|
-
// first) stay eligible for a later proposal.
|
|
1570
|
-
const newlyLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
|
|
1571
|
-
const looseFailed = newlyLoose.filter((k) => next.looseOutcome.get(k) === null);
|
|
1572
|
-
for (const k of looseFailed)
|
|
1573
|
-
failedLoose.add(k);
|
|
1574
|
-
const looseWon = newlyLoose.filter((k) => typeof next.looseOutcome.get(k) === 'number');
|
|
1575
|
-
if (score < bestScore) {
|
|
1576
|
-
best = next;
|
|
1577
|
-
bestScore = score;
|
|
1578
|
-
applied = {
|
|
1579
|
-
lines: proposal.lines,
|
|
1580
|
-
loose: new Map([...proposal.loose].filter(([k]) => applied.loose.has(k) || looseWon.includes(k))),
|
|
1862
|
+
const balance = () => {
|
|
1863
|
+
while (!converged && passCount < MAX_BALANCING_PASSES) {
|
|
1864
|
+
const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
|
|
1865
|
+
maxLinesPerHeading: balancing.maxLinesPerHeading,
|
|
1866
|
+
stretchAfterLists: balancing.stretchAfterLists,
|
|
1867
|
+
maxLinesAfterList: balancing.maxLinesAfterList,
|
|
1868
|
+
looseParagraphs: balancing.looseParagraphs,
|
|
1869
|
+
maxLooseParagraphs: balancing.maxLooseParagraphs,
|
|
1870
|
+
optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
|
|
1871
|
+
failedLoose,
|
|
1872
|
+
});
|
|
1873
|
+
if (!proposal.changed) {
|
|
1874
|
+
// No stretch point can absorb the remaining gaps — stable.
|
|
1875
|
+
converged = true;
|
|
1876
|
+
break;
|
|
1877
|
+
}
|
|
1878
|
+
const extraPx = new Map();
|
|
1879
|
+
for (const [idx, n] of proposal.lines)
|
|
1880
|
+
extraPx.set(idx, n * best.doc.baselineGrid);
|
|
1881
|
+
const hints = {
|
|
1882
|
+
balanceExtraPx: extraPx,
|
|
1883
|
+
balanceLooseness: proposal.loose,
|
|
1884
|
+
balanceLooseBudget: proposal.looseBudget,
|
|
1885
|
+
bandCaps,
|
|
1581
1886
|
};
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
//
|
|
1586
|
-
//
|
|
1587
|
-
//
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1887
|
+
const next = runPass(hints);
|
|
1888
|
+
passCount++;
|
|
1889
|
+
// Band caps ride along unchanged; a retry that unsettles one (its span
|
|
1890
|
+
// block no longer lands in the capped band, or a levelled closing band
|
|
1891
|
+
// spills past its cut) counts as a regression — capped columns without
|
|
1892
|
+
// their box are not a layout we may keep.
|
|
1893
|
+
const capsDelivered = [...bandCaps.keys()].every((i) => next.spanPlacedInBand.has(i));
|
|
1894
|
+
const score = capsDelivered ? totalGapLines(next.doc, next.forcedBreakPages) : Infinity;
|
|
1895
|
+
// Loose paragraphs that gained no line at any tracking rung are
|
|
1896
|
+
// blacklisted whatever the score did, and never counted as applied.
|
|
1897
|
+
// Candidates the pass never tried (their column's budget was met
|
|
1898
|
+
// first) stay eligible for a later proposal.
|
|
1899
|
+
const newlyLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
|
|
1900
|
+
const looseFailed = newlyLoose.filter((k) => next.looseOutcome.get(k) === null);
|
|
1901
|
+
for (const k of looseFailed)
|
|
1902
|
+
failedLoose.add(k);
|
|
1903
|
+
const looseWon = newlyLoose.filter((k) => typeof next.looseOutcome.get(k) === 'number');
|
|
1904
|
+
if (score < bestScore) {
|
|
1905
|
+
best = next;
|
|
1906
|
+
bestHints = hints;
|
|
1907
|
+
bestScore = score;
|
|
1908
|
+
applied = {
|
|
1909
|
+
lines: proposal.lines,
|
|
1910
|
+
loose: new Map([...proposal.loose].filter(([k]) => applied.loose.has(k) || looseWon.includes(k))),
|
|
1911
|
+
};
|
|
1912
|
+
converged = score === 0;
|
|
1594
1913
|
}
|
|
1595
|
-
|
|
1914
|
+
else {
|
|
1915
|
+
// Plateau or regression. Retry when a loose candidate was just
|
|
1916
|
+
// blacklisted (the proposer falls through to the next one), or when
|
|
1917
|
+
// the new loose paragraphs gained their lines yet the layout did not
|
|
1918
|
+
// improve (the gain landed elsewhere — drop them too). A pure spacing
|
|
1919
|
+
// plateau means we're done: keep the best layout found so far.
|
|
1920
|
+
if (looseFailed.length > 0 || looseWon.length > 0) {
|
|
1921
|
+
for (const k of looseWon)
|
|
1922
|
+
failedLoose.add(k);
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1925
|
+
break;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
};
|
|
1929
|
+
balance();
|
|
1930
|
+
// --- Trailing bands (closing columns cut level) -------------------------
|
|
1931
|
+
// Once the balancing levers have settled the earlier pages, level the
|
|
1932
|
+
// closing band of every chapter / the document with a trailing cap. It is
|
|
1933
|
+
// resolved AFTER balancing because a cap is keyed by the block that opens
|
|
1934
|
+
// its band, and that block moves whenever an earlier page absorbs extra
|
|
1935
|
+
// lines; with the balancing hints frozen the band opens with the same
|
|
1936
|
+
// block and the cap applies. A short polish round then lets the levers
|
|
1937
|
+
// fill what the cut left short (a column ending a line under the cap).
|
|
1938
|
+
if (balancing.trailing) {
|
|
1939
|
+
const trailing = resolveTrailingCaps(best, bandCaps, (caps) => runPass({ ...bestHints, bandCaps: caps }));
|
|
1940
|
+
passCount += trailing.passCount;
|
|
1941
|
+
if (trailing.result !== best) {
|
|
1942
|
+
best = trailing.result;
|
|
1943
|
+
for (const [i, cap] of trailing.caps)
|
|
1944
|
+
bandCaps.set(i, cap);
|
|
1945
|
+
bestHints = { ...bestHints, bandCaps };
|
|
1946
|
+
bestScore = totalGapLines(best.doc, best.forcedBreakPages);
|
|
1947
|
+
converged = bestScore === 0;
|
|
1948
|
+
balance();
|
|
1596
1949
|
}
|
|
1597
1950
|
}
|
|
1598
1951
|
best.doc.iterationCount = passCount;
|