postext 0.3.28 → 0.3.30
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 +78 -1
- package/dist/__tests__/columnBalancing.test.js.map +1 -1
- package/dist/__tests__/exports.test.js +2 -0
- package/dist/__tests__/exports.test.js.map +1 -1
- package/dist/__tests__/pipeline/calloutOverflow.test.d.ts +2 -0
- package/dist/__tests__/pipeline/calloutOverflow.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/calloutOverflow.test.js +189 -0
- package/dist/__tests__/pipeline/calloutOverflow.test.js.map +1 -0
- package/dist/__tests__/pipeline/colonBalancing.test.d.ts +2 -0
- package/dist/__tests__/pipeline/colonBalancing.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/colonBalancing.test.js +124 -0
- package/dist/__tests__/pipeline/colonBalancing.test.js.map +1 -0
- package/dist/__tests__/pipeline/floatFirstSlot.test.js +33 -0
- package/dist/__tests__/pipeline/floatFirstSlot.test.js.map +1 -1
- package/dist/__tests__/pipeline/headingShortBand.test.d.ts +2 -0
- package/dist/__tests__/pipeline/headingShortBand.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/headingShortBand.test.js +89 -0
- package/dist/__tests__/pipeline/headingShortBand.test.js.map +1 -0
- package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.d.ts +2 -0
- package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.js +96 -0
- package/dist/__tests__/pipeline/spanBoxHeadAfterCut.test.js.map +1 -0
- package/dist/__tests__/pipeline/spanFloatAtCut.test.d.ts +2 -0
- package/dist/__tests__/pipeline/spanFloatAtCut.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/spanFloatAtCut.test.js +155 -0
- package/dist/__tests__/pipeline/spanFloatAtCut.test.js.map +1 -0
- package/dist/__tests__/pipeline/tableCellImages.test.d.ts +2 -0
- package/dist/__tests__/pipeline/tableCellImages.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/tableCellImages.test.js +166 -0
- package/dist/__tests__/pipeline/tableCellImages.test.js.map +1 -0
- package/dist/__tests__/pipeline/tableCellLists.test.d.ts +2 -0
- package/dist/__tests__/pipeline/tableCellLists.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/tableCellLists.test.js +91 -0
- package/dist/__tests__/pipeline/tableCellLists.test.js.map +1 -0
- package/dist/__tests__/pipeline/tableSlice.test.d.ts +2 -0
- package/dist/__tests__/pipeline/tableSlice.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/tableSlice.test.js +226 -0
- package/dist/__tests__/pipeline/tableSlice.test.js.map +1 -0
- package/dist/__tests__/pipeline/tableSplit.test.d.ts +2 -0
- package/dist/__tests__/pipeline/tableSplit.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/tableSplit.test.js +154 -0
- package/dist/__tests__/pipeline/tableSplit.test.js.map +1 -0
- package/dist/__tests__/pipeline/trailingCalloutBalance.test.d.ts +2 -0
- package/dist/__tests__/pipeline/trailingCalloutBalance.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/trailingCalloutBalance.test.js +60 -0
- package/dist/__tests__/pipeline/trailingCalloutBalance.test.js.map +1 -0
- package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
- package/dist/canvas-backend/headerFooter.js +2 -6
- package/dist/canvas-backend/headerFooter.js.map +1 -1
- package/dist/canvas-backend/index.d.ts +1 -1
- package/dist/canvas-backend/index.d.ts.map +1 -1
- package/dist/canvas-backend/renderResourceBlock.d.ts +15 -1
- package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
- package/dist/canvas-backend/renderResourceBlock.js +117 -9
- package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
- package/dist/defaults/calloutStyles.d.ts +1 -0
- package/dist/defaults/calloutStyles.d.ts.map +1 -1
- package/dist/defaults/calloutStyles.js +6 -0
- package/dist/defaults/calloutStyles.js.map +1 -1
- package/dist/defaults/headings.d.ts +2 -0
- package/dist/defaults/headings.d.ts.map +1 -1
- package/dist/defaults/headings.js +14 -0
- package/dist/defaults/headings.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/defaults/tableStyle.d.ts +7 -1
- package/dist/defaults/tableStyle.d.ts.map +1 -1
- package/dist/defaults/tableStyle.js +35 -1
- package/dist/defaults/tableStyle.js.map +1 -1
- package/dist/html-backend.d.ts.map +1 -1
- package/dist/html-backend.js +32 -19
- package/dist/html-backend.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/knuthPlass/breakpoints.d.ts.map +1 -1
- package/dist/knuthPlass/breakpoints.js +6 -2
- package/dist/knuthPlass/breakpoints.js.map +1 -1
- package/dist/knuthPlass/constants.d.ts +3 -1
- package/dist/knuthPlass/constants.d.ts.map +1 -1
- package/dist/knuthPlass/constants.js +3 -1
- package/dist/knuthPlass/constants.js.map +1 -1
- package/dist/pipeline/bandCaps.d.ts +4 -2
- package/dist/pipeline/bandCaps.d.ts.map +1 -1
- package/dist/pipeline/bandCaps.js +5 -3
- package/dist/pipeline/bandCaps.js.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +710 -107
- package/dist/pipeline/build.js.map +1 -1
- package/dist/pipeline/calloutLayout.d.ts +7 -0
- package/dist/pipeline/calloutLayout.d.ts.map +1 -1
- package/dist/pipeline/calloutLayout.js +36 -6
- package/dist/pipeline/calloutLayout.js.map +1 -1
- package/dist/pipeline/columnBalancing.d.ts +46 -5
- package/dist/pipeline/columnBalancing.d.ts.map +1 -1
- package/dist/pipeline/columnBalancing.js +142 -6
- package/dist/pipeline/columnBalancing.js.map +1 -1
- package/dist/pipeline/config.js +1 -1
- package/dist/pipeline/config.js.map +1 -1
- package/dist/pipeline/floatPlacement.d.ts +4 -0
- package/dist/pipeline/floatPlacement.d.ts.map +1 -1
- package/dist/pipeline/floatPlacement.js.map +1 -1
- package/dist/pipeline/resourceLayout.d.ts +55 -1
- package/dist/pipeline/resourceLayout.d.ts.map +1 -1
- package/dist/pipeline/resourceLayout.js +394 -30
- package/dist/pipeline/resourceLayout.js.map +1 -1
- package/dist/table/model.d.ts +7 -1
- package/dist/table/model.d.ts.map +1 -1
- package/dist/table/model.js +22 -0
- package/dist/table/model.js.map +1 -1
- package/dist/types.d.ts +56 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +55 -0
- package/dist/vdt.d.ts.map +1 -1
- package/dist/vdt.js.map +1 -1
- package/package.json +1 -1
package/dist/pipeline/build.js
CHANGED
|
@@ -16,13 +16,13 @@ import { measureContentBlock } from './measureContentBlock';
|
|
|
16
16
|
import { planParagraphContainers } from './paragraphContainers';
|
|
17
17
|
import { planParts, derivePartMeasureContext } from './parts';
|
|
18
18
|
import { layoutCallout, offsetCalloutToAbsolute, pickCalloutStyle, planCallouts, resolveCalloutAttrs, } from './calloutLayout';
|
|
19
|
-
import { layoutResourceBlock } from './resourceLayout';
|
|
19
|
+
import { layoutResourceBlock, planTableSlice } from './resourceLayout';
|
|
20
20
|
import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
|
|
21
21
|
import { enumerateCurrentPageSlots, measureFloatBand, columnHasFloatBand, fitsStrict, trueBottom, } from './floatSlots';
|
|
22
22
|
import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
|
|
23
23
|
import { defaultResourceTypes } from '../defaults/resourceTypes';
|
|
24
24
|
import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
|
|
25
|
-
import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES } from './columnBalancing';
|
|
25
|
+
import { totalGapLines, proposeBalanceLines, collectColumnGaps, firstDivergentColumn, MAX_BALANCING_PASSES, balanceKey } from './columnBalancing';
|
|
26
26
|
import { applyBandCap, uncapBand, columnBottom, bandCapLines, bandTop, resolveBandCaps, resolveTrailingCaps, bandCapLinesAroundZone, } from './bandCaps';
|
|
27
27
|
import { raggedUrlLines } from './raggedUrl';
|
|
28
28
|
export class BuildCancelledError extends Error {
|
|
@@ -207,6 +207,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
207
207
|
};
|
|
208
208
|
const floatGapPx = bodyStyle.lineHeightPx;
|
|
209
209
|
const minTextPx = bodyStyle.lineHeightPx * 3;
|
|
210
|
+
/** Fewest body rows the closing slice of a split table carries. */
|
|
211
|
+
const MIN_TAIL_ROWS = 3;
|
|
210
212
|
/** Offset a resolved resource block's caption/table geometry from
|
|
211
213
|
* block-relative to absolute page coordinates (mirrors inline placement). */
|
|
212
214
|
const offsetResourceBlockToAbsolute = (rb, ox, oy) => {
|
|
@@ -220,6 +222,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
220
222
|
ln.bbox.y += oy;
|
|
221
223
|
ln.baseline += oy;
|
|
222
224
|
}
|
|
225
|
+
for (const ln of rb.continuesLines) {
|
|
226
|
+
ln.bbox.x += ox;
|
|
227
|
+
ln.bbox.y += oy;
|
|
228
|
+
ln.baseline += oy;
|
|
229
|
+
}
|
|
223
230
|
if (rb.captionBar) {
|
|
224
231
|
rb.captionBar.rect.x += ox;
|
|
225
232
|
rb.captionBar.rect.y += oy;
|
|
@@ -228,6 +235,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
228
235
|
for (const cell of rb.table.cells) {
|
|
229
236
|
cell.rect.x += ox;
|
|
230
237
|
cell.rect.y += oy;
|
|
238
|
+
if (cell.image) {
|
|
239
|
+
cell.image.rect.x += ox;
|
|
240
|
+
cell.image.rect.y += oy;
|
|
241
|
+
}
|
|
231
242
|
for (const cl of cell.lines) {
|
|
232
243
|
cl.bbox.x += ox;
|
|
233
244
|
cl.bbox.y += oy;
|
|
@@ -236,7 +247,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
236
247
|
}
|
|
237
248
|
}
|
|
238
249
|
};
|
|
239
|
-
const layoutFloat = (resourceId, width) => {
|
|
250
|
+
const layoutFloat = (resourceId, width, slice) => {
|
|
240
251
|
const resource = resourceById.get(resourceId);
|
|
241
252
|
if (!resource)
|
|
242
253
|
return null;
|
|
@@ -249,17 +260,38 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
249
260
|
resourceNumbering,
|
|
250
261
|
resourceTypes,
|
|
251
262
|
resources,
|
|
263
|
+
...(slice ? { slice } : {}),
|
|
252
264
|
});
|
|
253
265
|
};
|
|
266
|
+
/** Row count of a table resource (0 for anything else). */
|
|
267
|
+
const tableRowCount = (resourceId) => resourceById.get(resourceId)?.table?.model.rows.length ?? 0;
|
|
268
|
+
/** The slice a pending float stands for: the whole resource, or — for the
|
|
269
|
+
* rest of a split table — its remaining rows, laid out as the closing
|
|
270
|
+
* slice (no marker; the note). */
|
|
271
|
+
const sliceOf = (f) => f.startRow !== undefined && f.startRow > 0
|
|
272
|
+
? { startRow: f.startRow, endRow: tableRowCount(f.resourceId), continues: false }
|
|
273
|
+
: undefined;
|
|
274
|
+
const sliceKey = (slice) => slice ? `:${slice.startRow}-${slice.endRow}${slice.continues ? '+' : ''}` : '';
|
|
275
|
+
/** Row metrics of a table float laid out in full at a width, memoised. */
|
|
276
|
+
const tableMetricsMemo = new Map();
|
|
277
|
+
const tableMetrics = (resourceId, width) => {
|
|
278
|
+
const key = `${resourceId}:${width.toFixed(2)}`;
|
|
279
|
+
const memo = tableMetricsMemo.get(key);
|
|
280
|
+
if (memo !== undefined)
|
|
281
|
+
return memo;
|
|
282
|
+
const m = layoutFloat(resourceId, width)?.tableRows ?? null;
|
|
283
|
+
tableMetricsMemo.set(key, m);
|
|
284
|
+
return m;
|
|
285
|
+
};
|
|
254
286
|
/** Height (and caption baseline) of a float at a given width, memoised —
|
|
255
287
|
* fit checks run for every pending float on every loop iteration. */
|
|
256
288
|
const floatMeasureMemo = new Map();
|
|
257
|
-
const measureFloat = (resourceId, width) => {
|
|
258
|
-
const key = `${resourceId}:${width.toFixed(2)}`;
|
|
289
|
+
const measureFloat = (resourceId, width, slice) => {
|
|
290
|
+
const key = `${resourceId}:${width.toFixed(2)}${sliceKey(slice)}`;
|
|
259
291
|
const memo = floatMeasureMemo.get(key);
|
|
260
292
|
if (memo !== undefined)
|
|
261
293
|
return memo;
|
|
262
|
-
const laid = layoutFloat(resourceId, width);
|
|
294
|
+
const laid = layoutFloat(resourceId, width, slice);
|
|
263
295
|
let m = null;
|
|
264
296
|
if (laid) {
|
|
265
297
|
// A bottom band aligns the float's LAST text line to the grid: the
|
|
@@ -269,7 +301,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
269
301
|
const rb = laid.block;
|
|
270
302
|
const bodyBottom = rb.bodyRect.y + rb.bodyRect.height;
|
|
271
303
|
let lastBaseline;
|
|
272
|
-
for (const ln of [...rb.captionLines, ...rb.noteLines]) {
|
|
304
|
+
for (const ln of [...rb.captionLines, ...rb.noteLines, ...rb.continuesLines]) {
|
|
273
305
|
if (ln.bbox.y < bodyBottom - 0.5)
|
|
274
306
|
continue;
|
|
275
307
|
if (lastBaseline === undefined || ln.baseline > lastBaseline)
|
|
@@ -285,12 +317,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
285
317
|
};
|
|
286
318
|
/** Measure + build a float block at horizontal offset `x` (y = 0), or null
|
|
287
319
|
* when the resource id is unknown. Caller offsets it to its final `y`. */
|
|
288
|
-
const buildFloatBlock = (resourceId, x, width) => {
|
|
289
|
-
const laid = layoutFloat(resourceId, width);
|
|
320
|
+
const buildFloatBlock = (resourceId, x, width, slice) => {
|
|
321
|
+
const laid = layoutFloat(resourceId, width, slice);
|
|
290
322
|
if (!laid)
|
|
291
323
|
return null;
|
|
292
324
|
const { block: rb, totalHeight } = laid;
|
|
293
|
-
const
|
|
325
|
+
const id = slice && slice.startRow > 0 ? `float-${resourceId}-cont-${slice.startRow}` : `float-${resourceId}`;
|
|
326
|
+
const blk = createVDTBlock(id, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
|
|
294
327
|
blk.resourceBlock = rb;
|
|
295
328
|
blk.dirty = false;
|
|
296
329
|
blk.snappedToGrid = false;
|
|
@@ -303,6 +336,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
303
336
|
* sends single-column floats to the least reserved column). */
|
|
304
337
|
const floatReserved = new Map();
|
|
305
338
|
const reservedOf = (col) => floatReserved.get(col) ?? { top: 0, bottom: 0 };
|
|
339
|
+
/** Content index of the block that first referenced the latest float
|
|
340
|
+
* reserved at the head of each column. */
|
|
341
|
+
const topFloatRefOf = new Map();
|
|
306
342
|
/** Kind of cap the column is under (`undefined` when uncapped). A cap that
|
|
307
343
|
* cannot be attributed to the active band is treated as a span cap — the
|
|
308
344
|
* conservative reading, which keeps the column's bottom off the slot list. */
|
|
@@ -314,24 +350,136 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
314
350
|
}
|
|
315
351
|
return 'span';
|
|
316
352
|
};
|
|
353
|
+
/** Floats (or slices) placed so far — the progress signal of the drain
|
|
354
|
+
* loop, which a split does not shorten the queue for. */
|
|
355
|
+
let floatsPlaced = 0;
|
|
356
|
+
/**
|
|
357
|
+
* The slice of a table float that fits a fresh band of `avail` px, when
|
|
358
|
+
* the whole (rest of the) table does not: the leading rows within the
|
|
359
|
+
* band, cut where `planTableSlice` allows, verified against the band
|
|
360
|
+
* geometry and shrunk row by row until it fits. `tableStyle.overflow`
|
|
361
|
+
* decides what becomes of the rows left over — a rest float to continue
|
|
362
|
+
* on the next page (`'split'`), nothing (`'clip'`) — or, for `'hide'`,
|
|
363
|
+
* that the table is dropped. Returns null for a figure or a table with
|
|
364
|
+
* nothing to cut, which are force-placed like before.
|
|
365
|
+
*/
|
|
366
|
+
const splitTableFloat = (f, width, position, targetCols, contentArea, avail) => {
|
|
367
|
+
const rowCount = tableRowCount(f.resourceId);
|
|
368
|
+
if (rowCount === 0)
|
|
369
|
+
return null;
|
|
370
|
+
const overflow = resolved.tableStyle.overflow;
|
|
371
|
+
if (overflow === 'hide')
|
|
372
|
+
return 'skip';
|
|
373
|
+
const metrics = tableMetrics(f.resourceId, width);
|
|
374
|
+
if (!metrics)
|
|
375
|
+
return null;
|
|
376
|
+
const startRow = f.startRow ?? 0;
|
|
377
|
+
const firstBody = startRow > 0 ? Math.max(startRow, metrics.headerRowCount) : metrics.headerRowCount;
|
|
378
|
+
if (firstBody >= rowCount)
|
|
379
|
+
return null;
|
|
380
|
+
// Overhead of a continuing slice at this width — caption, gaps, marker —
|
|
381
|
+
// from a one-row probe; the row heights come from the metrics.
|
|
382
|
+
const probe = layoutFloat(f.resourceId, width, { startRow, endRow: firstBody + 1, continues: true });
|
|
383
|
+
if (!probe)
|
|
384
|
+
return null;
|
|
385
|
+
const overhead = probe.totalHeight - probe.block.bodyRect.height;
|
|
386
|
+
// A top band rounds up to the grid: the tallest float it holds within
|
|
387
|
+
// `avail` is the grid multiple below it, less the gap.
|
|
388
|
+
const hMax = Math.floor((avail + 0.01) / baselineGrid) * baselineGrid - floatGapPx;
|
|
389
|
+
let end = planTableSlice(metrics, startRow, hMax - overhead);
|
|
390
|
+
// Nothing fits: carry the smallest slice anyway (it overflows, as a
|
|
391
|
+
// dominating figure would) rather than stall the queue.
|
|
392
|
+
const floor = firstBody + 1;
|
|
393
|
+
if (end < floor)
|
|
394
|
+
end = floor;
|
|
395
|
+
// A last page holding a row or two under a repeated header reads as a
|
|
396
|
+
// stranded tail: give the closing slice at least `MIN_TAIL_ROWS` rows by
|
|
397
|
+
// handing some back from this one (at a breakable edge, not under a
|
|
398
|
+
// group head), when this slice can spare them.
|
|
399
|
+
const tail = rowCount - end;
|
|
400
|
+
if (overflow === 'split' && tail > 0 && tail < MIN_TAIL_ROWS) {
|
|
401
|
+
let e = rowCount - MIN_TAIL_ROWS;
|
|
402
|
+
while (e > floor && (!(metrics.breakableAfter[e - 1] ?? true) || metrics.groupHeaderRow[e - 1]))
|
|
403
|
+
e--;
|
|
404
|
+
if (e >= floor && e >= end - MIN_TAIL_ROWS)
|
|
405
|
+
end = e;
|
|
406
|
+
}
|
|
407
|
+
const fits = (slice) => {
|
|
408
|
+
const measure = measureFloat(f.resourceId, width, slice);
|
|
409
|
+
if (!measure)
|
|
410
|
+
return true;
|
|
411
|
+
const { need } = measureFloatBand(position, measure, targetCols, contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms));
|
|
412
|
+
return need <= avail + 0.01;
|
|
413
|
+
};
|
|
414
|
+
const sliceFor = (e) => ({
|
|
415
|
+
startRow,
|
|
416
|
+
endRow: e,
|
|
417
|
+
continues: e < rowCount && overflow === 'split',
|
|
418
|
+
});
|
|
419
|
+
let slice = sliceFor(end);
|
|
420
|
+
// The caption may wrap differently with its suffix, the closing slice
|
|
421
|
+
// carries the note instead of the marker: verify, backing off a row at
|
|
422
|
+
// a time (over breakable edges) when the band still overflows.
|
|
423
|
+
for (let guard = 0; guard < 8 && end > floor && !fits(slice); guard++) {
|
|
424
|
+
do
|
|
425
|
+
end--;
|
|
426
|
+
while (end > floor && !(metrics.breakableAfter[end - 1] ?? true));
|
|
427
|
+
slice = sliceFor(end);
|
|
428
|
+
}
|
|
429
|
+
const rest = slice.continues ? { ...f, startRow: end } : undefined;
|
|
430
|
+
return rest ? { slice, rest } : { slice };
|
|
431
|
+
};
|
|
317
432
|
/**
|
|
318
433
|
* Reserve a float band on `targetCols` (one column, or every text column
|
|
319
434
|
* of the band for a page-span float) and position the float there.
|
|
320
435
|
* `'fresh'` is the freshly-opened-page rule: keep three lines of text room
|
|
321
436
|
* once a band already holds a float, but force-place a dominating float
|
|
322
|
-
* on an all-text band so the queue always progresses
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
437
|
+
* on an all-text band so the queue always progresses — a table is cut to
|
|
438
|
+
* the band instead and continues on the next page (see
|
|
439
|
+
* {@link splitTableFloat}). `'strict'` is the current-page rule: the band
|
|
440
|
+
* must fit in each column's remaining height (below its content), keeping
|
|
441
|
+
* the text room only next to another band. Only mutates page geometry
|
|
442
|
+
* when it places.
|
|
326
443
|
*/
|
|
327
|
-
|
|
444
|
+
/** The band a float would take in a slot — its height (`need`) and the
|
|
445
|
+
* float's `y` — without reserving it. `null` when the float cannot be
|
|
446
|
+
* measured. */
|
|
447
|
+
const probeFloatBand = (page, f, targetCols, position, pageSpan, anchorToCap) => {
|
|
328
448
|
const first = targetCols[0];
|
|
329
449
|
const width = pageSpan ? page.contentArea.width : first.bbox.width;
|
|
330
450
|
const xLeft = pageSpan ? page.contentArea.x : first.bbox.x;
|
|
331
|
-
const
|
|
451
|
+
const slice = sliceOf(f);
|
|
452
|
+
const measure = measureFloat(f.resourceId, width, slice);
|
|
332
453
|
if (!measure)
|
|
454
|
+
return null;
|
|
455
|
+
// A bottom band normally anchors to the column's true foot (under a
|
|
456
|
+
// trailing cap, the page bottom — the closing-page figure). Before a
|
|
457
|
+
// page-span box the cap IS the band's foot: the figure hugs the text
|
|
458
|
+
// and the box follows both.
|
|
459
|
+
const bottomOf = (c) => anchorToCap ? c.bbox.y + c.bbox.height : trueBottom(c, uncappedBottoms);
|
|
460
|
+
const { need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, bottomOf);
|
|
461
|
+
return { need, y, measure, slice, width, xLeft };
|
|
462
|
+
};
|
|
463
|
+
/** Free height `col` keeps for the flow once a float band `probe` is
|
|
464
|
+
* reserved in it at `position` (mirrors the reservation arithmetic of
|
|
465
|
+
* `placeFloatInColumns`; a capped column absorbs a bottom band in its
|
|
466
|
+
* remembered true bottom, so its free height does not change). */
|
|
467
|
+
const availableAfterBand = (col, position, probe, anchorToCap) => {
|
|
468
|
+
if (position === 'top')
|
|
469
|
+
return Math.max(0, col.availableHeight - probe.need);
|
|
470
|
+
if (uncappedBottoms.has(col) && !anchorToCap)
|
|
471
|
+
return col.availableHeight;
|
|
472
|
+
const newHeight = Math.max(0, probe.y - floatGapPx - col.bbox.y);
|
|
473
|
+
return Math.max(0, col.availableHeight - (col.bbox.height - newHeight));
|
|
474
|
+
};
|
|
475
|
+
const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode, anchorToCap = false) => {
|
|
476
|
+
const first = targetCols[0];
|
|
477
|
+
const probe = probeFloatBand(page, f, targetCols, position, pageSpan, anchorToCap);
|
|
478
|
+
if (!probe)
|
|
333
479
|
return 'skip';
|
|
334
|
-
const {
|
|
480
|
+
const { width, xLeft } = probe;
|
|
481
|
+
let { slice, measure, need, y } = probe;
|
|
482
|
+
let rest;
|
|
335
483
|
if (mode === 'fresh') {
|
|
336
484
|
let minAvail = Infinity;
|
|
337
485
|
let anyReserved = false;
|
|
@@ -343,10 +491,25 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
343
491
|
}
|
|
344
492
|
if (need > minAvail - minTextPx && anyReserved)
|
|
345
493
|
return 'defer';
|
|
494
|
+
if (need > minAvail + 0.01) {
|
|
495
|
+
// Dominating the band: a table is cut to it.
|
|
496
|
+
const split = splitTableFloat(f, width, position, targetCols, page.contentArea, minAvail);
|
|
497
|
+
if (split === 'skip')
|
|
498
|
+
return 'skip';
|
|
499
|
+
if (split) {
|
|
500
|
+
slice = split.slice;
|
|
501
|
+
rest = split.rest;
|
|
502
|
+
const m = measureFloat(f.resourceId, width, slice);
|
|
503
|
+
if (!m)
|
|
504
|
+
return 'skip';
|
|
505
|
+
measure = m;
|
|
506
|
+
({ need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms)));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
346
509
|
}
|
|
347
510
|
else {
|
|
348
511
|
for (const c of targetCols) {
|
|
349
|
-
if (position === 'bottom' && uncappedBottoms.has(c)) {
|
|
512
|
+
if (position === 'bottom' && uncappedBottoms.has(c) && !anchorToCap) {
|
|
350
513
|
// Trailing cap: the band must lie entirely below the level cut.
|
|
351
514
|
if (y - floatGapPx < c.bbox.y + c.bbox.height - 0.01)
|
|
352
515
|
return 'defer';
|
|
@@ -356,7 +519,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
356
519
|
return 'defer';
|
|
357
520
|
}
|
|
358
521
|
}
|
|
359
|
-
const built = buildFloatBlock(f.resourceId, xLeft, width);
|
|
522
|
+
const built = buildFloatBlock(f.resourceId, xLeft, width, slice);
|
|
360
523
|
if (!built)
|
|
361
524
|
return 'skip';
|
|
362
525
|
for (const col of targetCols) {
|
|
@@ -366,10 +529,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
366
529
|
col.bbox.height = Math.max(0, col.bbox.height - need);
|
|
367
530
|
col.availableHeight = Math.max(0, col.availableHeight - need);
|
|
368
531
|
r.top += need;
|
|
532
|
+
topFloatRefOf.set(col, Math.max(topFloatRefOf.get(col) ?? -1, f.firstBlockIdx));
|
|
369
533
|
}
|
|
370
534
|
else {
|
|
371
535
|
const capped = uncappedBottoms.get(col);
|
|
372
|
-
if (capped !== undefined) {
|
|
536
|
+
if (capped !== undefined && !anchorToCap) {
|
|
373
537
|
uncappedBottoms.set(col, capped - need);
|
|
374
538
|
}
|
|
375
539
|
else {
|
|
@@ -377,6 +541,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
377
541
|
const reserved = col.bbox.height - newHeight;
|
|
378
542
|
col.bbox.height = newHeight;
|
|
379
543
|
col.availableHeight = Math.max(0, col.availableHeight - reserved);
|
|
544
|
+
// Anchored to a cap: the band is the column's content down to
|
|
545
|
+
// the cut; the true bottom shrinks by it too, so a span box
|
|
546
|
+
// measuring its room never cuts across the figure.
|
|
547
|
+
if (capped !== undefined)
|
|
548
|
+
uncappedBottoms.set(col, capped - need);
|
|
380
549
|
}
|
|
381
550
|
r.bottom += need;
|
|
382
551
|
}
|
|
@@ -387,7 +556,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
387
556
|
built.block.pageIndex = page.index;
|
|
388
557
|
built.block.columnIndex = first.index;
|
|
389
558
|
(page.floats ??= []).push(built.block);
|
|
390
|
-
|
|
559
|
+
floatsPlaced++;
|
|
560
|
+
return rest ? { rest } : 'placed';
|
|
561
|
+
};
|
|
562
|
+
/** Apply a slot outcome to the queue at `i`: drop a placed float, keep a
|
|
563
|
+
* deferred one, swap in the rest of a split table. Returns the index to
|
|
564
|
+
* continue from. */
|
|
565
|
+
const settle = (i, r) => {
|
|
566
|
+
if (r === 'defer')
|
|
567
|
+
return i + 1;
|
|
568
|
+
if (typeof r === 'object') {
|
|
569
|
+
pendingFloats[i] = r.rest;
|
|
570
|
+
return i + 1;
|
|
571
|
+
}
|
|
572
|
+
pendingFloats.splice(i, 1);
|
|
573
|
+
return i;
|
|
391
574
|
};
|
|
392
575
|
const positionsFor = (f) => f.position === 'auto' ? ['top', 'bottom'] : [f.position];
|
|
393
576
|
/** Reserve top/bottom bands on a freshly opened page and position as many
|
|
@@ -429,38 +612,123 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
429
612
|
if (r !== 'defer')
|
|
430
613
|
break;
|
|
431
614
|
}
|
|
432
|
-
|
|
433
|
-
i++;
|
|
434
|
-
else
|
|
435
|
-
pendingFloats.splice(i, 1);
|
|
615
|
+
i = settle(i, r);
|
|
436
616
|
}
|
|
437
617
|
}
|
|
438
618
|
};
|
|
619
|
+
/** The keep-together box content block `idx` opens, when it is one that
|
|
620
|
+
* lays out inline in a column of the current band (`span: 'column'`,
|
|
621
|
+
* placement `here`): its height at a column width, so the floats placed
|
|
622
|
+
* right before it can tell whether a slot would starve it. */
|
|
623
|
+
const keepTogetherBoxAt = (idx) => {
|
|
624
|
+
const b = contentBlocks[idx];
|
|
625
|
+
if (!b || b.type !== 'containerStart' || b.containerName !== 'callout')
|
|
626
|
+
return null;
|
|
627
|
+
const plan = calloutPlan.get(idx);
|
|
628
|
+
const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
|
|
629
|
+
if (!plan || !style || !style.keepTogether)
|
|
630
|
+
return null;
|
|
631
|
+
const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
|
|
632
|
+
if (placement !== 'here')
|
|
633
|
+
return null;
|
|
634
|
+
const page = doc.pages[cursor.pageIndex];
|
|
635
|
+
if (span === 'page' && bandColumns(page, currentBand(page, cursor)).length > 1)
|
|
636
|
+
return null;
|
|
637
|
+
const L = makeCalloutLayouter(idx, plan, style);
|
|
638
|
+
const heights = new Map();
|
|
639
|
+
return {
|
|
640
|
+
heightAt: (width) => {
|
|
641
|
+
const key = Math.round(width * 100);
|
|
642
|
+
let h = heights.get(key);
|
|
643
|
+
if (h === undefined) {
|
|
644
|
+
const r = L.layoutRange(CUT_START, L.end, width, 'probe', false);
|
|
645
|
+
h = r.totalHeight + Math.max(pendingSpacing, r.marginTopPx);
|
|
646
|
+
heights.set(key, h);
|
|
647
|
+
}
|
|
648
|
+
return h;
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
};
|
|
652
|
+
/** Whether reserving float `f` in `slot` would leave the keep-together
|
|
653
|
+
* box that comes next with no column of the current band to land in —
|
|
654
|
+
* when, without the float, one of them (the cursor's, or an empty one
|
|
655
|
+
* after it) still holds it. A float yields to such a box, as a
|
|
656
|
+
* compositor would: the box stays in flow and the figure takes the next
|
|
657
|
+
* slot (usually the next page) instead of pushing the box off the page
|
|
658
|
+
* and leaving the column with the figure alone. */
|
|
659
|
+
const slotStarvesBox = (page, f, slot, box, anchorToCap) => {
|
|
660
|
+
const cursorCol = page.columns[cursor.columnIndex];
|
|
661
|
+
if (!cursorCol)
|
|
662
|
+
return false;
|
|
663
|
+
const candidates = bandColumns(page, currentBand(page, cursor))
|
|
664
|
+
.filter((c) => c.kind !== 'span' && c.index >= cursorCol.index && (c === cursorCol || c.blocks.length === 0));
|
|
665
|
+
const fitsIn = (c, available) => available >= box.heightAt(c.bbox.width) - 0.01;
|
|
666
|
+
if (!candidates.some((c) => fitsIn(c, c.availableHeight)))
|
|
667
|
+
return false;
|
|
668
|
+
const probe = probeFloatBand(page, f, slot.cols, slot.position, slot.pageSpan, anchorToCap);
|
|
669
|
+
if (!probe)
|
|
670
|
+
return false;
|
|
671
|
+
const after = (c) => slot.cols.includes(c) ? availableAfterBand(c, slot.position, probe, anchorToCap) : c.availableHeight;
|
|
672
|
+
return !candidates.some((c) => fitsIn(c, after(c)));
|
|
673
|
+
};
|
|
439
674
|
/** Offer every pending float the free slots of the current page after the
|
|
440
675
|
* cursor (bottom of the referencing column, top / bottom of the next
|
|
441
676
|
* empty columns; the band bottom for page-span floats). Runs before each
|
|
442
|
-
* block is placed, so a float lands in the first gap after its reference.
|
|
443
|
-
|
|
677
|
+
* block is placed, so a float lands in the first gap after its reference.
|
|
678
|
+
* `nextBlockIdx` is that block: a keep-together box it opens holds the
|
|
679
|
+
* slots that would starve it (see `slotStarvesBox`). */
|
|
680
|
+
const tryPlacePendingFloatsOnCurrentPage = (preferTop = false, nextBlockIdx) => {
|
|
444
681
|
if (pendingFloats.length === 0)
|
|
445
682
|
return;
|
|
446
683
|
const page = doc.pages[cursor.pageIndex];
|
|
684
|
+
const box = nextBlockIdx !== undefined ? keepTogetherBoxAt(nextBlockIdx) : null;
|
|
447
685
|
for (let i = 0; i < pendingFloats.length;) {
|
|
448
686
|
const f = pendingFloats[i];
|
|
449
687
|
let r = 'defer';
|
|
450
|
-
|
|
451
|
-
|
|
688
|
+
let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf);
|
|
689
|
+
// A page-span box comes next: the head of an empty column keeps the
|
|
690
|
+
// band cuttable under the float (the box then sits below both the
|
|
691
|
+
// text and the figure), where the referencing column's foot would
|
|
692
|
+
// wall the box off the page. A page-span figure waits for the box
|
|
693
|
+
// itself, which cuts the band under the text and sets the figure
|
|
694
|
+
// there, above the box (`placeSpanFloatsAtCut`) — its only slot here
|
|
695
|
+
// would be the band's foot, under the box.
|
|
696
|
+
if (preferTop && f.span === 'page' && bandColumns(page, currentBand(page, cursor)).length > 1) {
|
|
697
|
+
i++;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (preferTop)
|
|
701
|
+
slots = [...slots.filter((s) => s.position === 'top'), ...slots.filter((s) => s.position !== 'top')];
|
|
702
|
+
for (const slot of slots) {
|
|
703
|
+
if (box && slotStarvesBox(page, f, slot, box, preferTop))
|
|
704
|
+
continue;
|
|
705
|
+
r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict', preferTop);
|
|
452
706
|
if (r !== 'defer')
|
|
453
707
|
break;
|
|
454
708
|
}
|
|
455
|
-
|
|
456
|
-
i++;
|
|
457
|
-
else
|
|
458
|
-
pendingFloats.splice(i, 1);
|
|
709
|
+
i = settle(i, r);
|
|
459
710
|
}
|
|
460
711
|
};
|
|
461
712
|
/** Reserve floats on each freshly opened content page. Passed only to the
|
|
462
713
|
* content-flow column advances — parity / force-blank pages never get it. */
|
|
463
714
|
const onNewPage = (page) => flushFloatsIntoPage(page);
|
|
715
|
+
/** Whether content block `idx` opens a callout that will span the page
|
|
716
|
+
* in the current (multi-column) band — the floats placed right before
|
|
717
|
+
* it prefer the head of an empty column. */
|
|
718
|
+
const spanBoxAt = (idx) => {
|
|
719
|
+
const b = contentBlocks[idx];
|
|
720
|
+
if (!b || b.type !== 'containerStart' || b.containerName !== 'callout')
|
|
721
|
+
return false;
|
|
722
|
+
const plan = calloutPlan.get(idx);
|
|
723
|
+
const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
|
|
724
|
+
if (!style)
|
|
725
|
+
return false;
|
|
726
|
+
const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
|
|
727
|
+
if (span !== 'page' || placement === 'fixed')
|
|
728
|
+
return false;
|
|
729
|
+
const page = doc.pages[cursor.pageIndex];
|
|
730
|
+
return bandColumns(page, currentBand(page, cursor)).length > 1;
|
|
731
|
+
};
|
|
464
732
|
/** Chapter barrier: place every pending float before the boundary — in
|
|
465
733
|
* the current page's free slots, then on fresh pages opened ahead of it
|
|
466
734
|
* (each force-places at least one float). The cursor is left on the last
|
|
@@ -469,12 +737,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
469
737
|
tryPlacePendingFloatsOnCurrentPage();
|
|
470
738
|
let guard = 0;
|
|
471
739
|
while (pendingFloats.length > 0 && guard++ < 1000) {
|
|
472
|
-
const before =
|
|
740
|
+
const before = floatsPlaced;
|
|
473
741
|
const startPageIndex = cursor.pageIndex;
|
|
474
742
|
do {
|
|
475
743
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
476
744
|
} while (cursor.pageIndex === startPageIndex);
|
|
477
|
-
if (
|
|
745
|
+
if (floatsPlaced === before)
|
|
478
746
|
break; // safety: no progress
|
|
479
747
|
}
|
|
480
748
|
};
|
|
@@ -531,6 +799,19 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
531
799
|
let activeCap = null;
|
|
532
800
|
/** True bottoms of capped columns (restored when the span block cuts). */
|
|
533
801
|
const uncappedBottoms = new Map();
|
|
802
|
+
/** Column balancing: height (px) the levers added inside each column so
|
|
803
|
+
* far — extra grid lines above headings / list ends and lines gained by
|
|
804
|
+
* loose paragraphs. Balancing only ever fills a column's bottom gap, so
|
|
805
|
+
* a placement rule that needs slack *after* a block (keep-colon-with-
|
|
806
|
+
* list) must see the column as it was before the levers filled it:
|
|
807
|
+
* otherwise the block that closed the column in the plain pass moves to
|
|
808
|
+
* the next column, the flow shifts on every later page, and the pass is
|
|
809
|
+
* discarded as a regression. */
|
|
810
|
+
const balanceExtraInColumn = new Map();
|
|
811
|
+
const addBalanceExtra = (col, px) => {
|
|
812
|
+
if (px > 0)
|
|
813
|
+
balanceExtraInColumn.set(col, (balanceExtraInColumn.get(col) ?? 0) + px);
|
|
814
|
+
};
|
|
534
815
|
const bandCapProposals = new Map();
|
|
535
816
|
const spanPlacedInBand = new Set();
|
|
536
817
|
const bandCapsApplied = new Set();
|
|
@@ -599,11 +880,22 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
599
880
|
const aroundZone = zone ? bandCapLinesAroundZone(cols, baselineGrid, zone, (c) => columnBottom(c, uncappedBottoms)) : null;
|
|
600
881
|
if (aroundZone === null && Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5)
|
|
601
882
|
return;
|
|
883
|
+
// Float bands reserved at the columns' feet are the band's content too:
|
|
884
|
+
// the level cut must leave them room under the text (a figure placed
|
|
885
|
+
// before a span box then anchors to the cut, see `anchorToCap`).
|
|
886
|
+
const footBands = cols.reduce((sum, c) => sum + reservedOf(c).bottom, 0);
|
|
887
|
+
// A figure heading an otherwise empty column keeps its slot: the cut
|
|
888
|
+
// goes no higher than the band it takes, or the capped pass evicts it
|
|
889
|
+
// to a page of its own.
|
|
890
|
+
const top = bandTop(cols);
|
|
891
|
+
const floatHeads = cols
|
|
892
|
+
.filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0)
|
|
893
|
+
.map((c) => Math.ceil((c.bbox.y - top - 0.01) / baselineGrid));
|
|
602
894
|
bandCapProposals.set(boundaryIndex, {
|
|
603
895
|
kind: 'trailing',
|
|
604
896
|
startContentIndex: bandStart.contentIndex,
|
|
605
897
|
startPart: bandStart.part,
|
|
606
|
-
lines: aroundZone ?? bandCapLines(cols, baselineGrid),
|
|
898
|
+
lines: aroundZone ?? Math.max(bandCapLines(cols, baselineGrid, footBands), ...floatHeads),
|
|
607
899
|
retries: 0,
|
|
608
900
|
...(aroundZone !== null ? { zone } : {}),
|
|
609
901
|
});
|
|
@@ -696,6 +988,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
696
988
|
doc.blocks.push(child);
|
|
697
989
|
}
|
|
698
990
|
};
|
|
991
|
+
const CUT_START = { child: 0, line: 0 };
|
|
699
992
|
const makeCalloutLayouter = (startIdx, plan, style) => {
|
|
700
993
|
const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
|
|
701
994
|
const realAt = [];
|
|
@@ -705,12 +998,15 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
705
998
|
});
|
|
706
999
|
const layoutRange = (from, to, width, frameId, continuation) => {
|
|
707
1000
|
let n = 0;
|
|
1001
|
+
// A cut inside child `to.child` includes that child (its first
|
|
1002
|
+
// `to.line` lines); a cut at a child's head excludes it.
|
|
1003
|
+
const toChild = to.line > 0 ? to.child + 1 : to.child;
|
|
708
1004
|
return layoutCallout({
|
|
709
1005
|
style,
|
|
710
1006
|
attrs: plan.attrs,
|
|
711
1007
|
continuation,
|
|
712
|
-
children: children.slice(from,
|
|
713
|
-
childStartIdx: startIdx + 1 + from,
|
|
1008
|
+
children: children.slice(from.child, toChild),
|
|
1009
|
+
childStartIdx: startIdx + 1 + from.child,
|
|
714
1010
|
width,
|
|
715
1011
|
ctx: measureCtx,
|
|
716
1012
|
resolved,
|
|
@@ -718,49 +1014,64 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
718
1014
|
frameId,
|
|
719
1015
|
nextChildId: () => `${frameId}-c${n++}`,
|
|
720
1016
|
paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
|
|
1017
|
+
...(from.line > 0 ? { lineFrom: from.line } : {}),
|
|
1018
|
+
...(to.line > 0 ? { lineTo: to.line } : {}),
|
|
721
1019
|
});
|
|
722
1020
|
};
|
|
723
|
-
return { children, childBase: startIdx + 1, realAt, layoutRange };
|
|
1021
|
+
return { children, childBase: startIdx + 1, realAt, end: { child: children.length, line: 0 }, layoutRange };
|
|
724
1022
|
};
|
|
725
|
-
/** The longest leading fragment of the
|
|
726
|
-
* is at most `roomPx` tall
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const full = L.layoutRange(from, L.children.length, width, frameId, continuation);
|
|
1023
|
+
/** The longest leading fragment of the box from cut `from` on whose box
|
|
1024
|
+
* is at most `roomPx` tall. Cuts fall between children or between the
|
|
1025
|
+
* lines of a text child, and every fragment keeps at least the style's
|
|
1026
|
+
* `splitMinLines` lines on its side of the cut (a figure or a display
|
|
1027
|
+
* formula counts as one line). The full layout's geometry ranks the
|
|
1028
|
+
* candidates (box bottom = content bottom at the cut + the box's tail
|
|
1029
|
+
* below its last child); the deepest candidate that fits is laid out
|
|
1030
|
+
* for real and taken when it truly fits. `null` when none does. */
|
|
1031
|
+
const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation, minLines) => {
|
|
1032
|
+
const full = L.layoutRange(from, L.end, width, frameId, continuation);
|
|
736
1033
|
const lastChild = full.children[full.children.length - 1];
|
|
737
1034
|
if (!lastChild)
|
|
738
1035
|
return null;
|
|
739
1036
|
const tail = full.totalHeight - (lastChild.bbox.y + lastChild.bbox.height);
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1037
|
+
const totalLines = full.children.reduce((n, c) => n + Math.max(1, c.lines.length), 0);
|
|
1038
|
+
/** Candidate cuts with the head's content bottom and line count. */
|
|
1039
|
+
const candidates = [];
|
|
1040
|
+
let linesBefore = 0;
|
|
1041
|
+
for (let i = 0; i < full.children.length; i++) {
|
|
1042
|
+
const c = full.children[i];
|
|
1043
|
+
const k = (c.contentIndex ?? L.childBase) - L.childBase;
|
|
1044
|
+
const cuttable = c.type !== 'resource' && c.type !== 'mathDisplay' && c.lines.length > 1;
|
|
1045
|
+
// The first laid-out child of a continuation opens after `from.line`
|
|
1046
|
+
// lines: cuts inside it are counted from the child's own head.
|
|
1047
|
+
const lineBase = k === from.child ? from.line : 0;
|
|
1048
|
+
if (cuttable) {
|
|
1049
|
+
for (let l = 1; l < c.lines.length; l++) {
|
|
1050
|
+
const line = c.lines[l - 1];
|
|
1051
|
+
candidates.push({ cut: { child: k, line: lineBase + l }, bottom: line.bbox.y + line.bbox.height, headLines: linesBefore + l });
|
|
746
1052
|
}
|
|
747
1053
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1054
|
+
linesBefore += Math.max(1, c.lines.length);
|
|
1055
|
+
if (i < full.children.length - 1) {
|
|
1056
|
+
candidates.push({ cut: { child: k + 1, line: 0 }, bottom: c.bbox.y + c.bbox.height, headLines: linesBefore });
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const min = Math.max(1, minLines);
|
|
1060
|
+
const viable = candidates
|
|
1061
|
+
.filter((c) => c.headLines >= min && totalLines - c.headLines >= min)
|
|
1062
|
+
.sort((a, b) => b.bottom - a.bottom);
|
|
1063
|
+
for (const c of viable) {
|
|
1064
|
+
if (c.bottom + tail > roomPx + 0.01)
|
|
1065
|
+
continue;
|
|
1066
|
+
const result = L.layoutRange(from, c.cut, width, frameId, continuation);
|
|
756
1067
|
if (result.totalHeight <= roomPx + 0.01)
|
|
757
|
-
return { to, result };
|
|
1068
|
+
return { to: c.cut, result };
|
|
758
1069
|
}
|
|
759
1070
|
return null;
|
|
760
1071
|
};
|
|
761
|
-
/** Absolute content indices of the children
|
|
762
|
-
* the fragment's source range. */
|
|
763
|
-
const fragmentRange = (L, from, to) => ({ firstChildIdx: L.childBase + from, lastChildIdx: L.childBase + to - 1 });
|
|
1072
|
+
/** Absolute content indices of the children a fragment from cut `from`
|
|
1073
|
+
* to cut `to` of `L` touches, for the fragment's source range. */
|
|
1074
|
+
const fragmentRange = (L, from, to) => ({ firstChildIdx: L.childBase + from.child, lastChildIdx: L.childBase + (to.line > 0 ? to.child : to.child - 1) });
|
|
764
1075
|
const markFragment = (result, part, continued) => {
|
|
765
1076
|
if (result.frame.callout) {
|
|
766
1077
|
result.frame.callout.part = part;
|
|
@@ -835,9 +1146,92 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
835
1146
|
const bottoms = cols.map((c) => c.bbox.y + (c.bbox.height - c.availableHeight));
|
|
836
1147
|
return Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5;
|
|
837
1148
|
};
|
|
838
|
-
/**
|
|
839
|
-
*
|
|
840
|
-
|
|
1149
|
+
/** Level for the box: the text columns end level, and a column holding
|
|
1150
|
+
* only a float band (a figure at its head, no text yet) counts as level
|
|
1151
|
+
* when a band cap could not level it either — the figure was first
|
|
1152
|
+
* referenced by the very block before the box, so a cut that spills
|
|
1153
|
+
* that block into the figure's column leaves the figure no slot after
|
|
1154
|
+
* its reference (it would fall off the page, and the box with it). The
|
|
1155
|
+
* box then cuts under the text and the figure alike; a figure
|
|
1156
|
+
* referenced earlier keeps the cap route, which flows text under it. */
|
|
1157
|
+
const lastBlockBefore = () => {
|
|
1158
|
+
let j = startIdx - 1;
|
|
1159
|
+
while (j >= 0 && isMarkerBlock(contentBlocks[j]))
|
|
1160
|
+
j--;
|
|
1161
|
+
return j;
|
|
1162
|
+
};
|
|
1163
|
+
const levelForBox = (cols) => {
|
|
1164
|
+
if (isBandLevel(cols))
|
|
1165
|
+
return true;
|
|
1166
|
+
const textCols = cols.filter((c) => c.blocks.length > 0);
|
|
1167
|
+
const floatOnly = cols.filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0);
|
|
1168
|
+
if (textCols.length === 0 || textCols.length + floatOnly.length !== cols.length)
|
|
1169
|
+
return false;
|
|
1170
|
+
if (!isBandLevel(textCols))
|
|
1171
|
+
return false;
|
|
1172
|
+
const before = lastBlockBefore();
|
|
1173
|
+
// However tall the figure is: the text before the box is all placed,
|
|
1174
|
+
// so no cut could level the columns any better — the box goes under
|
|
1175
|
+
// both, the slack under the text being the compositor's trade.
|
|
1176
|
+
return floatOnly.every((c) => topFloatRefOf.get(c) === before);
|
|
1177
|
+
};
|
|
1178
|
+
/**
|
|
1179
|
+
* A page-span figure referenced before the box takes the cut first: the
|
|
1180
|
+
* band is closed level under the text, the figure spans the page right
|
|
1181
|
+
* there (where the text ended, as the compositor reads it), and the box
|
|
1182
|
+
* goes on below — or to the next page when it no longer fits. Only when
|
|
1183
|
+
* the band can be cut for the box (level, or capped for it) and the
|
|
1184
|
+
* figure fits between the cut and the band bottom; otherwise the figure
|
|
1185
|
+
* stays pending for the ordinary slots. Returns whether any was set.
|
|
1186
|
+
*/
|
|
1187
|
+
const placeSpanFloatsAtCut = (page, capActiveHere) => {
|
|
1188
|
+
let placedAny = false;
|
|
1189
|
+
for (let i = 0; i < pendingFloats.length;) {
|
|
1190
|
+
const f = pendingFloats[i];
|
|
1191
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
1192
|
+
if (f.span !== 'page' || cols.length < 2 || !((capActiveHere && !placedAny) || levelForBox(cols))) {
|
|
1193
|
+
i++;
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
const width = page.contentArea.width;
|
|
1197
|
+
const slice = sliceOf(f);
|
|
1198
|
+
const measure = measureFloat(f.resourceId, width, slice);
|
|
1199
|
+
if (!measure) {
|
|
1200
|
+
i++;
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
const cutY = gridUp(page, bandUsedBottom(cols));
|
|
1204
|
+
const spacing = cols.some((c) => c.blocks.length > 0) ? floatGapPx : 0;
|
|
1205
|
+
const need = needFor(spacing, measure.height, floatGapPx);
|
|
1206
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
1207
|
+
if (cutY + need > bandBottom + 0.01) {
|
|
1208
|
+
i++;
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
const built = buildFloatBlock(f.resourceId, page.contentArea.x, width, slice);
|
|
1212
|
+
if (!built) {
|
|
1213
|
+
i++;
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (capActiveHere && !placedAny) {
|
|
1217
|
+
uncapBand(cols, uncappedBottoms);
|
|
1218
|
+
spanPlacedInBand.add(startIdx);
|
|
1219
|
+
}
|
|
1220
|
+
closeBandAndInsertSpan(page, cols, cutY, built.block, need, cursor, spacing, built.height);
|
|
1221
|
+
// The float was built at (x, 0): move its inner geometry down to
|
|
1222
|
+
// where the span column put it.
|
|
1223
|
+
offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, built.block.bbox.y);
|
|
1224
|
+
built.block.contentIndex = f.firstBlockIdx;
|
|
1225
|
+
doc.blocks.push(built.block);
|
|
1226
|
+
floatsPlaced++;
|
|
1227
|
+
placedAny = true;
|
|
1228
|
+
pendingFloats.splice(i, 1);
|
|
1229
|
+
}
|
|
1230
|
+
return placedAny;
|
|
1231
|
+
};
|
|
1232
|
+
/** Cut the fragment to place starts at, and its 0-based index among
|
|
1233
|
+
* the fragments (0 = the box, or its head). */
|
|
1234
|
+
let from = CUT_START;
|
|
841
1235
|
let part = 0;
|
|
842
1236
|
let frameId = firstFrameId;
|
|
843
1237
|
/** The box already moved to a fresh page (or sits on an empty one):
|
|
@@ -846,7 +1240,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
846
1240
|
for (;;) {
|
|
847
1241
|
let page = doc.pages[cursor.pageIndex];
|
|
848
1242
|
const continuation = part > 0;
|
|
849
|
-
const layoutAt = (width) => L.layoutRange(from, L.
|
|
1243
|
+
const layoutAt = (width) => L.layoutRange(from, L.end, width, frameId, continuation);
|
|
850
1244
|
const result = layoutAt(page.contentArea.width);
|
|
851
1245
|
/** Where the box would cut the current band, and whether it fits (room
|
|
852
1246
|
* is measured against the columns' TRUE bottoms — a capped band keeps
|
|
@@ -855,7 +1249,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
855
1249
|
* `requireLevel` is set. */
|
|
856
1250
|
const measureBand = (requireLevel) => {
|
|
857
1251
|
const cols = bandColumns(page, currentBand(page, cursor));
|
|
858
|
-
if (cols.length === 0 || (requireLevel && !
|
|
1252
|
+
if (cols.length === 0 || (requireLevel && !levelForBox(cols)))
|
|
859
1253
|
return null;
|
|
860
1254
|
const cutY = gridUp(page, bandUsedBottom(cols));
|
|
861
1255
|
const bandHasContent = cols.some((c) => c.blocks.length > 0);
|
|
@@ -870,11 +1264,70 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
870
1264
|
// Is this band capped for this very box? Then it cuts at the band's
|
|
871
1265
|
// used bottom (at most the cap) even when the last column is short.
|
|
872
1266
|
const cap = part === 0 ? bandCaps?.get(startIdx) : undefined;
|
|
873
|
-
|
|
1267
|
+
let capActive = cap !== undefined
|
|
874
1268
|
&& activeCap !== null
|
|
875
1269
|
&& activeCap.spanIndex === startIdx
|
|
876
1270
|
&& activeCap.pageIndex === page.index
|
|
877
1271
|
&& activeCap.band === currentBand(page, cursor);
|
|
1272
|
+
// A page-span figure referenced before the box cuts the band first
|
|
1273
|
+
// and the box measures the fresh band under it. A barrier box then
|
|
1274
|
+
// takes every other pending float — the current page's free slots,
|
|
1275
|
+
// else pages opened ahead — before it lands.
|
|
1276
|
+
if (part === 0) {
|
|
1277
|
+
if (placeSpanFloatsAtCut(page, capActive))
|
|
1278
|
+
capActive = false;
|
|
1279
|
+
if (style.floatBarrier && pendingFloats.length > 0) {
|
|
1280
|
+
const pageBefore = cursor.pageIndex;
|
|
1281
|
+
const bandBefore = currentBand(page, cursor);
|
|
1282
|
+
// An uneven, uncapped band the figures are about to leave behind
|
|
1283
|
+
// gets the cap that levels it: a span cap when a page-span figure
|
|
1284
|
+
// would fit under the level cut — the capped pass sets it there
|
|
1285
|
+
// and the box follows — else a trailing cap, the figure and the
|
|
1286
|
+
// box moving on and the band ending level like a closing one.
|
|
1287
|
+
// Only a page-span figure is planned for here; column figures keep
|
|
1288
|
+
// the ordinary slots (their level is the band cap's own business).
|
|
1289
|
+
const first = pendingFloats.find((f) => f.span === 'page');
|
|
1290
|
+
if (first && !capActive && cap === undefined && bandStart && registeredBand
|
|
1291
|
+
&& registeredBand.pageIndex === page.index && registeredBand.band === bandBefore) {
|
|
1292
|
+
const cols = bandColumns(page, bandBefore).filter((c) => c.bbox.height > 0.5);
|
|
1293
|
+
if (cols.length > 1 && !isBandLevel(cols) && cols.some((c) => c.blocks.length > 0)) {
|
|
1294
|
+
const lines = bandCapLines(cols, baselineGrid);
|
|
1295
|
+
const capBottom = bandTop(cols) + lines * baselineGrid;
|
|
1296
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
1297
|
+
const m = measureFloat(first.resourceId, page.contentArea.width, sliceOf(first));
|
|
1298
|
+
const figureFits = m !== null && capBottom + needFor(floatGapPx, m.height, floatGapPx) <= bandBottom + 0.01;
|
|
1299
|
+
if (figureFits) {
|
|
1300
|
+
bandCapProposals.set(startIdx, {
|
|
1301
|
+
kind: 'span',
|
|
1302
|
+
startContentIndex: bandStart.contentIndex,
|
|
1303
|
+
startPart: bandStart.part,
|
|
1304
|
+
lines,
|
|
1305
|
+
retries: 0,
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
else {
|
|
1309
|
+
proposeTrailingCap(startIdx);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
tryPlacePendingFloatsOnCurrentPage();
|
|
1314
|
+
drainPendingFloats();
|
|
1315
|
+
if (cursor.pageIndex !== pageBefore || currentBand(doc.pages[cursor.pageIndex], cursor) !== bandBefore) {
|
|
1316
|
+
// A figure took a page ahead and the box follows it there. A
|
|
1317
|
+
// band cut level for it (trailing cap) counts as delivered —
|
|
1318
|
+
// the columns stay cut — and balancing never stretches the
|
|
1319
|
+
// page's last column back to the bottom. A span cap that could
|
|
1320
|
+
// not seat the figure is left undelivered for the driver to
|
|
1321
|
+
// grow or drop.
|
|
1322
|
+
if (capActive && cap?.kind === 'trailing')
|
|
1323
|
+
spanPlacedInBand.add(startIdx);
|
|
1324
|
+
if (doc.pages[pageBefore].columns.some((c) => c.blocks.length > 0))
|
|
1325
|
+
forcedBreakPages.add(pageBefore);
|
|
1326
|
+
}
|
|
1327
|
+
capActive = false;
|
|
1328
|
+
}
|
|
1329
|
+
page = doc.pages[cursor.pageIndex];
|
|
1330
|
+
}
|
|
878
1331
|
const fit = forceHere ? (measureBand(true) ?? measureBand(false)) : measureBand(!capActive);
|
|
879
1332
|
let action = null;
|
|
880
1333
|
if (fit) {
|
|
@@ -894,21 +1347,30 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
894
1347
|
band = measureBand(false);
|
|
895
1348
|
}
|
|
896
1349
|
if (band) {
|
|
897
|
-
const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation);
|
|
1350
|
+
const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation, style.splitMinLines);
|
|
898
1351
|
if (fragment) {
|
|
899
1352
|
action = { kind: 'split', fit: band, need: needFor(band.spacing, fragment.result.totalHeight, 0), fragment };
|
|
900
1353
|
}
|
|
901
1354
|
}
|
|
902
1355
|
}
|
|
903
|
-
if (!action && forceHere && fit)
|
|
1356
|
+
if (!action && forceHere && fit) {
|
|
904
1357
|
action = { kind: 'whole', fit, need: fit.need, result };
|
|
1358
|
+
(doc.warnings ??= []).push({
|
|
1359
|
+
kind: 'calloutOverflow',
|
|
1360
|
+
pageIndex: cursor.pageIndex,
|
|
1361
|
+
columnIndex: cursor.columnIndex,
|
|
1362
|
+
sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
|
|
1363
|
+
sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
|
|
1364
|
+
overflowPx: Math.max(0, fit.spacing + result.totalHeight - fit.roomPx),
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
905
1367
|
if (action) {
|
|
906
1368
|
if (capActive) {
|
|
907
1369
|
uncapBand(action.fit.cols, uncappedBottoms);
|
|
908
1370
|
spanPlacedInBand.add(startIdx);
|
|
909
1371
|
}
|
|
910
1372
|
const placed = action.kind === 'whole' ? action.result : action.fragment.result;
|
|
911
|
-
const to = action.kind === 'whole' ? L.
|
|
1373
|
+
const to = action.kind === 'whole' ? L.end : action.fragment.to;
|
|
912
1374
|
if (part > 0 || action.kind === 'split')
|
|
913
1375
|
markFragment(placed, part, action.kind === 'split');
|
|
914
1376
|
const spanCol = closeBandAndInsertSpan(page, action.fit.cols, action.fit.cutY, placed.frame, action.need, cursor, action.fit.spacing, placed.totalHeight);
|
|
@@ -945,13 +1407,23 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
945
1407
|
// (or, for a splittable box, for the whole box flush with the
|
|
946
1408
|
// band bottom).
|
|
947
1409
|
const cols = bandColumns(page, currentBand(page, cursor));
|
|
948
|
-
|
|
949
|
-
|
|
1410
|
+
// A column holding only a figure at its head must keep that slot
|
|
1411
|
+
// in the capped pass: the cut can go no higher than the band the
|
|
1412
|
+
// figure takes, or the figure falls off the page and the box after it.
|
|
1413
|
+
const top = bandTop(cols);
|
|
1414
|
+
const floatHeads = cols
|
|
1415
|
+
.filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0)
|
|
1416
|
+
.map((c) => Math.ceil((c.bbox.y - top - 0.01) / baselineGrid));
|
|
1417
|
+
const lines = Math.max(bandCapLines(cols, baselineGrid), ...floatHeads);
|
|
1418
|
+
const capBottom = top + lines * baselineGrid;
|
|
950
1419
|
const spacing = Math.max(pendingSpacing, result.marginTopPx);
|
|
951
1420
|
const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
|
|
952
1421
|
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
1422
|
+
// A splittable box is worth the cut when its head fits flush with
|
|
1423
|
+
// the band bottom, the rest going on to the next page.
|
|
953
1424
|
const fitsAfterCut = capBottom + need + minRoomPx <= bandBottom + 0.01
|
|
954
|
-
|| (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01)
|
|
1425
|
+
|| (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01)
|
|
1426
|
+
|| (splittable && splitCalloutFragment(L, from, page.contentArea.width, bandBottom - capBottom - spacing, frameId, continuation, style.splitMinLines) !== null);
|
|
955
1427
|
if (fitsAfterCut) {
|
|
956
1428
|
bandCapProposals.set(startIdx, {
|
|
957
1429
|
kind: 'span',
|
|
@@ -970,7 +1442,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
970
1442
|
proposeTrailingCap(startIdx);
|
|
971
1443
|
markForcedBreak();
|
|
972
1444
|
}
|
|
973
|
-
else if (capActive && cap
|
|
1445
|
+
else if (capActive && cap?.kind === 'trailing') {
|
|
974
1446
|
// Reached inside the band cut level for it: delivered even though
|
|
975
1447
|
// the box moves on — the columns stay cut.
|
|
976
1448
|
spanPlacedInBand.add(startIdx);
|
|
@@ -1195,7 +1667,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1195
1667
|
const firstFrameId = `block-${blockIdCounter++}`;
|
|
1196
1668
|
const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
|
|
1197
1669
|
const L = makeCalloutLayouter(startIdx, plan, style);
|
|
1198
|
-
const layoutAt = (width) => L.layoutRange(
|
|
1670
|
+
const layoutAt = (width) => L.layoutRange(CUT_START, L.end, width, firstFrameId, false);
|
|
1199
1671
|
// Fixed boxes leave the flow entirely.
|
|
1200
1672
|
if (placement === 'fixed') {
|
|
1201
1673
|
placeCalloutFixed(startIdx, plan, style, layoutAt);
|
|
@@ -1213,22 +1685,42 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1213
1685
|
}
|
|
1214
1686
|
}
|
|
1215
1687
|
const splittable = !style.keepTogether;
|
|
1216
|
-
let from =
|
|
1688
|
+
let from = CUT_START;
|
|
1217
1689
|
let part = 0;
|
|
1218
1690
|
let frameId = firstFrameId;
|
|
1691
|
+
/** Times the box left an EMPTY short column (see below) — bounded. */
|
|
1692
|
+
let shortColumnMoves = 0;
|
|
1219
1693
|
for (;;) {
|
|
1220
1694
|
let curCol = currentColumn(doc, cursor);
|
|
1221
1695
|
const continuation = part > 0;
|
|
1222
|
-
const result = L.layoutRange(from, L.
|
|
1223
|
-
|
|
1696
|
+
const result = L.layoutRange(from, L.end, curCol.bbox.width, frameId, continuation);
|
|
1697
|
+
// Column balancing: a box closing its column takes the column's gap
|
|
1698
|
+
// above it (the trailing-callout lever), so its foot lands on the
|
|
1699
|
+
// last grid slot — level with the column beside it. Any fragment
|
|
1700
|
+
// qualifies, as long as something sits above it to push down from:
|
|
1701
|
+
// text, or the float band at the head of an otherwise empty column.
|
|
1702
|
+
const balanceBefore = curCol.blocks.length > 0 || reservedOf(curCol).top > 0
|
|
1703
|
+
? (balanceExtraPx?.get(balanceKey(startIdx, part)) ?? 0)
|
|
1704
|
+
: 0;
|
|
1705
|
+
const spacing = (curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx)) + balanceBefore;
|
|
1224
1706
|
const roomPx = curCol.availableHeight - spacing;
|
|
1225
1707
|
let fragment = null;
|
|
1226
1708
|
if (result.totalHeight > roomPx + 0.01) {
|
|
1227
1709
|
// The (rest of the) box does not fit the column: a splittable box
|
|
1228
1710
|
// leaves the head that fits here…
|
|
1229
1711
|
if (splittable)
|
|
1230
|
-
fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation);
|
|
1231
|
-
|
|
1712
|
+
fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation, style.splitMinLines);
|
|
1713
|
+
// …otherwise it moves whole to the next column — also out of an
|
|
1714
|
+
// EMPTY column that float bands or a band cap have cut short, when
|
|
1715
|
+
// a full column would hold it (bounded, so a run of short columns
|
|
1716
|
+
// cannot make it wander forever). Only a box taller than a full
|
|
1717
|
+
// column is placed anyway, overflowing (a layout warning says so).
|
|
1718
|
+
const shortColumn = shortColumnMoves < 4
|
|
1719
|
+
&& curCol.bbox.height < contentArea.height - baselineGrid
|
|
1720
|
+
&& result.totalHeight <= contentArea.height + 0.01;
|
|
1721
|
+
if (!fragment && (curCol.blocks.length > 0 || shortColumn)) {
|
|
1722
|
+
if (curCol.blocks.length === 0)
|
|
1723
|
+
shortColumnMoves++;
|
|
1232
1724
|
// …otherwise it moves whole to the next column. Keep-with-next: a
|
|
1233
1725
|
// run of headings at the column's tail travels with the box.
|
|
1234
1726
|
// Skipped when the column holds nothing else (rolling back again
|
|
@@ -1249,13 +1741,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1249
1741
|
return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
|
|
1250
1742
|
}
|
|
1251
1743
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
1252
|
-
|
|
1253
|
-
//
|
|
1254
|
-
|
|
1255
|
-
continue;
|
|
1256
|
-
}
|
|
1744
|
+
// Try the next column afresh: it may be short too (a float band
|
|
1745
|
+
// reserved on the page it opened), or of another width (oneAndHalf).
|
|
1746
|
+
continue;
|
|
1257
1747
|
}
|
|
1258
1748
|
// An empty column that is still too short: placed anyway, overflowing.
|
|
1749
|
+
if (!fragment && curCol.blocks.length === 0 && result.totalHeight > curCol.availableHeight - spacing + 0.01) {
|
|
1750
|
+
(doc.warnings ??= []).push({
|
|
1751
|
+
kind: 'calloutOverflow',
|
|
1752
|
+
pageIndex: cursor.pageIndex,
|
|
1753
|
+
columnIndex: cursor.columnIndex,
|
|
1754
|
+
sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
|
|
1755
|
+
sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
|
|
1756
|
+
overflowPx: result.totalHeight + spacing - curCol.availableHeight,
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1259
1759
|
}
|
|
1260
1760
|
// Floats first-referenced inside the box still enqueue in reading order
|
|
1261
1761
|
// (only once the box is committed, so a keep-with-next replay does not
|
|
@@ -1264,10 +1764,17 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1264
1764
|
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
1265
1765
|
enqueueFloatsFor(i);
|
|
1266
1766
|
const placed = fragment ? fragment.result : result;
|
|
1267
|
-
const to = fragment ? fragment.to : L.
|
|
1767
|
+
const to = fragment ? fragment.to : L.end;
|
|
1268
1768
|
if (part > 0 || fragment)
|
|
1269
1769
|
markFragment(placed, part, fragment !== null);
|
|
1270
|
-
const spacingBefore = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx);
|
|
1770
|
+
const spacingBefore = (curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx)) + balanceBefore;
|
|
1771
|
+
if (balanceBefore > 0)
|
|
1772
|
+
addBalanceExtra(curCol, balanceBefore);
|
|
1773
|
+
// Spacing collapses at a column top, so the lever's push under a
|
|
1774
|
+
// float band is consumed here: the box then opens that far down.
|
|
1775
|
+
if (balanceBefore > 0 && curCol.blocks.length === 0) {
|
|
1776
|
+
curCol.availableHeight = Math.max(0, curCol.availableHeight - balanceBefore);
|
|
1777
|
+
}
|
|
1271
1778
|
enterBand(startIdx, 0);
|
|
1272
1779
|
placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
1273
1780
|
enterBand(startIdx, 0);
|
|
@@ -1301,7 +1808,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1301
1808
|
// order. Then enqueue the floats first-referenced in this block, so the
|
|
1302
1809
|
// next page opened while placing it (or any later block) reserves their
|
|
1303
1810
|
// band and the next iteration offers them the slots that follow.
|
|
1304
|
-
tryPlacePendingFloatsOnCurrentPage();
|
|
1811
|
+
tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx), blockIdx);
|
|
1305
1812
|
enqueueFloatsFor(blockIdx);
|
|
1306
1813
|
// --- Directives ----------------------------------------------------
|
|
1307
1814
|
if (rawBlock.type === 'directive') {
|
|
@@ -1420,7 +1927,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1420
1927
|
// takes every pending float first — in the current page's free
|
|
1421
1928
|
// slots, else on pages opened ahead of it — so no float escapes
|
|
1422
1929
|
// past it. The page stays balanceable (no forced break).
|
|
1423
|
-
|
|
1930
|
+
// A page-span barrier box drains inside `placeCalloutSpan`, once
|
|
1931
|
+
// the page-span figures before it have taken the band cut.
|
|
1932
|
+
if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier && !spanBoxAt(blockIdx)) {
|
|
1424
1933
|
tryPlacePendingFloatsOnCurrentPage();
|
|
1425
1934
|
drainPendingFloats();
|
|
1426
1935
|
}
|
|
@@ -1614,6 +2123,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1614
2123
|
// A justified line a link leaves with too few spaces is set ragged.
|
|
1615
2124
|
let remainingLines = [...raggedUrlLines(measured.lines, style.textAlign, rawBlock.text)];
|
|
1616
2125
|
let partIndex = 0;
|
|
2126
|
+
/** Times this block left an EMPTY short column (see `shortColumn`) —
|
|
2127
|
+
* bounded so a page whose columns are all short (footnotes, design
|
|
2128
|
+
* bands) cannot make it wander forever. */
|
|
2129
|
+
let shortColumnMoves = 0;
|
|
1617
2130
|
// "Keep with next" for colon-introduced lists: a paragraph ending in `:`
|
|
1618
2131
|
// followed directly by a list acts as a lead-in title — the colon-bearing
|
|
1619
2132
|
// line must share a column with the first list item. Only checked for the
|
|
@@ -1642,8 +2155,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1642
2155
|
// page top.
|
|
1643
2156
|
if (vdtType === 'heading') {
|
|
1644
2157
|
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
1645
|
-
if (extraPx)
|
|
2158
|
+
if (extraPx) {
|
|
1646
2159
|
spacingBefore += extraPx;
|
|
2160
|
+
addBalanceExtra(curCol, extraPx);
|
|
2161
|
+
}
|
|
1647
2162
|
}
|
|
1648
2163
|
}
|
|
1649
2164
|
else if (vdtType === 'listItem') {
|
|
@@ -1657,10 +2172,26 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1657
2172
|
// handled inside the heading branch above (after margin collapsing).
|
|
1658
2173
|
if (vdtType !== 'heading' && vdtType !== 'mathDisplay' && partIndex === 0) {
|
|
1659
2174
|
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
1660
|
-
if (extraPx)
|
|
2175
|
+
if (extraPx) {
|
|
1661
2176
|
spacingBefore += extraPx;
|
|
2177
|
+
addBalanceExtra(curCol, extraPx);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
else if (partIndex === 0 && reservedOf(curCol).top > 0) {
|
|
2182
|
+
// Column balancing: extra grid lines between the float band at the
|
|
2183
|
+
// head of this column and its first block (the after-float lever).
|
|
2184
|
+
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
2185
|
+
if (extraPx) {
|
|
2186
|
+
spacingBefore += extraPx;
|
|
2187
|
+
addBalanceExtra(curCol, extraPx);
|
|
1662
2188
|
}
|
|
1663
2189
|
}
|
|
2190
|
+
// A loose paragraph's extra line is balancing height too (it lands
|
|
2191
|
+
// whole in this column — loose candidates are never split parts).
|
|
2192
|
+
if (partIndex === 0 && tryLoose && looseLines !== undefined && typeof looseOutcome.get(blockIdx) === 'number') {
|
|
2193
|
+
addBalanceExtra(curCol, looseLines * style.lineHeightPx);
|
|
2194
|
+
}
|
|
1664
2195
|
const effectiveAvailable = curCol.availableHeight - spacingBefore;
|
|
1665
2196
|
const linesPerAvailable = Math.floor(effectiveAvailable / style.lineHeightPx);
|
|
1666
2197
|
// Math display blocks carry their natural pixel height on the single
|
|
@@ -1704,7 +2235,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1704
2235
|
&& curCol.blocks.length > 0) {
|
|
1705
2236
|
const usedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
|
|
1706
2237
|
const paragraphBottom = usedHeight + totalRemainHeight;
|
|
1707
|
-
|
|
2238
|
+
// Slack after the paragraph as the plain pass saw it: the height
|
|
2239
|
+
// balancing added above in this column is not room the list lost.
|
|
2240
|
+
const availableAfter = curCol.bbox.height - paragraphBottom + (balanceExtraInColumn.get(curCol) ?? 0);
|
|
1708
2241
|
const nextListKind = nextBlock?.listKind ?? 'unordered';
|
|
1709
2242
|
const nextListMarginDim = nextListKind === 'ordered'
|
|
1710
2243
|
? resolved.orderedLists.marginTop
|
|
@@ -1784,6 +2317,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1784
2317
|
// headingRunCount === curCol.blocks.length: fall through to place.
|
|
1785
2318
|
}
|
|
1786
2319
|
}
|
|
2320
|
+
// A block opening a column normally stays (no column can take it any
|
|
2321
|
+
// better), except in a column at least a line shorter than the content
|
|
2322
|
+
// area — the band under a page-span box, or a column cut by a float:
|
|
2323
|
+
// a heading that fits there alone, or a block that does not fit there
|
|
2324
|
+
// but would fit a full column, opens the next column instead (the move
|
|
2325
|
+
// count is bounded besides). A column cut by a band cap is short on
|
|
2326
|
+
// purpose — its content is meant to end at the cut.
|
|
2327
|
+
const shortColumn = shortColumnMoves < 4
|
|
2328
|
+
&& !uncappedBottoms.has(curCol)
|
|
2329
|
+
&& curCol.bbox.height < contentArea.height - baselineGrid;
|
|
1787
2330
|
// Block fits in current column
|
|
1788
2331
|
if (effectiveRemainHeight <= effectiveAvailable) {
|
|
1789
2332
|
// Heading keep-with-next: never leave a heading as the last block of a
|
|
@@ -1800,7 +2343,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1800
2343
|
&& resolved.headings.keepWithNext
|
|
1801
2344
|
&& !nextIsHeading
|
|
1802
2345
|
&& nextBlock !== null
|
|
1803
|
-
&& curCol.blocks.length > 0) {
|
|
2346
|
+
&& (curCol.blocks.length > 0 || shortColumn)) {
|
|
1804
2347
|
const wouldUsedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
|
|
1805
2348
|
const naturalBottom = wouldUsedHeight + effectiveRemainHeight + style.marginBottomPx;
|
|
1806
2349
|
const snappedBottom = shouldSnapToGrid
|
|
@@ -1812,6 +2355,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1812
2355
|
: 1;
|
|
1813
2356
|
const minSpaceAfter = minLinesNeeded * bodyStyle.lineHeightPx;
|
|
1814
2357
|
if (remainAfterHeading < minSpaceAfter) {
|
|
2358
|
+
if (curCol.blocks.length === 0)
|
|
2359
|
+
shortColumnMoves++;
|
|
1815
2360
|
// Roll back any immediately-preceding heading blocks in this
|
|
1816
2361
|
// column so they travel with this one.
|
|
1817
2362
|
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
@@ -1975,8 +2520,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1975
2520
|
}
|
|
1976
2521
|
// choice.splitAt === 0: fall through to push whole paragraph to next column
|
|
1977
2522
|
}
|
|
1978
|
-
// Cannot split — advance to next column if current has content
|
|
1979
|
-
|
|
2523
|
+
// Cannot split — advance to next column if current has content (or
|
|
2524
|
+
// the column is a short band that cannot hold the block at all).
|
|
2525
|
+
if (curCol.blocks.length > 0 || (shortColumn && effectiveRemainHeight <= contentArea.height)) {
|
|
2526
|
+
if (curCol.blocks.length === 0)
|
|
2527
|
+
shortColumnMoves++;
|
|
1980
2528
|
// Heading keep-with-next (no-fit variant): when a heading can't fit
|
|
1981
2529
|
// in the current column and the column's tail is a run of headings,
|
|
1982
2530
|
// pull those headings along so they don't remain stranded as orphans
|
|
@@ -2099,17 +2647,67 @@ export function buildDocument(content, config, cache, options) {
|
|
|
2099
2647
|
let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
|
|
2100
2648
|
let applied = { lines: new Map(), loose: new Map() };
|
|
2101
2649
|
const failedLoose = new Set();
|
|
2650
|
+
const failedLines = new Set();
|
|
2102
2651
|
let converged = bestScore === 0;
|
|
2652
|
+
/**
|
|
2653
|
+
* A rejected pass moved content across a column break somewhere (a
|
|
2654
|
+
* split paragraph whose head no longer fits, a float that lost its slot,
|
|
2655
|
+
* a lead-in that left with its list…): every page after that point is
|
|
2656
|
+
* re-flowed, gaps open elsewhere and a span cap may miss its band. The
|
|
2657
|
+
* levers are meant to be local, so contain the damage: find the first
|
|
2658
|
+
* column whose content changed and blacklist the levers this pass newly
|
|
2659
|
+
* applied there (failing that, on its page; failing that, everywhere), so
|
|
2660
|
+
* the next proposal keeps the working levers before it and tries again
|
|
2661
|
+
* without the one that cascaded. Returns whether anything was blacklisted.
|
|
2662
|
+
*/
|
|
2663
|
+
const containCascade = (next, proposal) => {
|
|
2664
|
+
const div = firstDivergentColumn(best.doc, next.doc);
|
|
2665
|
+
if (!div)
|
|
2666
|
+
return false;
|
|
2667
|
+
const newLines = [...proposal.lines].filter(([k, n]) => n > (applied.lines.get(k) ?? 0)).map(([k]) => k);
|
|
2668
|
+
const newLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
|
|
2669
|
+
if (newLines.length === 0 && newLoose.length === 0)
|
|
2670
|
+
return false;
|
|
2671
|
+
const gaps = collectColumnGaps(best.doc, best.forcedBreakPages);
|
|
2672
|
+
const blacklist = (cands) => {
|
|
2673
|
+
let hit = false;
|
|
2674
|
+
for (const k of newLines)
|
|
2675
|
+
if (!cands || cands.has(k)) {
|
|
2676
|
+
failedLines.add(k);
|
|
2677
|
+
hit = true;
|
|
2678
|
+
}
|
|
2679
|
+
for (const k of newLoose)
|
|
2680
|
+
if (!cands || cands.has(k)) {
|
|
2681
|
+
failedLoose.add(k);
|
|
2682
|
+
hit = true;
|
|
2683
|
+
}
|
|
2684
|
+
return hit;
|
|
2685
|
+
};
|
|
2686
|
+
const inColumn = gaps
|
|
2687
|
+
.filter((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)
|
|
2688
|
+
.flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
|
|
2689
|
+
if (blacklist(new Set(inColumn)))
|
|
2690
|
+
return true;
|
|
2691
|
+
const onPage = gaps
|
|
2692
|
+
.filter((g) => g.pageIndex === div.pageIndex)
|
|
2693
|
+
.flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
|
|
2694
|
+
if (blacklist(new Set(onPage)))
|
|
2695
|
+
return true;
|
|
2696
|
+
return blacklist(null);
|
|
2697
|
+
};
|
|
2103
2698
|
const balance = () => {
|
|
2104
2699
|
while (!converged && passCount < MAX_BALANCING_PASSES) {
|
|
2105
2700
|
const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
|
|
2106
2701
|
maxLinesPerHeading: balancing.maxLinesPerHeading,
|
|
2107
2702
|
stretchAfterLists: balancing.stretchAfterLists,
|
|
2108
2703
|
maxLinesAfterList: balancing.maxLinesAfterList,
|
|
2704
|
+
stretchAfterFloats: balancing.stretchAfterFloats,
|
|
2705
|
+
maxLinesAfterFloat: balancing.maxLinesAfterFloat,
|
|
2109
2706
|
looseParagraphs: balancing.looseParagraphs,
|
|
2110
2707
|
maxLooseParagraphs: balancing.maxLooseParagraphs,
|
|
2111
2708
|
optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
|
|
2112
2709
|
failedLoose,
|
|
2710
|
+
failedLines,
|
|
2113
2711
|
});
|
|
2114
2712
|
if (!proposal.changed) {
|
|
2115
2713
|
// No stretch point can absorb the remaining gaps — stable.
|
|
@@ -2153,11 +2751,16 @@ export function buildDocument(content, config, cache, options) {
|
|
|
2153
2751
|
converged = score === 0;
|
|
2154
2752
|
}
|
|
2155
2753
|
else {
|
|
2156
|
-
// Plateau or regression.
|
|
2157
|
-
//
|
|
2158
|
-
//
|
|
2159
|
-
//
|
|
2160
|
-
//
|
|
2754
|
+
// Plateau or regression. First contain a cascade: a lever that
|
|
2755
|
+
// moved content across a column break is blacklisted and the loop
|
|
2756
|
+
// retries without it. Otherwise retry when a loose candidate was
|
|
2757
|
+
// just blacklisted (the proposer falls through to the next one), or
|
|
2758
|
+
// when the new loose paragraphs gained their lines yet the layout
|
|
2759
|
+
// did not improve (the gain landed elsewhere — drop them too). A
|
|
2760
|
+
// pure spacing plateau means we're done: keep the best layout found
|
|
2761
|
+
// so far.
|
|
2762
|
+
if (containCascade(next, proposal))
|
|
2763
|
+
continue;
|
|
2161
2764
|
if (looseFailed.length > 0 || looseWon.length > 0) {
|
|
2162
2765
|
for (const k of looseWon)
|
|
2163
2766
|
failedLoose.add(k);
|