postext 0.3.29 → 0.3.31
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__/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/paragraphStyles.test.js +19 -0
- package/dist/__tests__/pipeline/paragraphStyles.test.js.map +1 -1
- package/dist/__tests__/pipeline/raggedLines.test.d.ts +2 -0
- package/dist/__tests__/pipeline/raggedLines.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/raggedLines.test.js +26 -0
- package/dist/__tests__/pipeline/raggedLines.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 +159 -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/trailingBand.test.js +67 -0
- package/dist/__tests__/pipeline/trailingBand.test.js.map +1 -1
- 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/renderResourceBlock.d.ts.map +1 -1
- package/dist/canvas-backend/renderResourceBlock.js +13 -1
- 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/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 +4 -4
- 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 +14 -8
- package/dist/pipeline/bandCaps.d.ts.map +1 -1
- package/dist/pipeline/bandCaps.js +38 -20
- package/dist/pipeline/bandCaps.js.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +582 -123
- 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 +17 -1
- package/dist/pipeline/columnBalancing.d.ts.map +1 -1
- package/dist/pipeline/columnBalancing.js +76 -3
- 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/paragraphContainers.d.ts.map +1 -1
- package/dist/pipeline/paragraphContainers.js +4 -1
- package/dist/pipeline/paragraphContainers.js.map +1 -1
- package/dist/pipeline/raggedLines.d.ts +16 -0
- package/dist/pipeline/raggedLines.d.ts.map +1 -0
- package/dist/pipeline/raggedLines.js +28 -0
- package/dist/pipeline/raggedLines.js.map +1 -0
- 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 +48 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +59 -0
- package/dist/vdt.d.ts.map +1 -1
- package/dist/vdt.js.map +1 -1
- package/package.json +1 -1
- package/dist/__tests__/pipeline/raggedUrl.test.d.ts +0 -2
- package/dist/__tests__/pipeline/raggedUrl.test.d.ts.map +0 -1
- package/dist/__tests__/pipeline/raggedUrl.test.js +0 -22
- package/dist/__tests__/pipeline/raggedUrl.test.js.map +0 -1
- package/dist/pipeline/raggedUrl.d.ts +0 -15
- package/dist/pipeline/raggedUrl.d.ts.map +0 -1
- package/dist/pipeline/raggedUrl.js +0 -28
- package/dist/pipeline/raggedUrl.js.map +0 -1
package/dist/pipeline/build.js
CHANGED
|
@@ -16,15 +16,18 @@ 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, collectColumnGaps, firstDivergentColumn, 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
|
-
import {
|
|
27
|
+
import { raggedLooseLines } from './raggedLines';
|
|
28
|
+
/** Tolerance for "does this block fit" checks against a column's free
|
|
29
|
+
* height, absorbing floating-point drift between grid multiples. */
|
|
30
|
+
const FIT_EPS = 0.01;
|
|
28
31
|
export class BuildCancelledError extends Error {
|
|
29
32
|
constructor() {
|
|
30
33
|
super('Build cancelled');
|
|
@@ -207,6 +210,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
207
210
|
};
|
|
208
211
|
const floatGapPx = bodyStyle.lineHeightPx;
|
|
209
212
|
const minTextPx = bodyStyle.lineHeightPx * 3;
|
|
213
|
+
/** Fewest body rows the closing slice of a split table carries. */
|
|
214
|
+
const MIN_TAIL_ROWS = 3;
|
|
210
215
|
/** Offset a resolved resource block's caption/table geometry from
|
|
211
216
|
* block-relative to absolute page coordinates (mirrors inline placement). */
|
|
212
217
|
const offsetResourceBlockToAbsolute = (rb, ox, oy) => {
|
|
@@ -220,6 +225,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
220
225
|
ln.bbox.y += oy;
|
|
221
226
|
ln.baseline += oy;
|
|
222
227
|
}
|
|
228
|
+
for (const ln of rb.continuesLines) {
|
|
229
|
+
ln.bbox.x += ox;
|
|
230
|
+
ln.bbox.y += oy;
|
|
231
|
+
ln.baseline += oy;
|
|
232
|
+
}
|
|
223
233
|
if (rb.captionBar) {
|
|
224
234
|
rb.captionBar.rect.x += ox;
|
|
225
235
|
rb.captionBar.rect.y += oy;
|
|
@@ -228,6 +238,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
228
238
|
for (const cell of rb.table.cells) {
|
|
229
239
|
cell.rect.x += ox;
|
|
230
240
|
cell.rect.y += oy;
|
|
241
|
+
if (cell.image) {
|
|
242
|
+
cell.image.rect.x += ox;
|
|
243
|
+
cell.image.rect.y += oy;
|
|
244
|
+
}
|
|
231
245
|
for (const cl of cell.lines) {
|
|
232
246
|
cl.bbox.x += ox;
|
|
233
247
|
cl.bbox.y += oy;
|
|
@@ -236,7 +250,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
236
250
|
}
|
|
237
251
|
}
|
|
238
252
|
};
|
|
239
|
-
const layoutFloat = (resourceId, width) => {
|
|
253
|
+
const layoutFloat = (resourceId, width, slice) => {
|
|
240
254
|
const resource = resourceById.get(resourceId);
|
|
241
255
|
if (!resource)
|
|
242
256
|
return null;
|
|
@@ -249,17 +263,38 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
249
263
|
resourceNumbering,
|
|
250
264
|
resourceTypes,
|
|
251
265
|
resources,
|
|
266
|
+
...(slice ? { slice } : {}),
|
|
252
267
|
});
|
|
253
268
|
};
|
|
269
|
+
/** Row count of a table resource (0 for anything else). */
|
|
270
|
+
const tableRowCount = (resourceId) => resourceById.get(resourceId)?.table?.model.rows.length ?? 0;
|
|
271
|
+
/** The slice a pending float stands for: the whole resource, or — for the
|
|
272
|
+
* rest of a split table — its remaining rows, laid out as the closing
|
|
273
|
+
* slice (no marker; the note). */
|
|
274
|
+
const sliceOf = (f) => f.startRow !== undefined && f.startRow > 0
|
|
275
|
+
? { startRow: f.startRow, endRow: tableRowCount(f.resourceId), continues: false }
|
|
276
|
+
: undefined;
|
|
277
|
+
const sliceKey = (slice) => slice ? `:${slice.startRow}-${slice.endRow}${slice.continues ? '+' : ''}` : '';
|
|
278
|
+
/** Row metrics of a table float laid out in full at a width, memoised. */
|
|
279
|
+
const tableMetricsMemo = new Map();
|
|
280
|
+
const tableMetrics = (resourceId, width) => {
|
|
281
|
+
const key = `${resourceId}:${width.toFixed(2)}`;
|
|
282
|
+
const memo = tableMetricsMemo.get(key);
|
|
283
|
+
if (memo !== undefined)
|
|
284
|
+
return memo;
|
|
285
|
+
const m = layoutFloat(resourceId, width)?.tableRows ?? null;
|
|
286
|
+
tableMetricsMemo.set(key, m);
|
|
287
|
+
return m;
|
|
288
|
+
};
|
|
254
289
|
/** Height (and caption baseline) of a float at a given width, memoised —
|
|
255
290
|
* fit checks run for every pending float on every loop iteration. */
|
|
256
291
|
const floatMeasureMemo = new Map();
|
|
257
|
-
const measureFloat = (resourceId, width) => {
|
|
258
|
-
const key = `${resourceId}:${width.toFixed(2)}`;
|
|
292
|
+
const measureFloat = (resourceId, width, slice) => {
|
|
293
|
+
const key = `${resourceId}:${width.toFixed(2)}${sliceKey(slice)}`;
|
|
259
294
|
const memo = floatMeasureMemo.get(key);
|
|
260
295
|
if (memo !== undefined)
|
|
261
296
|
return memo;
|
|
262
|
-
const laid = layoutFloat(resourceId, width);
|
|
297
|
+
const laid = layoutFloat(resourceId, width, slice);
|
|
263
298
|
let m = null;
|
|
264
299
|
if (laid) {
|
|
265
300
|
// A bottom band aligns the float's LAST text line to the grid: the
|
|
@@ -269,7 +304,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
269
304
|
const rb = laid.block;
|
|
270
305
|
const bodyBottom = rb.bodyRect.y + rb.bodyRect.height;
|
|
271
306
|
let lastBaseline;
|
|
272
|
-
for (const ln of [...rb.captionLines, ...rb.noteLines]) {
|
|
307
|
+
for (const ln of [...rb.captionLines, ...rb.noteLines, ...rb.continuesLines]) {
|
|
273
308
|
if (ln.bbox.y < bodyBottom - 0.5)
|
|
274
309
|
continue;
|
|
275
310
|
if (lastBaseline === undefined || ln.baseline > lastBaseline)
|
|
@@ -285,12 +320,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
285
320
|
};
|
|
286
321
|
/** Measure + build a float block at horizontal offset `x` (y = 0), or null
|
|
287
322
|
* 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);
|
|
323
|
+
const buildFloatBlock = (resourceId, x, width, slice) => {
|
|
324
|
+
const laid = layoutFloat(resourceId, width, slice);
|
|
290
325
|
if (!laid)
|
|
291
326
|
return null;
|
|
292
327
|
const { block: rb, totalHeight } = laid;
|
|
293
|
-
const
|
|
328
|
+
const id = slice && slice.startRow > 0 ? `float-${resourceId}-cont-${slice.startRow}` : `float-${resourceId}`;
|
|
329
|
+
const blk = createVDTBlock(id, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
|
|
294
330
|
blk.resourceBlock = rb;
|
|
295
331
|
blk.dirty = false;
|
|
296
332
|
blk.snappedToGrid = false;
|
|
@@ -317,24 +353,136 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
317
353
|
}
|
|
318
354
|
return 'span';
|
|
319
355
|
};
|
|
356
|
+
/** Floats (or slices) placed so far — the progress signal of the drain
|
|
357
|
+
* loop, which a split does not shorten the queue for. */
|
|
358
|
+
let floatsPlaced = 0;
|
|
359
|
+
/**
|
|
360
|
+
* The slice of a table float that fits a fresh band of `avail` px, when
|
|
361
|
+
* the whole (rest of the) table does not: the leading rows within the
|
|
362
|
+
* band, cut where `planTableSlice` allows, verified against the band
|
|
363
|
+
* geometry and shrunk row by row until it fits. `tableStyle.overflow`
|
|
364
|
+
* decides what becomes of the rows left over — a rest float to continue
|
|
365
|
+
* on the next page (`'split'`), nothing (`'clip'`) — or, for `'hide'`,
|
|
366
|
+
* that the table is dropped. Returns null for a figure or a table with
|
|
367
|
+
* nothing to cut, which are force-placed like before.
|
|
368
|
+
*/
|
|
369
|
+
const splitTableFloat = (f, width, position, targetCols, contentArea, avail) => {
|
|
370
|
+
const rowCount = tableRowCount(f.resourceId);
|
|
371
|
+
if (rowCount === 0)
|
|
372
|
+
return null;
|
|
373
|
+
const overflow = resolved.tableStyle.overflow;
|
|
374
|
+
if (overflow === 'hide')
|
|
375
|
+
return 'skip';
|
|
376
|
+
const metrics = tableMetrics(f.resourceId, width);
|
|
377
|
+
if (!metrics)
|
|
378
|
+
return null;
|
|
379
|
+
const startRow = f.startRow ?? 0;
|
|
380
|
+
const firstBody = startRow > 0 ? Math.max(startRow, metrics.headerRowCount) : metrics.headerRowCount;
|
|
381
|
+
if (firstBody >= rowCount)
|
|
382
|
+
return null;
|
|
383
|
+
// Overhead of a continuing slice at this width — caption, gaps, marker —
|
|
384
|
+
// from a one-row probe; the row heights come from the metrics.
|
|
385
|
+
const probe = layoutFloat(f.resourceId, width, { startRow, endRow: firstBody + 1, continues: true });
|
|
386
|
+
if (!probe)
|
|
387
|
+
return null;
|
|
388
|
+
const overhead = probe.totalHeight - probe.block.bodyRect.height;
|
|
389
|
+
// A top band rounds up to the grid: the tallest float it holds within
|
|
390
|
+
// `avail` is the grid multiple below it, less the gap.
|
|
391
|
+
const hMax = Math.floor((avail + 0.01) / baselineGrid) * baselineGrid - floatGapPx;
|
|
392
|
+
let end = planTableSlice(metrics, startRow, hMax - overhead);
|
|
393
|
+
// Nothing fits: carry the smallest slice anyway (it overflows, as a
|
|
394
|
+
// dominating figure would) rather than stall the queue.
|
|
395
|
+
const floor = firstBody + 1;
|
|
396
|
+
if (end < floor)
|
|
397
|
+
end = floor;
|
|
398
|
+
// A last page holding a row or two under a repeated header reads as a
|
|
399
|
+
// stranded tail: give the closing slice at least `MIN_TAIL_ROWS` rows by
|
|
400
|
+
// handing some back from this one (at a breakable edge, not under a
|
|
401
|
+
// group head), when this slice can spare them.
|
|
402
|
+
const tail = rowCount - end;
|
|
403
|
+
if (overflow === 'split' && tail > 0 && tail < MIN_TAIL_ROWS) {
|
|
404
|
+
let e = rowCount - MIN_TAIL_ROWS;
|
|
405
|
+
while (e > floor && (!(metrics.breakableAfter[e - 1] ?? true) || metrics.groupHeaderRow[e - 1]))
|
|
406
|
+
e--;
|
|
407
|
+
if (e >= floor && e >= end - MIN_TAIL_ROWS)
|
|
408
|
+
end = e;
|
|
409
|
+
}
|
|
410
|
+
const fits = (slice) => {
|
|
411
|
+
const measure = measureFloat(f.resourceId, width, slice);
|
|
412
|
+
if (!measure)
|
|
413
|
+
return true;
|
|
414
|
+
const { need } = measureFloatBand(position, measure, targetCols, contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms));
|
|
415
|
+
return need <= avail + 0.01;
|
|
416
|
+
};
|
|
417
|
+
const sliceFor = (e) => ({
|
|
418
|
+
startRow,
|
|
419
|
+
endRow: e,
|
|
420
|
+
continues: e < rowCount && overflow === 'split',
|
|
421
|
+
});
|
|
422
|
+
let slice = sliceFor(end);
|
|
423
|
+
// The caption may wrap differently with its suffix, the closing slice
|
|
424
|
+
// carries the note instead of the marker: verify, backing off a row at
|
|
425
|
+
// a time (over breakable edges) when the band still overflows.
|
|
426
|
+
for (let guard = 0; guard < 8 && end > floor && !fits(slice); guard++) {
|
|
427
|
+
do
|
|
428
|
+
end--;
|
|
429
|
+
while (end > floor && !(metrics.breakableAfter[end - 1] ?? true));
|
|
430
|
+
slice = sliceFor(end);
|
|
431
|
+
}
|
|
432
|
+
const rest = slice.continues ? { ...f, startRow: end } : undefined;
|
|
433
|
+
return rest ? { slice, rest } : { slice };
|
|
434
|
+
};
|
|
320
435
|
/**
|
|
321
436
|
* Reserve a float band on `targetCols` (one column, or every text column
|
|
322
437
|
* of the band for a page-span float) and position the float there.
|
|
323
438
|
* `'fresh'` is the freshly-opened-page rule: keep three lines of text room
|
|
324
439
|
* once a band already holds a float, but force-place a dominating float
|
|
325
|
-
* on an all-text band so the queue always progresses
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
440
|
+
* on an all-text band so the queue always progresses — a table is cut to
|
|
441
|
+
* the band instead and continues on the next page (see
|
|
442
|
+
* {@link splitTableFloat}). `'strict'` is the current-page rule: the band
|
|
443
|
+
* must fit in each column's remaining height (below its content), keeping
|
|
444
|
+
* the text room only next to another band. Only mutates page geometry
|
|
445
|
+
* when it places.
|
|
329
446
|
*/
|
|
330
|
-
|
|
447
|
+
/** The band a float would take in a slot — its height (`need`) and the
|
|
448
|
+
* float's `y` — without reserving it. `null` when the float cannot be
|
|
449
|
+
* measured. */
|
|
450
|
+
const probeFloatBand = (page, f, targetCols, position, pageSpan, anchorToCap) => {
|
|
331
451
|
const first = targetCols[0];
|
|
332
452
|
const width = pageSpan ? page.contentArea.width : first.bbox.width;
|
|
333
453
|
const xLeft = pageSpan ? page.contentArea.x : first.bbox.x;
|
|
334
|
-
const
|
|
454
|
+
const slice = sliceOf(f);
|
|
455
|
+
const measure = measureFloat(f.resourceId, width, slice);
|
|
335
456
|
if (!measure)
|
|
457
|
+
return null;
|
|
458
|
+
// A bottom band normally anchors to the column's true foot (under a
|
|
459
|
+
// trailing cap, the page bottom — the closing-page figure). Before a
|
|
460
|
+
// page-span box the cap IS the band's foot: the figure hugs the text
|
|
461
|
+
// and the box follows both.
|
|
462
|
+
const bottomOf = (c) => anchorToCap ? c.bbox.y + c.bbox.height : trueBottom(c, uncappedBottoms);
|
|
463
|
+
const { need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, bottomOf);
|
|
464
|
+
return { need, y, measure, slice, width, xLeft };
|
|
465
|
+
};
|
|
466
|
+
/** Free height `col` keeps for the flow once a float band `probe` is
|
|
467
|
+
* reserved in it at `position` (mirrors the reservation arithmetic of
|
|
468
|
+
* `placeFloatInColumns`; a capped column absorbs a bottom band in its
|
|
469
|
+
* remembered true bottom, so its free height does not change). */
|
|
470
|
+
const availableAfterBand = (col, position, probe, anchorToCap) => {
|
|
471
|
+
if (position === 'top')
|
|
472
|
+
return Math.max(0, col.availableHeight - probe.need);
|
|
473
|
+
if (uncappedBottoms.has(col) && !anchorToCap)
|
|
474
|
+
return col.availableHeight;
|
|
475
|
+
const newHeight = Math.max(0, probe.y - floatGapPx - col.bbox.y);
|
|
476
|
+
return Math.max(0, col.availableHeight - (col.bbox.height - newHeight));
|
|
477
|
+
};
|
|
478
|
+
const placeFloatInColumns = (page, f, targetCols, position, pageSpan, mode, anchorToCap = false) => {
|
|
479
|
+
const first = targetCols[0];
|
|
480
|
+
const probe = probeFloatBand(page, f, targetCols, position, pageSpan, anchorToCap);
|
|
481
|
+
if (!probe)
|
|
336
482
|
return 'skip';
|
|
337
|
-
const {
|
|
483
|
+
const { width, xLeft } = probe;
|
|
484
|
+
let { slice, measure, need, y } = probe;
|
|
485
|
+
let rest;
|
|
338
486
|
if (mode === 'fresh') {
|
|
339
487
|
let minAvail = Infinity;
|
|
340
488
|
let anyReserved = false;
|
|
@@ -346,10 +494,25 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
346
494
|
}
|
|
347
495
|
if (need > minAvail - minTextPx && anyReserved)
|
|
348
496
|
return 'defer';
|
|
497
|
+
if (need > minAvail + 0.01) {
|
|
498
|
+
// Dominating the band: a table is cut to it.
|
|
499
|
+
const split = splitTableFloat(f, width, position, targetCols, page.contentArea, minAvail);
|
|
500
|
+
if (split === 'skip')
|
|
501
|
+
return 'skip';
|
|
502
|
+
if (split) {
|
|
503
|
+
slice = split.slice;
|
|
504
|
+
rest = split.rest;
|
|
505
|
+
const m = measureFloat(f.resourceId, width, slice);
|
|
506
|
+
if (!m)
|
|
507
|
+
return 'skip';
|
|
508
|
+
measure = m;
|
|
509
|
+
({ need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, (c) => trueBottom(c, uncappedBottoms)));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
349
512
|
}
|
|
350
513
|
else {
|
|
351
514
|
for (const c of targetCols) {
|
|
352
|
-
if (position === 'bottom' && uncappedBottoms.has(c)) {
|
|
515
|
+
if (position === 'bottom' && uncappedBottoms.has(c) && !anchorToCap) {
|
|
353
516
|
// Trailing cap: the band must lie entirely below the level cut.
|
|
354
517
|
if (y - floatGapPx < c.bbox.y + c.bbox.height - 0.01)
|
|
355
518
|
return 'defer';
|
|
@@ -359,7 +522,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
359
522
|
return 'defer';
|
|
360
523
|
}
|
|
361
524
|
}
|
|
362
|
-
const built = buildFloatBlock(f.resourceId, xLeft, width);
|
|
525
|
+
const built = buildFloatBlock(f.resourceId, xLeft, width, slice);
|
|
363
526
|
if (!built)
|
|
364
527
|
return 'skip';
|
|
365
528
|
for (const col of targetCols) {
|
|
@@ -373,7 +536,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
373
536
|
}
|
|
374
537
|
else {
|
|
375
538
|
const capped = uncappedBottoms.get(col);
|
|
376
|
-
if (capped !== undefined) {
|
|
539
|
+
if (capped !== undefined && !anchorToCap) {
|
|
377
540
|
uncappedBottoms.set(col, capped - need);
|
|
378
541
|
}
|
|
379
542
|
else {
|
|
@@ -381,6 +544,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
381
544
|
const reserved = col.bbox.height - newHeight;
|
|
382
545
|
col.bbox.height = newHeight;
|
|
383
546
|
col.availableHeight = Math.max(0, col.availableHeight - reserved);
|
|
547
|
+
// Anchored to a cap: the band is the column's content down to
|
|
548
|
+
// the cut; the true bottom shrinks by it too, so a span box
|
|
549
|
+
// measuring its room never cuts across the figure.
|
|
550
|
+
if (capped !== undefined)
|
|
551
|
+
uncappedBottoms.set(col, capped - need);
|
|
384
552
|
}
|
|
385
553
|
r.bottom += need;
|
|
386
554
|
}
|
|
@@ -391,7 +559,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
391
559
|
built.block.pageIndex = page.index;
|
|
392
560
|
built.block.columnIndex = first.index;
|
|
393
561
|
(page.floats ??= []).push(built.block);
|
|
394
|
-
|
|
562
|
+
floatsPlaced++;
|
|
563
|
+
return rest ? { rest } : 'placed';
|
|
564
|
+
};
|
|
565
|
+
/** Apply a slot outcome to the queue at `i`: drop a placed float, keep a
|
|
566
|
+
* deferred one, swap in the rest of a split table. Returns the index to
|
|
567
|
+
* continue from. */
|
|
568
|
+
const settle = (i, r) => {
|
|
569
|
+
if (r === 'defer')
|
|
570
|
+
return i + 1;
|
|
571
|
+
if (typeof r === 'object') {
|
|
572
|
+
pendingFloats[i] = r.rest;
|
|
573
|
+
return i + 1;
|
|
574
|
+
}
|
|
575
|
+
pendingFloats.splice(i, 1);
|
|
576
|
+
return i;
|
|
395
577
|
};
|
|
396
578
|
const positionsFor = (f) => f.position === 'auto' ? ['top', 'bottom'] : [f.position];
|
|
397
579
|
/** Reserve top/bottom bands on a freshly opened page and position as many
|
|
@@ -433,21 +615,76 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
433
615
|
if (r !== 'defer')
|
|
434
616
|
break;
|
|
435
617
|
}
|
|
436
|
-
|
|
437
|
-
i++;
|
|
438
|
-
else
|
|
439
|
-
pendingFloats.splice(i, 1);
|
|
618
|
+
i = settle(i, r);
|
|
440
619
|
}
|
|
441
620
|
}
|
|
442
621
|
};
|
|
622
|
+
/** The keep-together box content block `idx` opens, when it is one that
|
|
623
|
+
* lays out inline in a column of the current band (`span: 'column'`,
|
|
624
|
+
* placement `here`): its height at a column width, so the floats placed
|
|
625
|
+
* right before it can tell whether a slot would starve it. */
|
|
626
|
+
const keepTogetherBoxAt = (idx) => {
|
|
627
|
+
const b = contentBlocks[idx];
|
|
628
|
+
if (!b || b.type !== 'containerStart' || b.containerName !== 'callout')
|
|
629
|
+
return null;
|
|
630
|
+
const plan = calloutPlan.get(idx);
|
|
631
|
+
const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
|
|
632
|
+
if (!plan || !style || !style.keepTogether)
|
|
633
|
+
return null;
|
|
634
|
+
const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
|
|
635
|
+
if (placement !== 'here')
|
|
636
|
+
return null;
|
|
637
|
+
const page = doc.pages[cursor.pageIndex];
|
|
638
|
+
if (span === 'page' && bandColumns(page, currentBand(page, cursor)).length > 1)
|
|
639
|
+
return null;
|
|
640
|
+
const L = makeCalloutLayouter(idx, plan, style);
|
|
641
|
+
const heights = new Map();
|
|
642
|
+
return {
|
|
643
|
+
heightAt: (width) => {
|
|
644
|
+
const key = Math.round(width * 100);
|
|
645
|
+
let h = heights.get(key);
|
|
646
|
+
if (h === undefined) {
|
|
647
|
+
const r = L.layoutRange(CUT_START, L.end, width, 'probe', false);
|
|
648
|
+
h = r.totalHeight + Math.max(pendingSpacing, r.marginTopPx);
|
|
649
|
+
heights.set(key, h);
|
|
650
|
+
}
|
|
651
|
+
return h;
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
};
|
|
655
|
+
/** Whether reserving float `f` in `slot` would leave the keep-together
|
|
656
|
+
* box that comes next with no column of the current band to land in —
|
|
657
|
+
* when, without the float, one of them (the cursor's, or an empty one
|
|
658
|
+
* after it) still holds it. A float yields to such a box, as a
|
|
659
|
+
* compositor would: the box stays in flow and the figure takes the next
|
|
660
|
+
* slot (usually the next page) instead of pushing the box off the page
|
|
661
|
+
* and leaving the column with the figure alone. */
|
|
662
|
+
const slotStarvesBox = (page, f, slot, box, anchorToCap) => {
|
|
663
|
+
const cursorCol = page.columns[cursor.columnIndex];
|
|
664
|
+
if (!cursorCol)
|
|
665
|
+
return false;
|
|
666
|
+
const candidates = bandColumns(page, currentBand(page, cursor))
|
|
667
|
+
.filter((c) => c.kind !== 'span' && c.index >= cursorCol.index && (c === cursorCol || c.blocks.length === 0));
|
|
668
|
+
const fitsIn = (c, available) => available >= box.heightAt(c.bbox.width) - 0.01;
|
|
669
|
+
if (!candidates.some((c) => fitsIn(c, c.availableHeight)))
|
|
670
|
+
return false;
|
|
671
|
+
const probe = probeFloatBand(page, f, slot.cols, slot.position, slot.pageSpan, anchorToCap);
|
|
672
|
+
if (!probe)
|
|
673
|
+
return false;
|
|
674
|
+
const after = (c) => slot.cols.includes(c) ? availableAfterBand(c, slot.position, probe, anchorToCap) : c.availableHeight;
|
|
675
|
+
return !candidates.some((c) => fitsIn(c, after(c)));
|
|
676
|
+
};
|
|
443
677
|
/** Offer every pending float the free slots of the current page after the
|
|
444
678
|
* cursor (bottom of the referencing column, top / bottom of the next
|
|
445
679
|
* empty columns; the band bottom for page-span floats). Runs before each
|
|
446
|
-
* block is placed, so a float lands in the first gap after its reference.
|
|
447
|
-
|
|
680
|
+
* block is placed, so a float lands in the first gap after its reference.
|
|
681
|
+
* `nextBlockIdx` is that block: a keep-together box it opens holds the
|
|
682
|
+
* slots that would starve it (see `slotStarvesBox`). */
|
|
683
|
+
const tryPlacePendingFloatsOnCurrentPage = (preferTop = false, nextBlockIdx) => {
|
|
448
684
|
if (pendingFloats.length === 0)
|
|
449
685
|
return;
|
|
450
686
|
const page = doc.pages[cursor.pageIndex];
|
|
687
|
+
const box = nextBlockIdx !== undefined ? keepTogetherBoxAt(nextBlockIdx) : null;
|
|
451
688
|
for (let i = 0; i < pendingFloats.length;) {
|
|
452
689
|
const f = pendingFloats[i];
|
|
453
690
|
let r = 'defer';
|
|
@@ -455,18 +692,24 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
455
692
|
// A page-span box comes next: the head of an empty column keeps the
|
|
456
693
|
// band cuttable under the float (the box then sits below both the
|
|
457
694
|
// text and the figure), where the referencing column's foot would
|
|
458
|
-
// wall the box off the page.
|
|
695
|
+
// wall the box off the page. A page-span figure waits for the box
|
|
696
|
+
// itself, which cuts the band under the text and sets the figure
|
|
697
|
+
// there, above the box (`placeSpanFloatsAtCut`) — its only slot here
|
|
698
|
+
// would be the band's foot, under the box.
|
|
699
|
+
if (preferTop && f.span === 'page' && bandColumns(page, currentBand(page, cursor)).length > 1) {
|
|
700
|
+
i++;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
459
703
|
if (preferTop)
|
|
460
704
|
slots = [...slots.filter((s) => s.position === 'top'), ...slots.filter((s) => s.position !== 'top')];
|
|
461
705
|
for (const slot of slots) {
|
|
462
|
-
|
|
706
|
+
if (box && slotStarvesBox(page, f, slot, box, preferTop))
|
|
707
|
+
continue;
|
|
708
|
+
r = placeFloatInColumns(page, f, slot.cols, slot.position, slot.pageSpan, 'strict', preferTop);
|
|
463
709
|
if (r !== 'defer')
|
|
464
710
|
break;
|
|
465
711
|
}
|
|
466
|
-
|
|
467
|
-
i++;
|
|
468
|
-
else
|
|
469
|
-
pendingFloats.splice(i, 1);
|
|
712
|
+
i = settle(i, r);
|
|
470
713
|
}
|
|
471
714
|
};
|
|
472
715
|
/** Reserve floats on each freshly opened content page. Passed only to the
|
|
@@ -481,7 +724,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
481
724
|
return false;
|
|
482
725
|
const plan = calloutPlan.get(idx);
|
|
483
726
|
const style = plan ? pickCalloutStyle(resolved.calloutStyles, plan.attrs.type) : undefined;
|
|
484
|
-
if (!style
|
|
727
|
+
if (!style)
|
|
728
|
+
return false;
|
|
729
|
+
const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
|
|
730
|
+
if (span !== 'page' || placement === 'fixed')
|
|
485
731
|
return false;
|
|
486
732
|
const page = doc.pages[cursor.pageIndex];
|
|
487
733
|
return bandColumns(page, currentBand(page, cursor)).length > 1;
|
|
@@ -494,12 +740,12 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
494
740
|
tryPlacePendingFloatsOnCurrentPage();
|
|
495
741
|
let guard = 0;
|
|
496
742
|
while (pendingFloats.length > 0 && guard++ < 1000) {
|
|
497
|
-
const before =
|
|
743
|
+
const before = floatsPlaced;
|
|
498
744
|
const startPageIndex = cursor.pageIndex;
|
|
499
745
|
do {
|
|
500
746
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
501
747
|
} while (cursor.pageIndex === startPageIndex);
|
|
502
|
-
if (
|
|
748
|
+
if (floatsPlaced === before)
|
|
503
749
|
break; // safety: no progress
|
|
504
750
|
}
|
|
505
751
|
};
|
|
@@ -585,7 +831,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
585
831
|
for (const [spanIndex, cap] of bandCaps) {
|
|
586
832
|
if (cap.startContentIndex !== contentIndex || cap.startPart !== part)
|
|
587
833
|
continue;
|
|
588
|
-
applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms, cap.zone);
|
|
834
|
+
applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms, cap.zone, cap.kind === 'trailing');
|
|
589
835
|
activeCap = { spanIndex, pageIndex: page.index, band };
|
|
590
836
|
bandCapsApplied.add(spanIndex);
|
|
591
837
|
break;
|
|
@@ -620,13 +866,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
620
866
|
const cols = bandColumns(page, band).filter((c) => c.bbox.height > 0.5);
|
|
621
867
|
if (cols.length < 2 || cols.some((c) => c.forcedBreak))
|
|
622
868
|
return;
|
|
623
|
-
if (
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
spanPlacedInBand.add(boundaryIndex);
|
|
627
|
-
}
|
|
869
|
+
if (activeCap && activeCap.spanIndex === boundaryIndex
|
|
870
|
+
&& activeCap.pageIndex === page.index && activeCap.band === band) {
|
|
871
|
+
spanPlacedInBand.add(boundaryIndex);
|
|
628
872
|
return;
|
|
629
873
|
}
|
|
874
|
+
// A cap in force for this boundary that did not open the band the
|
|
875
|
+
// boundary is reached in (an earlier cap moved the flow under it, or
|
|
876
|
+
// its own band overflowed) still gets a fresh proposal below: the
|
|
877
|
+
// driver replaces a cap that no longer applies with it, and ignores it
|
|
878
|
+
// while retrying an applied cap a line taller.
|
|
630
879
|
if (activeCap && activeCap.pageIndex === page.index && activeCap.band === band)
|
|
631
880
|
return;
|
|
632
881
|
if (!cols.some((c) => c.blocks.length > 0))
|
|
@@ -637,11 +886,22 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
637
886
|
const aroundZone = zone ? bandCapLinesAroundZone(cols, baselineGrid, zone, (c) => columnBottom(c, uncappedBottoms)) : null;
|
|
638
887
|
if (aroundZone === null && Math.max(...bottoms) - Math.min(...bottoms) <= baselineGrid + 0.5)
|
|
639
888
|
return;
|
|
889
|
+
// Float bands reserved at the columns' feet are the band's content too:
|
|
890
|
+
// the level cut must leave them room under the text (a figure placed
|
|
891
|
+
// before a span box then anchors to the cut, see `anchorToCap`).
|
|
892
|
+
const footBands = cols.reduce((sum, c) => sum + reservedOf(c).bottom, 0);
|
|
893
|
+
// A figure heading an otherwise empty column keeps its slot: the cut
|
|
894
|
+
// goes no higher than the band it takes, or the capped pass evicts it
|
|
895
|
+
// to a page of its own.
|
|
896
|
+
const top = bandTop(cols);
|
|
897
|
+
const floatHeads = cols
|
|
898
|
+
.filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0)
|
|
899
|
+
.map((c) => Math.ceil((c.bbox.y - top - 0.01) / baselineGrid));
|
|
640
900
|
bandCapProposals.set(boundaryIndex, {
|
|
641
901
|
kind: 'trailing',
|
|
642
902
|
startContentIndex: bandStart.contentIndex,
|
|
643
903
|
startPart: bandStart.part,
|
|
644
|
-
lines: aroundZone ?? bandCapLines(cols, baselineGrid),
|
|
904
|
+
lines: aroundZone ?? Math.max(bandCapLines(cols, baselineGrid, footBands), ...floatHeads),
|
|
645
905
|
retries: 0,
|
|
646
906
|
...(aroundZone !== null ? { zone } : {}),
|
|
647
907
|
});
|
|
@@ -734,6 +994,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
734
994
|
doc.blocks.push(child);
|
|
735
995
|
}
|
|
736
996
|
};
|
|
997
|
+
const CUT_START = { child: 0, line: 0 };
|
|
737
998
|
const makeCalloutLayouter = (startIdx, plan, style) => {
|
|
738
999
|
const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
|
|
739
1000
|
const realAt = [];
|
|
@@ -743,12 +1004,15 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
743
1004
|
});
|
|
744
1005
|
const layoutRange = (from, to, width, frameId, continuation) => {
|
|
745
1006
|
let n = 0;
|
|
1007
|
+
// A cut inside child `to.child` includes that child (its first
|
|
1008
|
+
// `to.line` lines); a cut at a child's head excludes it.
|
|
1009
|
+
const toChild = to.line > 0 ? to.child + 1 : to.child;
|
|
746
1010
|
return layoutCallout({
|
|
747
1011
|
style,
|
|
748
1012
|
attrs: plan.attrs,
|
|
749
1013
|
continuation,
|
|
750
|
-
children: children.slice(from,
|
|
751
|
-
childStartIdx: startIdx + 1 + from,
|
|
1014
|
+
children: children.slice(from.child, toChild),
|
|
1015
|
+
childStartIdx: startIdx + 1 + from.child,
|
|
752
1016
|
width,
|
|
753
1017
|
ctx: measureCtx,
|
|
754
1018
|
resolved,
|
|
@@ -756,49 +1020,64 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
756
1020
|
frameId,
|
|
757
1021
|
nextChildId: () => `${frameId}-c${n++}`,
|
|
758
1022
|
paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
|
|
1023
|
+
...(from.line > 0 ? { lineFrom: from.line } : {}),
|
|
1024
|
+
...(to.line > 0 ? { lineTo: to.line } : {}),
|
|
759
1025
|
});
|
|
760
1026
|
};
|
|
761
|
-
return { children, childBase: startIdx + 1, realAt, layoutRange };
|
|
1027
|
+
return { children, childBase: startIdx + 1, realAt, end: { child: children.length, line: 0 }, layoutRange };
|
|
762
1028
|
};
|
|
763
|
-
/** The longest leading fragment of the
|
|
764
|
-
* is at most `roomPx` tall
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const full = L.layoutRange(from, L.children.length, width, frameId, continuation);
|
|
1029
|
+
/** The longest leading fragment of the box from cut `from` on whose box
|
|
1030
|
+
* is at most `roomPx` tall. Cuts fall between children or between the
|
|
1031
|
+
* lines of a text child, and every fragment keeps at least the style's
|
|
1032
|
+
* `splitMinLines` lines on its side of the cut (a figure or a display
|
|
1033
|
+
* formula counts as one line). The full layout's geometry ranks the
|
|
1034
|
+
* candidates (box bottom = content bottom at the cut + the box's tail
|
|
1035
|
+
* below its last child); the deepest candidate that fits is laid out
|
|
1036
|
+
* for real and taken when it truly fits. `null` when none does. */
|
|
1037
|
+
const splitCalloutFragment = (L, from, width, roomPx, frameId, continuation, minLines) => {
|
|
1038
|
+
const full = L.layoutRange(from, L.end, width, frameId, continuation);
|
|
774
1039
|
const lastChild = full.children[full.children.length - 1];
|
|
775
1040
|
if (!lastChild)
|
|
776
1041
|
return null;
|
|
777
1042
|
const tail = full.totalHeight - (lastChild.bbox.y + lastChild.bbox.height);
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1043
|
+
const totalLines = full.children.reduce((n, c) => n + Math.max(1, c.lines.length), 0);
|
|
1044
|
+
/** Candidate cuts with the head's content bottom and line count. */
|
|
1045
|
+
const candidates = [];
|
|
1046
|
+
let linesBefore = 0;
|
|
1047
|
+
for (let i = 0; i < full.children.length; i++) {
|
|
1048
|
+
const c = full.children[i];
|
|
1049
|
+
const k = (c.contentIndex ?? L.childBase) - L.childBase;
|
|
1050
|
+
const cuttable = c.type !== 'resource' && c.type !== 'mathDisplay' && c.lines.length > 1;
|
|
1051
|
+
// The first laid-out child of a continuation opens after `from.line`
|
|
1052
|
+
// lines: cuts inside it are counted from the child's own head.
|
|
1053
|
+
const lineBase = k === from.child ? from.line : 0;
|
|
1054
|
+
if (cuttable) {
|
|
1055
|
+
for (let l = 1; l < c.lines.length; l++) {
|
|
1056
|
+
const line = c.lines[l - 1];
|
|
1057
|
+
candidates.push({ cut: { child: k, line: lineBase + l }, bottom: line.bbox.y + line.bbox.height, headLines: linesBefore + l });
|
|
784
1058
|
}
|
|
785
1059
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1060
|
+
linesBefore += Math.max(1, c.lines.length);
|
|
1061
|
+
if (i < full.children.length - 1) {
|
|
1062
|
+
candidates.push({ cut: { child: k + 1, line: 0 }, bottom: c.bbox.y + c.bbox.height, headLines: linesBefore });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const min = Math.max(1, minLines);
|
|
1066
|
+
const viable = candidates
|
|
1067
|
+
.filter((c) => c.headLines >= min && totalLines - c.headLines >= min)
|
|
1068
|
+
.sort((a, b) => b.bottom - a.bottom);
|
|
1069
|
+
for (const c of viable) {
|
|
1070
|
+
if (c.bottom + tail > roomPx + 0.01)
|
|
1071
|
+
continue;
|
|
1072
|
+
const result = L.layoutRange(from, c.cut, width, frameId, continuation);
|
|
794
1073
|
if (result.totalHeight <= roomPx + 0.01)
|
|
795
|
-
return { to, result };
|
|
1074
|
+
return { to: c.cut, result };
|
|
796
1075
|
}
|
|
797
1076
|
return null;
|
|
798
1077
|
};
|
|
799
|
-
/** Absolute content indices of the children
|
|
800
|
-
* the fragment's source range. */
|
|
801
|
-
const fragmentRange = (L, from, to) => ({ firstChildIdx: L.childBase + from, lastChildIdx: L.childBase + to - 1 });
|
|
1078
|
+
/** Absolute content indices of the children a fragment from cut `from`
|
|
1079
|
+
* to cut `to` of `L` touches, for the fragment's source range. */
|
|
1080
|
+
const fragmentRange = (L, from, to) => ({ firstChildIdx: L.childBase + from.child, lastChildIdx: L.childBase + (to.line > 0 ? to.child : to.child - 1) });
|
|
802
1081
|
const markFragment = (result, part, continued) => {
|
|
803
1082
|
if (result.frame.callout) {
|
|
804
1083
|
result.frame.callout.part = part;
|
|
@@ -879,9 +1158,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
879
1158
|
* referenced by the very block before the box, so a cut that spills
|
|
880
1159
|
* that block into the figure's column leaves the figure no slot after
|
|
881
1160
|
* its reference (it would fall off the page, and the box with it). The
|
|
882
|
-
* box then cuts under the text and the figure alike
|
|
883
|
-
*
|
|
884
|
-
* earlier keeps the cap route, which flows text under it. */
|
|
1161
|
+
* box then cuts under the text and the figure alike; a figure
|
|
1162
|
+
* referenced earlier keeps the cap route, which flows text under it. */
|
|
885
1163
|
const lastBlockBefore = () => {
|
|
886
1164
|
let j = startIdx - 1;
|
|
887
1165
|
while (j >= 0 && isMarkerBlock(contentBlocks[j]))
|
|
@@ -897,13 +1175,69 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
897
1175
|
return false;
|
|
898
1176
|
if (!isBandLevel(textCols))
|
|
899
1177
|
return false;
|
|
900
|
-
const textBottom = bandUsedBottom(textCols);
|
|
901
1178
|
const before = lastBlockBefore();
|
|
902
|
-
|
|
1179
|
+
// However tall the figure is: the text before the box is all placed,
|
|
1180
|
+
// so no cut could level the columns any better — the box goes under
|
|
1181
|
+
// both, the slack under the text being the compositor's trade.
|
|
1182
|
+
return floatOnly.every((c) => topFloatRefOf.get(c) === before);
|
|
1183
|
+
};
|
|
1184
|
+
/**
|
|
1185
|
+
* A page-span figure referenced before the box takes the cut first: the
|
|
1186
|
+
* band is closed level under the text, the figure spans the page right
|
|
1187
|
+
* there (where the text ended, as the compositor reads it), and the box
|
|
1188
|
+
* goes on below — or to the next page when it no longer fits. Only when
|
|
1189
|
+
* the band can be cut for the box (level, or capped for it) and the
|
|
1190
|
+
* figure fits between the cut and the band bottom; otherwise the figure
|
|
1191
|
+
* stays pending for the ordinary slots. Returns whether any was set.
|
|
1192
|
+
*/
|
|
1193
|
+
const placeSpanFloatsAtCut = (page, capActiveHere) => {
|
|
1194
|
+
let placedAny = false;
|
|
1195
|
+
for (let i = 0; i < pendingFloats.length;) {
|
|
1196
|
+
const f = pendingFloats[i];
|
|
1197
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
1198
|
+
if (f.span !== 'page' || cols.length < 2 || !((capActiveHere && !placedAny) || levelForBox(cols))) {
|
|
1199
|
+
i++;
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
const width = page.contentArea.width;
|
|
1203
|
+
const slice = sliceOf(f);
|
|
1204
|
+
const measure = measureFloat(f.resourceId, width, slice);
|
|
1205
|
+
if (!measure) {
|
|
1206
|
+
i++;
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
const cutY = gridUp(page, bandUsedBottom(cols));
|
|
1210
|
+
const spacing = cols.some((c) => c.blocks.length > 0) ? floatGapPx : 0;
|
|
1211
|
+
const need = needFor(spacing, measure.height, floatGapPx);
|
|
1212
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
1213
|
+
if (cutY + need > bandBottom + 0.01) {
|
|
1214
|
+
i++;
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
const built = buildFloatBlock(f.resourceId, page.contentArea.x, width, slice);
|
|
1218
|
+
if (!built) {
|
|
1219
|
+
i++;
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
if (capActiveHere && !placedAny) {
|
|
1223
|
+
uncapBand(cols, uncappedBottoms);
|
|
1224
|
+
spanPlacedInBand.add(startIdx);
|
|
1225
|
+
}
|
|
1226
|
+
closeBandAndInsertSpan(page, cols, cutY, built.block, need, cursor, spacing, built.height);
|
|
1227
|
+
// The float was built at (x, 0): move its inner geometry down to
|
|
1228
|
+
// where the span column put it.
|
|
1229
|
+
offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, built.block.bbox.y);
|
|
1230
|
+
built.block.contentIndex = f.firstBlockIdx;
|
|
1231
|
+
doc.blocks.push(built.block);
|
|
1232
|
+
floatsPlaced++;
|
|
1233
|
+
placedAny = true;
|
|
1234
|
+
pendingFloats.splice(i, 1);
|
|
1235
|
+
}
|
|
1236
|
+
return placedAny;
|
|
903
1237
|
};
|
|
904
|
-
/**
|
|
905
|
-
*
|
|
906
|
-
let from =
|
|
1238
|
+
/** Cut the fragment to place starts at, and its 0-based index among
|
|
1239
|
+
* the fragments (0 = the box, or its head). */
|
|
1240
|
+
let from = CUT_START;
|
|
907
1241
|
let part = 0;
|
|
908
1242
|
let frameId = firstFrameId;
|
|
909
1243
|
/** The box already moved to a fresh page (or sits on an empty one):
|
|
@@ -912,7 +1246,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
912
1246
|
for (;;) {
|
|
913
1247
|
let page = doc.pages[cursor.pageIndex];
|
|
914
1248
|
const continuation = part > 0;
|
|
915
|
-
const layoutAt = (width) => L.layoutRange(from, L.
|
|
1249
|
+
const layoutAt = (width) => L.layoutRange(from, L.end, width, frameId, continuation);
|
|
916
1250
|
const result = layoutAt(page.contentArea.width);
|
|
917
1251
|
/** Where the box would cut the current band, and whether it fits (room
|
|
918
1252
|
* is measured against the columns' TRUE bottoms — a capped band keeps
|
|
@@ -936,11 +1270,70 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
936
1270
|
// Is this band capped for this very box? Then it cuts at the band's
|
|
937
1271
|
// used bottom (at most the cap) even when the last column is short.
|
|
938
1272
|
const cap = part === 0 ? bandCaps?.get(startIdx) : undefined;
|
|
939
|
-
|
|
1273
|
+
let capActive = cap !== undefined
|
|
940
1274
|
&& activeCap !== null
|
|
941
1275
|
&& activeCap.spanIndex === startIdx
|
|
942
1276
|
&& activeCap.pageIndex === page.index
|
|
943
1277
|
&& activeCap.band === currentBand(page, cursor);
|
|
1278
|
+
// A page-span figure referenced before the box cuts the band first
|
|
1279
|
+
// and the box measures the fresh band under it. A barrier box then
|
|
1280
|
+
// takes every other pending float — the current page's free slots,
|
|
1281
|
+
// else pages opened ahead — before it lands.
|
|
1282
|
+
if (part === 0) {
|
|
1283
|
+
if (placeSpanFloatsAtCut(page, capActive))
|
|
1284
|
+
capActive = false;
|
|
1285
|
+
if (style.floatBarrier && pendingFloats.length > 0) {
|
|
1286
|
+
const pageBefore = cursor.pageIndex;
|
|
1287
|
+
const bandBefore = currentBand(page, cursor);
|
|
1288
|
+
// An uneven, uncapped band the figures are about to leave behind
|
|
1289
|
+
// gets the cap that levels it: a span cap when a page-span figure
|
|
1290
|
+
// would fit under the level cut — the capped pass sets it there
|
|
1291
|
+
// and the box follows — else a trailing cap, the figure and the
|
|
1292
|
+
// box moving on and the band ending level like a closing one.
|
|
1293
|
+
// Only a page-span figure is planned for here; column figures keep
|
|
1294
|
+
// the ordinary slots (their level is the band cap's own business).
|
|
1295
|
+
const first = pendingFloats.find((f) => f.span === 'page');
|
|
1296
|
+
if (first && !capActive && cap === undefined && bandStart && registeredBand
|
|
1297
|
+
&& registeredBand.pageIndex === page.index && registeredBand.band === bandBefore) {
|
|
1298
|
+
const cols = bandColumns(page, bandBefore).filter((c) => c.bbox.height > 0.5);
|
|
1299
|
+
if (cols.length > 1 && !isBandLevel(cols) && cols.some((c) => c.blocks.length > 0)) {
|
|
1300
|
+
const lines = bandCapLines(cols, baselineGrid);
|
|
1301
|
+
const capBottom = bandTop(cols) + lines * baselineGrid;
|
|
1302
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
1303
|
+
const m = measureFloat(first.resourceId, page.contentArea.width, sliceOf(first));
|
|
1304
|
+
const figureFits = m !== null && capBottom + needFor(floatGapPx, m.height, floatGapPx) <= bandBottom + 0.01;
|
|
1305
|
+
if (figureFits) {
|
|
1306
|
+
bandCapProposals.set(startIdx, {
|
|
1307
|
+
kind: 'span',
|
|
1308
|
+
startContentIndex: bandStart.contentIndex,
|
|
1309
|
+
startPart: bandStart.part,
|
|
1310
|
+
lines,
|
|
1311
|
+
retries: 0,
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
else {
|
|
1315
|
+
proposeTrailingCap(startIdx);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
tryPlacePendingFloatsOnCurrentPage();
|
|
1320
|
+
drainPendingFloats();
|
|
1321
|
+
if (cursor.pageIndex !== pageBefore || currentBand(doc.pages[cursor.pageIndex], cursor) !== bandBefore) {
|
|
1322
|
+
// A figure took a page ahead and the box follows it there. A
|
|
1323
|
+
// band cut level for it (trailing cap) counts as delivered —
|
|
1324
|
+
// the columns stay cut — and balancing never stretches the
|
|
1325
|
+
// page's last column back to the bottom. A span cap that could
|
|
1326
|
+
// not seat the figure is left undelivered for the driver to
|
|
1327
|
+
// grow or drop.
|
|
1328
|
+
if (capActive && cap?.kind === 'trailing')
|
|
1329
|
+
spanPlacedInBand.add(startIdx);
|
|
1330
|
+
if (doc.pages[pageBefore].columns.some((c) => c.blocks.length > 0))
|
|
1331
|
+
forcedBreakPages.add(pageBefore);
|
|
1332
|
+
}
|
|
1333
|
+
capActive = false;
|
|
1334
|
+
}
|
|
1335
|
+
page = doc.pages[cursor.pageIndex];
|
|
1336
|
+
}
|
|
944
1337
|
const fit = forceHere ? (measureBand(true) ?? measureBand(false)) : measureBand(!capActive);
|
|
945
1338
|
let action = null;
|
|
946
1339
|
if (fit) {
|
|
@@ -960,21 +1353,30 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
960
1353
|
band = measureBand(false);
|
|
961
1354
|
}
|
|
962
1355
|
if (band) {
|
|
963
|
-
const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation);
|
|
1356
|
+
const fragment = splitCalloutFragment(L, from, page.contentArea.width, band.roomPx - band.spacing, frameId, continuation, style.splitMinLines);
|
|
964
1357
|
if (fragment) {
|
|
965
1358
|
action = { kind: 'split', fit: band, need: needFor(band.spacing, fragment.result.totalHeight, 0), fragment };
|
|
966
1359
|
}
|
|
967
1360
|
}
|
|
968
1361
|
}
|
|
969
|
-
if (!action && forceHere && fit)
|
|
1362
|
+
if (!action && forceHere && fit) {
|
|
970
1363
|
action = { kind: 'whole', fit, need: fit.need, result };
|
|
1364
|
+
(doc.warnings ??= []).push({
|
|
1365
|
+
kind: 'calloutOverflow',
|
|
1366
|
+
pageIndex: cursor.pageIndex,
|
|
1367
|
+
columnIndex: cursor.columnIndex,
|
|
1368
|
+
sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
|
|
1369
|
+
sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
|
|
1370
|
+
overflowPx: Math.max(0, fit.spacing + result.totalHeight - fit.roomPx),
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
971
1373
|
if (action) {
|
|
972
1374
|
if (capActive) {
|
|
973
1375
|
uncapBand(action.fit.cols, uncappedBottoms);
|
|
974
1376
|
spanPlacedInBand.add(startIdx);
|
|
975
1377
|
}
|
|
976
1378
|
const placed = action.kind === 'whole' ? action.result : action.fragment.result;
|
|
977
|
-
const to = action.kind === 'whole' ? L.
|
|
1379
|
+
const to = action.kind === 'whole' ? L.end : action.fragment.to;
|
|
978
1380
|
if (part > 0 || action.kind === 'split')
|
|
979
1381
|
markFragment(placed, part, action.kind === 'split');
|
|
980
1382
|
const spanCol = closeBandAndInsertSpan(page, action.fit.cols, action.fit.cutY, placed.frame, action.need, cursor, action.fit.spacing, placed.totalHeight);
|
|
@@ -1011,13 +1413,23 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1011
1413
|
// (or, for a splittable box, for the whole box flush with the
|
|
1012
1414
|
// band bottom).
|
|
1013
1415
|
const cols = bandColumns(page, currentBand(page, cursor));
|
|
1014
|
-
|
|
1015
|
-
|
|
1416
|
+
// A column holding only a figure at its head must keep that slot
|
|
1417
|
+
// in the capped pass: the cut can go no higher than the band the
|
|
1418
|
+
// figure takes, or the figure falls off the page and the box after it.
|
|
1419
|
+
const top = bandTop(cols);
|
|
1420
|
+
const floatHeads = cols
|
|
1421
|
+
.filter((c) => c.blocks.length === 0 && reservedOf(c).top > 0)
|
|
1422
|
+
.map((c) => Math.ceil((c.bbox.y - top - 0.01) / baselineGrid));
|
|
1423
|
+
const lines = Math.max(bandCapLines(cols, baselineGrid), ...floatHeads);
|
|
1424
|
+
const capBottom = top + lines * baselineGrid;
|
|
1016
1425
|
const spacing = Math.max(pendingSpacing, result.marginTopPx);
|
|
1017
1426
|
const need = needFor(spacing, result.totalHeight, result.marginBottomPx);
|
|
1018
1427
|
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
1428
|
+
// A splittable box is worth the cut when its head fits flush with
|
|
1429
|
+
// the band bottom, the rest going on to the next page.
|
|
1019
1430
|
const fitsAfterCut = capBottom + need + minRoomPx <= bandBottom + 0.01
|
|
1020
|
-
|| (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01)
|
|
1431
|
+
|| (splittable && capBottom + needFor(spacing, result.totalHeight, 0) <= bandBottom + 0.01)
|
|
1432
|
+
|| (splittable && splitCalloutFragment(L, from, page.contentArea.width, bandBottom - capBottom - spacing, frameId, continuation, style.splitMinLines) !== null);
|
|
1021
1433
|
if (fitsAfterCut) {
|
|
1022
1434
|
bandCapProposals.set(startIdx, {
|
|
1023
1435
|
kind: 'span',
|
|
@@ -1036,7 +1448,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1036
1448
|
proposeTrailingCap(startIdx);
|
|
1037
1449
|
markForcedBreak();
|
|
1038
1450
|
}
|
|
1039
|
-
else if (capActive && cap
|
|
1451
|
+
else if (capActive && cap?.kind === 'trailing') {
|
|
1040
1452
|
// Reached inside the band cut level for it: delivered even though
|
|
1041
1453
|
// the box moves on — the columns stay cut.
|
|
1042
1454
|
spanPlacedInBand.add(startIdx);
|
|
@@ -1261,7 +1673,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1261
1673
|
const firstFrameId = `block-${blockIdCounter++}`;
|
|
1262
1674
|
const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
|
|
1263
1675
|
const L = makeCalloutLayouter(startIdx, plan, style);
|
|
1264
|
-
const layoutAt = (width) => L.layoutRange(
|
|
1676
|
+
const layoutAt = (width) => L.layoutRange(CUT_START, L.end, width, firstFrameId, false);
|
|
1265
1677
|
// Fixed boxes leave the flow entirely.
|
|
1266
1678
|
if (placement === 'fixed') {
|
|
1267
1679
|
placeCalloutFixed(startIdx, plan, style, layoutAt);
|
|
@@ -1279,22 +1691,42 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1279
1691
|
}
|
|
1280
1692
|
}
|
|
1281
1693
|
const splittable = !style.keepTogether;
|
|
1282
|
-
let from =
|
|
1694
|
+
let from = CUT_START;
|
|
1283
1695
|
let part = 0;
|
|
1284
1696
|
let frameId = firstFrameId;
|
|
1697
|
+
/** Times the box left an EMPTY short column (see below) — bounded. */
|
|
1698
|
+
let shortColumnMoves = 0;
|
|
1285
1699
|
for (;;) {
|
|
1286
1700
|
let curCol = currentColumn(doc, cursor);
|
|
1287
1701
|
const continuation = part > 0;
|
|
1288
|
-
const result = L.layoutRange(from, L.
|
|
1289
|
-
|
|
1702
|
+
const result = L.layoutRange(from, L.end, curCol.bbox.width, frameId, continuation);
|
|
1703
|
+
// Column balancing: a box closing its column takes the column's gap
|
|
1704
|
+
// above it (the trailing-callout lever), so its foot lands on the
|
|
1705
|
+
// last grid slot — level with the column beside it. Any fragment
|
|
1706
|
+
// qualifies, as long as something sits above it to push down from:
|
|
1707
|
+
// text, or the float band at the head of an otherwise empty column.
|
|
1708
|
+
const balanceBefore = curCol.blocks.length > 0 || reservedOf(curCol).top > 0
|
|
1709
|
+
? (balanceExtraPx?.get(balanceKey(startIdx, part)) ?? 0)
|
|
1710
|
+
: 0;
|
|
1711
|
+
const spacing = (curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx)) + balanceBefore;
|
|
1290
1712
|
const roomPx = curCol.availableHeight - spacing;
|
|
1291
1713
|
let fragment = null;
|
|
1292
1714
|
if (result.totalHeight > roomPx + 0.01) {
|
|
1293
1715
|
// The (rest of the) box does not fit the column: a splittable box
|
|
1294
1716
|
// leaves the head that fits here…
|
|
1295
1717
|
if (splittable)
|
|
1296
|
-
fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation);
|
|
1297
|
-
|
|
1718
|
+
fragment = splitCalloutFragment(L, from, curCol.bbox.width, roomPx, frameId, continuation, style.splitMinLines);
|
|
1719
|
+
// …otherwise it moves whole to the next column — also out of an
|
|
1720
|
+
// EMPTY column that float bands or a band cap have cut short, when
|
|
1721
|
+
// a full column would hold it (bounded, so a run of short columns
|
|
1722
|
+
// cannot make it wander forever). Only a box taller than a full
|
|
1723
|
+
// column is placed anyway, overflowing (a layout warning says so).
|
|
1724
|
+
const shortColumn = shortColumnMoves < 4
|
|
1725
|
+
&& curCol.bbox.height < contentArea.height - baselineGrid
|
|
1726
|
+
&& result.totalHeight <= contentArea.height + 0.01;
|
|
1727
|
+
if (!fragment && (curCol.blocks.length > 0 || shortColumn)) {
|
|
1728
|
+
if (curCol.blocks.length === 0)
|
|
1729
|
+
shortColumnMoves++;
|
|
1298
1730
|
// …otherwise it moves whole to the next column. Keep-with-next: a
|
|
1299
1731
|
// run of headings at the column's tail travels with the box.
|
|
1300
1732
|
// Skipped when the column holds nothing else (rolling back again
|
|
@@ -1315,13 +1747,21 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1315
1747
|
return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
|
|
1316
1748
|
}
|
|
1317
1749
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
1318
|
-
|
|
1319
|
-
//
|
|
1320
|
-
|
|
1321
|
-
continue;
|
|
1322
|
-
}
|
|
1750
|
+
// Try the next column afresh: it may be short too (a float band
|
|
1751
|
+
// reserved on the page it opened), or of another width (oneAndHalf).
|
|
1752
|
+
continue;
|
|
1323
1753
|
}
|
|
1324
1754
|
// An empty column that is still too short: placed anyway, overflowing.
|
|
1755
|
+
if (!fragment && curCol.blocks.length === 0 && result.totalHeight > curCol.availableHeight - spacing + 0.01) {
|
|
1756
|
+
(doc.warnings ??= []).push({
|
|
1757
|
+
kind: 'calloutOverflow',
|
|
1758
|
+
pageIndex: cursor.pageIndex,
|
|
1759
|
+
columnIndex: cursor.columnIndex,
|
|
1760
|
+
sourceStart: contentBlocks[startIdx].sourceStart + bodyOffset,
|
|
1761
|
+
sourceEnd: contentBlocks[plan.endIdx].sourceEnd + bodyOffset,
|
|
1762
|
+
overflowPx: result.totalHeight + spacing - curCol.availableHeight,
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1325
1765
|
}
|
|
1326
1766
|
// Floats first-referenced inside the box still enqueue in reading order
|
|
1327
1767
|
// (only once the box is committed, so a keep-with-next replay does not
|
|
@@ -1330,10 +1770,17 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1330
1770
|
for (let i = startIdx + 1; i <= plan.endIdx; i++)
|
|
1331
1771
|
enqueueFloatsFor(i);
|
|
1332
1772
|
const placed = fragment ? fragment.result : result;
|
|
1333
|
-
const to = fragment ? fragment.to : L.
|
|
1773
|
+
const to = fragment ? fragment.to : L.end;
|
|
1334
1774
|
if (part > 0 || fragment)
|
|
1335
1775
|
markFragment(placed, part, fragment !== null);
|
|
1336
|
-
const spacingBefore = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx);
|
|
1776
|
+
const spacingBefore = (curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, placed.marginTopPx)) + balanceBefore;
|
|
1777
|
+
if (balanceBefore > 0)
|
|
1778
|
+
addBalanceExtra(curCol, balanceBefore);
|
|
1779
|
+
// Spacing collapses at a column top, so the lever's push under a
|
|
1780
|
+
// float band is consumed here: the box then opens that far down.
|
|
1781
|
+
if (balanceBefore > 0 && curCol.blocks.length === 0) {
|
|
1782
|
+
curCol.availableHeight = Math.max(0, curCol.availableHeight - balanceBefore);
|
|
1783
|
+
}
|
|
1337
1784
|
enterBand(startIdx, 0);
|
|
1338
1785
|
placeAtomicBlock(placed.frame, placed.totalHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
1339
1786
|
enterBand(startIdx, 0);
|
|
@@ -1367,7 +1814,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1367
1814
|
// order. Then enqueue the floats first-referenced in this block, so the
|
|
1368
1815
|
// next page opened while placing it (or any later block) reserves their
|
|
1369
1816
|
// band and the next iteration offers them the slots that follow.
|
|
1370
|
-
tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx));
|
|
1817
|
+
tryPlacePendingFloatsOnCurrentPage(spanBoxAt(blockIdx), blockIdx);
|
|
1371
1818
|
enqueueFloatsFor(blockIdx);
|
|
1372
1819
|
// --- Directives ----------------------------------------------------
|
|
1373
1820
|
if (rawBlock.type === 'directive') {
|
|
@@ -1486,8 +1933,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1486
1933
|
// takes every pending float first — in the current page's free
|
|
1487
1934
|
// slots, else on pages opened ahead of it — so no float escapes
|
|
1488
1935
|
// past it. The page stays balanceable (no forced break).
|
|
1489
|
-
|
|
1490
|
-
|
|
1936
|
+
// A page-span barrier box drains inside `placeCalloutSpan`, once
|
|
1937
|
+
// the page-span figures before it have taken the band cut.
|
|
1938
|
+
if (pickCalloutStyle(resolved.calloutStyles, plan.attrs.type).floatBarrier && !spanBoxAt(blockIdx)) {
|
|
1939
|
+
tryPlacePendingFloatsOnCurrentPage();
|
|
1491
1940
|
drainPendingFloats();
|
|
1492
1941
|
}
|
|
1493
1942
|
// `span: 'page'` boxes in multi-column layouts branch to the
|
|
@@ -1506,11 +1955,16 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1506
1955
|
? paragraphContainers.byId.get(rawBlock.containerId)
|
|
1507
1956
|
: undefined;
|
|
1508
1957
|
if (pc) {
|
|
1958
|
+
// Margins collapse with the pending spacing; a negative one pulls
|
|
1959
|
+
// the flow up past it instead (the container starts inside the
|
|
1960
|
+
// space the previous block left, or the next block inside the
|
|
1961
|
+
// container's).
|
|
1962
|
+
const collapse = (margin) => margin < 0 ? pendingSpacing + margin : Math.max(pendingSpacing, margin);
|
|
1509
1963
|
if (rawBlock.type === 'containerStart') {
|
|
1510
|
-
pendingSpacing =
|
|
1964
|
+
pendingSpacing = collapse(pc.marginTopPx);
|
|
1511
1965
|
}
|
|
1512
1966
|
else if (contentBlocks[blockIdx - 1]?.type !== 'paragraph') {
|
|
1513
|
-
pendingSpacing =
|
|
1967
|
+
pendingSpacing = collapse(pc.marginBottomPx);
|
|
1514
1968
|
}
|
|
1515
1969
|
}
|
|
1516
1970
|
continue;
|
|
@@ -1677,8 +2131,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1677
2131
|
// List items may split too — orphan/widow protection per-list is gated by
|
|
1678
2132
|
// `avoidOrphansInLists` / `avoidWidowsInLists`; bullet stays on first part.
|
|
1679
2133
|
const canSplit = vdtType === 'paragraph' || vdtType === 'blockquote' || vdtType === 'listItem';
|
|
1680
|
-
// A justified line
|
|
1681
|
-
|
|
2134
|
+
// A justified line the breaker could not fill (a link breaking at its
|
|
2135
|
+
// joints, a last word that cannot come up) is set ragged, not stretched.
|
|
2136
|
+
let remainingLines = [...raggedLooseLines(measured.lines, style.textAlign)];
|
|
1682
2137
|
let partIndex = 0;
|
|
1683
2138
|
/** Times this block left an EMPTY short column (see `shortColumn`) —
|
|
1684
2139
|
* bounded so a page whose columns are all short (footnotes, design
|
|
@@ -1808,7 +2263,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1808
2263
|
: 1;
|
|
1809
2264
|
const splitAt = remainingLines.length - 1;
|
|
1810
2265
|
if (splitAt >= effectiveWidowMin) {
|
|
1811
|
-
if (spacingBefore
|
|
2266
|
+
if (spacingBefore !== 0)
|
|
1812
2267
|
curCol.availableHeight -= spacingBefore;
|
|
1813
2268
|
const splitLines = remainingLines.slice(0, splitAt);
|
|
1814
2269
|
const blk = createVDTBlock(id, vdtType, style.fontString, style.color, style.textAlign);
|
|
@@ -1884,8 +2339,10 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1884
2339
|
const shortColumn = shortColumnMoves < 4
|
|
1885
2340
|
&& !uncappedBottoms.has(curCol)
|
|
1886
2341
|
&& curCol.bbox.height < contentArea.height - baselineGrid;
|
|
1887
|
-
// Block fits in current column
|
|
1888
|
-
|
|
2342
|
+
// Block fits in current column (a hair of tolerance: a capped column
|
|
2343
|
+
// and the grid lines balancing adds above a block differ by floating
|
|
2344
|
+
// point noise, which must not push the block over the cut).
|
|
2345
|
+
if (effectiveRemainHeight <= effectiveAvailable + FIT_EPS) {
|
|
1889
2346
|
// Heading keep-with-next: never leave a heading as the last block of a
|
|
1890
2347
|
// column. If the following (non-heading) block wouldn't have room to
|
|
1891
2348
|
// place at least its widow-minimum number of lines after this heading,
|
|
@@ -1930,8 +2387,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1930
2387
|
continue;
|
|
1931
2388
|
}
|
|
1932
2389
|
}
|
|
1933
|
-
// Consume spacing
|
|
1934
|
-
if (spacingBefore
|
|
2390
|
+
// Consume spacing (negative: a container margin pulling the block up)
|
|
2391
|
+
if (spacingBefore !== 0) {
|
|
1935
2392
|
curCol.availableHeight -= spacingBefore;
|
|
1936
2393
|
}
|
|
1937
2394
|
const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
|
|
@@ -1994,7 +2451,9 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
1994
2451
|
// the grid (e.g. marginBottom is an exact multiple of baselineGrid),
|
|
1995
2452
|
// don't round up to the next line.
|
|
1996
2453
|
const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
|
|
1997
|
-
|
|
2454
|
+
// A negative margin below a container tail may snap the flow back
|
|
2455
|
+
// above the text's own bottom; never below the block's top.
|
|
2456
|
+
h = Math.max(0, snappedBottom - usedHeight);
|
|
1998
2457
|
}
|
|
1999
2458
|
placeBlockInColumn(blk, h, curCol, cursor);
|
|
2000
2459
|
finalizeListItem(blk, partIndex === 0);
|
|
@@ -2040,8 +2499,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
|
2040
2499
|
slackWeight: resolved.bodyText.slackWeight,
|
|
2041
2500
|
});
|
|
2042
2501
|
if (choice.splitAt > 0) {
|
|
2043
|
-
// Consume spacing
|
|
2044
|
-
if (spacingBefore
|
|
2502
|
+
// Consume spacing (negative: a container margin pulling the block up)
|
|
2503
|
+
if (spacingBefore !== 0) {
|
|
2045
2504
|
curCol.availableHeight -= spacingBefore;
|
|
2046
2505
|
}
|
|
2047
2506
|
const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
|
|
@@ -2242,12 +2701,12 @@ export function buildDocument(content, config, cache, options) {
|
|
|
2242
2701
|
};
|
|
2243
2702
|
const inColumn = gaps
|
|
2244
2703
|
.filter((g) => g.pageIndex === div.pageIndex && g.columnIndex === div.columnIndex)
|
|
2245
|
-
.flatMap((g) => g.candidates.map((c) => c.contentIndex));
|
|
2704
|
+
.flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
|
|
2246
2705
|
if (blacklist(new Set(inColumn)))
|
|
2247
2706
|
return true;
|
|
2248
2707
|
const onPage = gaps
|
|
2249
2708
|
.filter((g) => g.pageIndex === div.pageIndex)
|
|
2250
|
-
.flatMap((g) => g.candidates.map((c) => c.contentIndex));
|
|
2709
|
+
.flatMap((g) => g.candidates.map((c) => balanceKey(c.contentIndex, c.part ?? 0)));
|
|
2251
2710
|
if (blacklist(new Set(onPage)))
|
|
2252
2711
|
return true;
|
|
2253
2712
|
return blacklist(null);
|