postext 0.3.19 → 0.3.21
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 +36 -2
- package/dist/__tests__/columnBalancing.test.js.map +1 -1
- package/dist/__tests__/defaults/calloutStyles.test.d.ts +2 -0
- package/dist/__tests__/defaults/calloutStyles.test.d.ts.map +1 -0
- package/dist/__tests__/defaults/calloutStyles.test.js +201 -0
- package/dist/__tests__/defaults/calloutStyles.test.js.map +1 -0
- package/dist/__tests__/defaults/captionStyle.test.d.ts +2 -0
- package/dist/__tests__/defaults/captionStyle.test.d.ts.map +1 -0
- package/dist/__tests__/defaults/captionStyle.test.js +87 -0
- package/dist/__tests__/defaults/captionStyle.test.js.map +1 -0
- package/dist/__tests__/defaults/pageSizePreset.test.d.ts +2 -0
- package/dist/__tests__/defaults/pageSizePreset.test.d.ts.map +1 -0
- package/dist/__tests__/defaults/pageSizePreset.test.js +24 -0
- package/dist/__tests__/defaults/pageSizePreset.test.js.map +1 -0
- package/dist/__tests__/defaults/paragraphStyles.test.d.ts +2 -0
- package/dist/__tests__/defaults/paragraphStyles.test.d.ts.map +1 -0
- package/dist/__tests__/defaults/paragraphStyles.test.js +93 -0
- package/dist/__tests__/defaults/paragraphStyles.test.js.map +1 -0
- package/dist/__tests__/design/pageAnchors.test.d.ts +2 -0
- package/dist/__tests__/design/pageAnchors.test.d.ts.map +1 -0
- package/dist/__tests__/design/pageAnchors.test.js +140 -0
- package/dist/__tests__/design/pageAnchors.test.js.map +1 -0
- package/dist/__tests__/directives.test.js +58 -0
- package/dist/__tests__/directives.test.js.map +1 -1
- package/dist/__tests__/exports.test.js +19 -0
- package/dist/__tests__/exports.test.js.map +1 -1
- package/dist/__tests__/mirroredMargins.test.d.ts +2 -0
- package/dist/__tests__/mirroredMargins.test.d.ts.map +1 -0
- package/dist/__tests__/mirroredMargins.test.js +136 -0
- package/dist/__tests__/mirroredMargins.test.js.map +1 -0
- package/dist/__tests__/numbering.test.js +54 -0
- package/dist/__tests__/numbering.test.js.map +1 -1
- package/dist/__tests__/openerTitleSource.test.d.ts +2 -0
- package/dist/__tests__/openerTitleSource.test.d.ts.map +1 -0
- package/dist/__tests__/openerTitleSource.test.js +108 -0
- package/dist/__tests__/openerTitleSource.test.js.map +1 -0
- package/dist/__tests__/overStretch.test.d.ts +2 -0
- package/dist/__tests__/overStretch.test.d.ts.map +1 -0
- package/dist/__tests__/overStretch.test.js +35 -0
- package/dist/__tests__/overStretch.test.js.map +1 -0
- package/dist/__tests__/pageRoles.test.d.ts +2 -0
- package/dist/__tests__/pageRoles.test.d.ts.map +1 -0
- package/dist/__tests__/pageRoles.test.js +95 -0
- package/dist/__tests__/pageRoles.test.js.map +1 -0
- package/dist/__tests__/parse/containers.test.d.ts +2 -0
- package/dist/__tests__/parse/containers.test.d.ts.map +1 -0
- package/dist/__tests__/parse/containers.test.js +204 -0
- package/dist/__tests__/parse/containers.test.js.map +1 -0
- package/dist/__tests__/parse/headingAttrs.test.d.ts +2 -0
- package/dist/__tests__/parse/headingAttrs.test.d.ts.map +1 -0
- package/dist/__tests__/parse/headingAttrs.test.js +62 -0
- package/dist/__tests__/parse/headingAttrs.test.js.map +1 -0
- package/dist/__tests__/parse/inlineRef.test.js +26 -0
- package/dist/__tests__/parse/inlineRef.test.js.map +1 -1
- package/dist/__tests__/parts.test.d.ts +2 -0
- package/dist/__tests__/parts.test.d.ts.map +1 -0
- package/dist/__tests__/parts.test.js +405 -0
- package/dist/__tests__/parts.test.js.map +1 -0
- package/dist/__tests__/pipeline/calloutLayout.test.d.ts +2 -0
- package/dist/__tests__/pipeline/calloutLayout.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/calloutLayout.test.js +273 -0
- package/dist/__tests__/pipeline/calloutLayout.test.js.map +1 -0
- package/dist/__tests__/pipeline/calloutPlacement.test.d.ts +2 -0
- package/dist/__tests__/pipeline/calloutPlacement.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/calloutPlacement.test.js +239 -0
- package/dist/__tests__/pipeline/calloutPlacement.test.js.map +1 -0
- package/dist/__tests__/pipeline/captionLayout.test.d.ts +2 -0
- package/dist/__tests__/pipeline/captionLayout.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/captionLayout.test.js +269 -0
- package/dist/__tests__/pipeline/captionLayout.test.js.map +1 -0
- package/dist/__tests__/pipeline/inlineRefRender.test.js +53 -0
- package/dist/__tests__/pipeline/inlineRefRender.test.js.map +1 -1
- package/dist/__tests__/pipeline/orderedSeparator.test.d.ts +2 -0
- package/dist/__tests__/pipeline/orderedSeparator.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/orderedSeparator.test.js +196 -0
- package/dist/__tests__/pipeline/orderedSeparator.test.js.map +1 -0
- package/dist/__tests__/pipeline/paragraphStyles.test.d.ts +2 -0
- package/dist/__tests__/pipeline/paragraphStyles.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/paragraphStyles.test.js +172 -0
- package/dist/__tests__/pipeline/paragraphStyles.test.js.map +1 -0
- package/dist/__tests__/pipeline/spanBands.test.d.ts +2 -0
- package/dist/__tests__/pipeline/spanBands.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/spanBands.test.js +303 -0
- package/dist/__tests__/pipeline/spanBands.test.js.map +1 -0
- package/dist/__tests__/pipeline/spanBlocks.test.d.ts +2 -0
- package/dist/__tests__/pipeline/spanBlocks.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/spanBlocks.test.js +407 -0
- package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -0
- package/dist/__tests__/placeholders.test.d.ts +2 -0
- package/dist/__tests__/placeholders.test.d.ts.map +1 -0
- package/dist/__tests__/placeholders.test.js +130 -0
- package/dist/__tests__/placeholders.test.js.map +1 -0
- package/dist/__tests__/table/model.test.js +24 -0
- package/dist/__tests__/table/model.test.js.map +1 -1
- package/dist/__tests__/tracking.test.d.ts +2 -0
- package/dist/__tests__/tracking.test.d.ts.map +1 -0
- package/dist/__tests__/tracking.test.js +37 -0
- package/dist/__tests__/tracking.test.js.map +1 -0
- package/dist/__tests__/urlBreaks.test.d.ts +2 -0
- package/dist/__tests__/urlBreaks.test.d.ts.map +1 -0
- package/dist/__tests__/urlBreaks.test.js +57 -0
- package/dist/__tests__/urlBreaks.test.js.map +1 -0
- package/dist/canvas-backend/blockRender.d.ts.map +1 -1
- package/dist/canvas-backend/blockRender.js +20 -1
- package/dist/canvas-backend/blockRender.js.map +1 -1
- package/dist/canvas-backend/decorations.d.ts +3 -0
- package/dist/canvas-backend/decorations.d.ts.map +1 -1
- package/dist/canvas-backend/decorations.js +14 -8
- package/dist/canvas-backend/decorations.js.map +1 -1
- package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
- package/dist/canvas-backend/headerFooter.js +23 -0
- package/dist/canvas-backend/headerFooter.js.map +1 -1
- package/dist/canvas-backend/index.d.ts.map +1 -1
- package/dist/canvas-backend/index.js +4 -2
- package/dist/canvas-backend/index.js.map +1 -1
- package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -1
- package/dist/canvas-backend/renderResourceBlock.js +33 -5
- package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
- package/dist/columnRule.d.ts +19 -0
- package/dist/columnRule.d.ts.map +1 -0
- package/dist/columnRule.js +36 -0
- package/dist/columnRule.js.map +1 -0
- package/dist/defaults/bodyText.js +1 -1
- package/dist/defaults/bodyText.js.map +1 -1
- package/dist/defaults/calloutStyles.d.ts +61 -0
- package/dist/defaults/calloutStyles.d.ts.map +1 -0
- package/dist/defaults/calloutStyles.js +292 -0
- package/dist/defaults/calloutStyles.js.map +1 -0
- package/dist/defaults/captionStyle.d.ts +11 -1
- package/dist/defaults/captionStyle.d.ts.map +1 -1
- package/dist/defaults/captionStyle.js +127 -2
- package/dist/defaults/captionStyle.js.map +1 -1
- package/dist/defaults/headerFooter.d.ts.map +1 -1
- package/dist/defaults/headerFooter.js +3 -0
- package/dist/defaults/headerFooter.js.map +1 -1
- package/dist/defaults/headings.d.ts +3 -0
- package/dist/defaults/headings.d.ts.map +1 -1
- package/dist/defaults/headings.js +33 -6
- package/dist/defaults/headings.js.map +1 -1
- package/dist/defaults/index.d.ts +4 -1
- package/dist/defaults/index.d.ts.map +1 -1
- package/dist/defaults/index.js +28 -1
- package/dist/defaults/index.js.map +1 -1
- package/dist/defaults/orderedLists.d.ts +9 -8
- package/dist/defaults/orderedLists.d.ts.map +1 -1
- package/dist/defaults/orderedLists.js +66 -4
- package/dist/defaults/orderedLists.js.map +1 -1
- package/dist/defaults/page.d.ts.map +1 -1
- package/dist/defaults/page.js +14 -3
- package/dist/defaults/page.js.map +1 -1
- package/dist/defaults/paragraphStyles.d.ts +10 -0
- package/dist/defaults/paragraphStyles.d.ts.map +1 -0
- package/dist/defaults/paragraphStyles.js +67 -0
- package/dist/defaults/paragraphStyles.js.map +1 -0
- package/dist/defaults/parts.d.ts +22 -0
- package/dist/defaults/parts.d.ts.map +1 -0
- package/dist/defaults/parts.js +139 -0
- package/dist/defaults/parts.js.map +1 -0
- package/dist/defaults/shared.d.ts.map +1 -1
- package/dist/defaults/shared.js +110 -17
- package/dist/defaults/shared.js.map +1 -1
- package/dist/defaults/tableStyle.d.ts.map +1 -1
- package/dist/defaults/tableStyle.js +6 -0
- package/dist/defaults/tableStyle.js.map +1 -1
- package/dist/design/layout.d.ts +27 -1
- package/dist/design/layout.d.ts.map +1 -1
- package/dist/design/layout.js +39 -8
- package/dist/design/layout.js.map +1 -1
- package/dist/design/placeholders.d.ts +21 -4
- package/dist/design/placeholders.d.ts.map +1 -1
- package/dist/design/placeholders.js +38 -7
- package/dist/design/placeholders.js.map +1 -1
- package/dist/html-backend.d.ts.map +1 -1
- package/dist/html-backend.js +75 -16
- package/dist/html-backend.js.map +1 -1
- package/dist/index.d.ts +13 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/knuthPlass/breakpoints.d.ts.map +1 -1
- package/dist/knuthPlass/breakpoints.js +4 -2
- package/dist/knuthPlass/breakpoints.js.map +1 -1
- package/dist/knuthPlass/constants.d.ts +7 -0
- package/dist/knuthPlass/constants.d.ts.map +1 -1
- package/dist/knuthPlass/constants.js +7 -0
- package/dist/knuthPlass/constants.js.map +1 -1
- package/dist/knuthPlass/richAdapter.d.ts +2 -0
- package/dist/knuthPlass/richAdapter.d.ts.map +1 -1
- package/dist/knuthPlass/richAdapter.js +6 -6
- package/dist/knuthPlass/richAdapter.js.map +1 -1
- package/dist/measure/cache.js +2 -2
- package/dist/measure/cache.js.map +1 -1
- package/dist/measure/rich.d.ts +8 -0
- package/dist/measure/rich.d.ts.map +1 -1
- package/dist/measure/rich.js +54 -10
- package/dist/measure/rich.js.map +1 -1
- package/dist/measure/types.d.ts +4 -0
- package/dist/measure/types.d.ts.map +1 -1
- package/dist/measure/types.js.map +1 -1
- package/dist/parse/attrs.d.ts +10 -0
- package/dist/parse/attrs.d.ts.map +1 -0
- package/dist/parse/attrs.js +20 -0
- package/dist/parse/attrs.js.map +1 -0
- package/dist/parse/blockParser.d.ts +8 -4
- package/dist/parse/blockParser.d.ts.map +1 -1
- package/dist/parse/blockParser.js +134 -20
- package/dist/parse/blockParser.js.map +1 -1
- package/dist/parse/index.d.ts +2 -2
- package/dist/parse/index.d.ts.map +1 -1
- package/dist/parse/index.js +1 -1
- package/dist/parse/index.js.map +1 -1
- package/dist/parse/inlineFormatting.d.ts +17 -1
- package/dist/parse/inlineFormatting.d.ts.map +1 -1
- package/dist/parse/inlineFormatting.js +56 -7
- package/dist/parse/inlineFormatting.js.map +1 -1
- package/dist/parse/sourceMapping.d.ts.map +1 -1
- package/dist/parse/sourceMapping.js +13 -1
- package/dist/parse/sourceMapping.js.map +1 -1
- package/dist/parse/types.d.ts +55 -5
- package/dist/parse/types.d.ts.map +1 -1
- package/dist/pipeline/bandCaps.d.ts +86 -0
- package/dist/pipeline/bandCaps.d.ts.map +1 -0
- package/dist/pipeline/bandCaps.js +142 -0
- package/dist/pipeline/bandCaps.js.map +1 -0
- package/dist/pipeline/build.d.ts +41 -0
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +675 -168
- package/dist/pipeline/build.js.map +1 -1
- package/dist/pipeline/buildBlockKind.d.ts +8 -0
- package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
- package/dist/pipeline/buildBlockKind.js +40 -11
- package/dist/pipeline/buildBlockKind.js.map +1 -1
- package/dist/pipeline/buildHelpers.d.ts +23 -1
- package/dist/pipeline/buildHelpers.d.ts.map +1 -1
- package/dist/pipeline/buildHelpers.js +50 -6
- package/dist/pipeline/buildHelpers.js.map +1 -1
- package/dist/pipeline/calloutLayout.d.ts +107 -0
- package/dist/pipeline/calloutLayout.d.ts.map +1 -0
- package/dist/pipeline/calloutLayout.js +498 -0
- package/dist/pipeline/calloutLayout.js.map +1 -0
- package/dist/pipeline/columnBalancing.d.ts +26 -8
- package/dist/pipeline/columnBalancing.d.ts.map +1 -1
- package/dist/pipeline/columnBalancing.js +50 -18
- package/dist/pipeline/columnBalancing.js.map +1 -1
- package/dist/pipeline/config.d.ts.map +1 -1
- package/dist/pipeline/config.js +17 -5
- package/dist/pipeline/config.js.map +1 -1
- package/dist/pipeline/headerFooter.d.ts +37 -2
- package/dist/pipeline/headerFooter.d.ts.map +1 -1
- package/dist/pipeline/headerFooter.js +231 -15
- package/dist/pipeline/headerFooter.js.map +1 -1
- package/dist/pipeline/lists.d.ts +15 -0
- package/dist/pipeline/lists.d.ts.map +1 -1
- package/dist/pipeline/lists.js +73 -16
- package/dist/pipeline/lists.js.map +1 -1
- package/dist/pipeline/measureContentBlock.d.ts +65 -0
- package/dist/pipeline/measureContentBlock.d.ts.map +1 -0
- package/dist/pipeline/measureContentBlock.js +134 -0
- package/dist/pipeline/measureContentBlock.js.map +1 -0
- package/dist/pipeline/pageRoles.d.ts +23 -0
- package/dist/pipeline/pageRoles.d.ts.map +1 -0
- package/dist/pipeline/pageRoles.js +54 -0
- package/dist/pipeline/pageRoles.js.map +1 -0
- package/dist/pipeline/paragraphContainers.d.ts +32 -0
- package/dist/pipeline/paragraphContainers.d.ts.map +1 -0
- package/dist/pipeline/paragraphContainers.js +61 -0
- package/dist/pipeline/paragraphContainers.js.map +1 -0
- package/dist/pipeline/parts.d.ts +47 -0
- package/dist/pipeline/parts.d.ts.map +1 -0
- package/dist/pipeline/parts.js +230 -0
- package/dist/pipeline/parts.js.map +1 -0
- package/dist/pipeline/placeholders.d.ts +46 -1
- package/dist/pipeline/placeholders.d.ts.map +1 -1
- package/dist/pipeline/placeholders.js +93 -11
- package/dist/pipeline/placeholders.js.map +1 -1
- package/dist/pipeline/placement.d.ts +61 -7
- package/dist/pipeline/placement.d.ts.map +1 -1
- package/dist/pipeline/placement.js +151 -12
- package/dist/pipeline/placement.js.map +1 -1
- package/dist/pipeline/resourceLayout.d.ts +18 -5
- package/dist/pipeline/resourceLayout.d.ts.map +1 -1
- package/dist/pipeline/resourceLayout.js +145 -23
- package/dist/pipeline/resourceLayout.js.map +1 -1
- package/dist/pipeline/styles.d.ts +9 -1
- package/dist/pipeline/styles.d.ts.map +1 -1
- package/dist/pipeline/styles.js +50 -0
- package/dist/pipeline/styles.js.map +1 -1
- package/dist/table/model.d.ts.map +1 -1
- package/dist/table/model.js +16 -2
- package/dist/table/model.js.map +1 -1
- package/dist/types.d.ts +468 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +132 -4
- package/dist/vdt.d.ts.map +1 -1
- package/dist/vdt.js +2 -1
- package/dist/vdt.js.map +1 -1
- package/package.json +8 -8
package/dist/pipeline/build.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyTitleBreaks } from '../parse/inlineFormatting';
|
|
1
2
|
import { dimensionToPx } from '../units';
|
|
2
3
|
import { createVDTDocument, createVDTBlock, createBoundingBox, } from '../vdt';
|
|
3
4
|
import { parseMarkdownMemo } from '../parse';
|
|
@@ -7,17 +8,20 @@ import { initHyphenator } from '../measure';
|
|
|
7
8
|
import { resolveAllConfig, computeBaselineGrid, buildHeadingLevelMap } from './config';
|
|
8
9
|
import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
|
|
9
10
|
import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
|
|
10
|
-
import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn,
|
|
11
|
+
import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeAtomicBlock, createPartPage, pageHasContent, bandColumns, currentBand, isBandLevel, bandUsedBottom, closeBandAndInsertSpan, } from './placement';
|
|
11
12
|
import { chooseParagraphSplit } from './orphanWidow';
|
|
12
|
-
import { applyStyleAttrs,
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
13
|
+
import { applyStyleAttrs, computePageMetrics, nextNonMarkerBlock, prevNonMarkerBlock, rollbackTrailingBlocks, } from './buildHelpers';
|
|
14
|
+
import { measureContentBlock } from './measureContentBlock';
|
|
15
|
+
import { planParagraphContainers } from './paragraphContainers';
|
|
16
|
+
import { planParts, derivePartMeasureContext } from './parts';
|
|
17
|
+
import { layoutCallout, offsetCalloutToAbsolute, pickCalloutStyle, planCallouts, resolveCalloutAttrs, } from './calloutLayout';
|
|
18
|
+
import { layoutResourceBlock } from './resourceLayout';
|
|
16
19
|
import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
|
|
17
20
|
import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
|
|
18
21
|
import { defaultResourceTypes } from '../defaults/resourceTypes';
|
|
19
22
|
import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
|
|
20
23
|
import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES, } from './columnBalancing';
|
|
24
|
+
import { applyBandCap, uncapBand, columnBottom, bandCapLines, resolveBandCaps, } from './bandCaps';
|
|
21
25
|
export class BuildCancelledError extends Error {
|
|
22
26
|
constructor() {
|
|
23
27
|
super('Build cancelled');
|
|
@@ -33,25 +37,77 @@ const ALLOWED_PAGE_FORMATS = new Set([
|
|
|
33
37
|
'upper-alpha',
|
|
34
38
|
]);
|
|
35
39
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
40
|
+
* Column balancing: measure a paragraph asked to run `extraLines` long.
|
|
41
|
+
* Walks the tracking ladder — no tracking first, then a little positive
|
|
42
|
+
* tracking up to `maxTracking` — and keeps the first measurement that gains
|
|
43
|
+
* exactly the requested lines within the word-spacing limit (a rung that
|
|
44
|
+
* gains the line on its own, without needing the looseness target, counts
|
|
45
|
+
* too). Falls back to the plain measurement when no rung works, recording
|
|
46
|
+
* the outcome either way.
|
|
41
47
|
*/
|
|
42
|
-
function
|
|
48
|
+
function measureLooseParagraph(rawBlock, blockIdx, columnWidth, ctx, styleOverride, extraLines, trackingLadder, looseOutcome) {
|
|
49
|
+
const base = measureContentBlock(rawBlock, blockIdx, columnWidth, ctx, { styleOverride });
|
|
50
|
+
if (!base)
|
|
51
|
+
return null;
|
|
52
|
+
const target = base.measured.lines.length + extraLines;
|
|
53
|
+
for (const tracking of trackingLadder) {
|
|
54
|
+
const loose = measureContentBlock(rawBlock, blockIdx, columnWidth, ctx, {
|
|
55
|
+
styleOverride,
|
|
56
|
+
looseness: extraLines,
|
|
57
|
+
trackingEm: tracking > 0 ? tracking / 1000 : undefined,
|
|
58
|
+
});
|
|
59
|
+
if (loose && loose.measured.lines.length === target) {
|
|
60
|
+
looseOutcome.set(blockIdx, tracking);
|
|
61
|
+
return loose;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
looseOutcome.set(blockIdx, null);
|
|
65
|
+
return base;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Single placement pass. `hints` carries the cross-pass adjustments: the
|
|
69
|
+
* column-balancing spacing / looseness and the band caps of page-span
|
|
70
|
+
* blocks. Besides the document it reports `forcedBreakPages` (for
|
|
71
|
+
* balancing) and the band-cap bookkeeping the driver in `buildDocument`
|
|
72
|
+
* needs — new cap proposals, the span blocks that landed in their capped
|
|
73
|
+
* band, and the caps that were actually applied.
|
|
74
|
+
*
|
|
75
|
+
* @internal Exposed for tests only; use `buildDocument`.
|
|
76
|
+
*/
|
|
77
|
+
export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
78
|
+
const { balanceExtraPx, balanceLooseness, balanceLooseBudget, bandCaps } = hints;
|
|
43
79
|
const resolved = resolveAllConfig(config);
|
|
80
|
+
// Tracking rungs a loose paragraph may climb: none, then 5‰ steps up to
|
|
81
|
+
// the cap (`headings.balancing.maxTracking`, thousandths of an em), the
|
|
82
|
+
// cap itself always included. Only the smallest rung that gains the line
|
|
83
|
+
// is ever kept; each rung costs one cached paragraph measurement.
|
|
84
|
+
const balancingCfg = resolved.headings.balancing;
|
|
85
|
+
const trackingLadder = [0];
|
|
86
|
+
if (balancingCfg.trackParagraphs && balancingCfg.maxTracking > 0) {
|
|
87
|
+
for (let t = 5; t < balancingCfg.maxTracking; t += 5)
|
|
88
|
+
trackingLadder.push(t);
|
|
89
|
+
trackingLadder.push(balancingCfg.maxTracking);
|
|
90
|
+
}
|
|
91
|
+
const looseOutcome = new Map();
|
|
92
|
+
// Lines gained so far per column budget group (see `LooseBudget`).
|
|
93
|
+
const looseGained = new Map();
|
|
44
94
|
const headingLevelByNumber = buildHeadingLevelMap(resolved);
|
|
45
95
|
const dpi = resolved.page.dpi;
|
|
46
|
-
// Initialize hyphenator if needed
|
|
47
|
-
|
|
96
|
+
// Initialize hyphenator if needed (body text, or any justified paragraph
|
|
97
|
+
// style that hyphenates — they share the document locale).
|
|
98
|
+
const needsHyphenator = (resolved.bodyText.hyphenation.enabled && resolved.bodyText.textAlign === 'justify')
|
|
99
|
+
|| resolved.paragraphStyles.some((s) => s.hyphenation && s.textAlign === 'justify');
|
|
100
|
+
if (needsHyphenator) {
|
|
48
101
|
initHyphenator(resolved.bodyText.hyphenation.locale);
|
|
49
102
|
}
|
|
50
103
|
// Compute baseline grid
|
|
51
104
|
const baselineGrid = computeBaselineGrid(resolved);
|
|
52
105
|
// Create document
|
|
53
106
|
const doc = createVDTDocument(resolved, baselineGrid);
|
|
54
|
-
const
|
|
107
|
+
const pageMetrics = computePageMetrics(resolved);
|
|
108
|
+
const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = pageMetrics;
|
|
109
|
+
// Page/bleed frames for design elements anchored to `'page'` / `'bleed'`.
|
|
110
|
+
const designFrames = { page: pageMetrics.trimBox, bleed: pageMetrics.bleedBox };
|
|
55
111
|
doc.trimOffset = trimOffset;
|
|
56
112
|
// Create first page
|
|
57
113
|
const firstPage = createPageWithColumns(0, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
@@ -92,6 +148,12 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
92
148
|
const listLevelIndentsPx = computeLevelIndentsPx(resolved, bodyStyle.fontSizePx);
|
|
93
149
|
const orderedMetrics = computeOrderedListRunMetrics(contentBlocks, resolved, bodyStyle.fontSizePx);
|
|
94
150
|
const orderedLevelIndentsPx = computeOrderedLevelIndentsPx(resolved, bodyStyle.fontSizePx, orderedMetrics.maxWidthByDepth);
|
|
151
|
+
// `:::paragraphs{style="…"}` containers, resolved per content-block index.
|
|
152
|
+
const paragraphContainers = planParagraphContainers(contentBlocks, resolved);
|
|
153
|
+
// `:::callout` ranges keyed by their start marker index (same rationale).
|
|
154
|
+
const calloutPlan = planCallouts(contentBlocks);
|
|
155
|
+
// `:::part` ranges: start/end marker indices and the enclosed blocks.
|
|
156
|
+
const partPlan = planParts(contentBlocks);
|
|
95
157
|
// --- Float planning (issue #49 — resources float to page bands) ----------
|
|
96
158
|
// A resource is incorporated by its first reference (an inline `:ref` or a
|
|
97
159
|
// `::resource` directive, whichever comes first in reading order). Floated
|
|
@@ -122,6 +184,15 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
122
184
|
ln.bbox.y += oy;
|
|
123
185
|
ln.baseline += oy;
|
|
124
186
|
}
|
|
187
|
+
for (const ln of rb.noteLines) {
|
|
188
|
+
ln.bbox.x += ox;
|
|
189
|
+
ln.bbox.y += oy;
|
|
190
|
+
ln.baseline += oy;
|
|
191
|
+
}
|
|
192
|
+
if (rb.captionBar) {
|
|
193
|
+
rb.captionBar.rect.x += ox;
|
|
194
|
+
rb.captionBar.rect.y += oy;
|
|
195
|
+
}
|
|
125
196
|
if (rb.table) {
|
|
126
197
|
for (const cell of rb.table.cells) {
|
|
127
198
|
cell.rect.x += ox;
|
|
@@ -190,8 +261,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
190
261
|
targetCols = [best];
|
|
191
262
|
}
|
|
192
263
|
const firstCol = page.columns[targetCols[0]];
|
|
193
|
-
const width = pageSpan ? contentArea.width : firstCol.bbox.width;
|
|
194
|
-
const xLeft = pageSpan ? contentArea.x : firstCol.bbox.x;
|
|
264
|
+
const width = pageSpan ? page.contentArea.width : firstCol.bbox.width;
|
|
265
|
+
const xLeft = pageSpan ? page.contentArea.x : firstCol.bbox.x;
|
|
195
266
|
const built = buildFloatBlock(f.resourceId, xLeft, width);
|
|
196
267
|
if (!built)
|
|
197
268
|
return 'skip';
|
|
@@ -225,8 +296,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
225
296
|
const cb = page.columns[c].bbox;
|
|
226
297
|
return cb.y + cb.height;
|
|
227
298
|
}));
|
|
228
|
-
const gridAlignedBottom = contentArea.y
|
|
229
|
-
+ Math.floor((bottomLimit - contentArea.y + 0.01) / baselineGrid) * baselineGrid;
|
|
299
|
+
const gridAlignedBottom = page.contentArea.y
|
|
300
|
+
+ Math.floor((bottomLimit - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
|
|
230
301
|
const capLines = built.block.resourceBlock.captionLines;
|
|
231
302
|
if (capLines.length > 0) {
|
|
232
303
|
// Body baselines sit at 0.2 × grid above each slot bottom; anchor
|
|
@@ -313,6 +384,30 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
313
384
|
/** Reserve floats on each freshly opened content page. Passed only to the
|
|
314
385
|
* content-flow column advances — parity / force-blank pages never get it. */
|
|
315
386
|
const onNewPage = (page) => flushFloatsIntoPage(page);
|
|
387
|
+
// Everything per-block measurement needs that is constant for this pass.
|
|
388
|
+
const measureCtx = {
|
|
389
|
+
resolved,
|
|
390
|
+
bodyStyle,
|
|
391
|
+
blockquoteStyle,
|
|
392
|
+
headingPrefixes,
|
|
393
|
+
listLevelIndentsPx,
|
|
394
|
+
orderedLevelIndentsPx,
|
|
395
|
+
orderedMetrics,
|
|
396
|
+
resourceById,
|
|
397
|
+
resourceTypeById,
|
|
398
|
+
resourceNumberById,
|
|
399
|
+
contentBlocks,
|
|
400
|
+
cache,
|
|
401
|
+
bodyOffset,
|
|
402
|
+
resources,
|
|
403
|
+
resourceTypes,
|
|
404
|
+
resourceNumbering,
|
|
405
|
+
floatedIds,
|
|
406
|
+
};
|
|
407
|
+
// Blocks inside a `:::part` measure with the part body typography.
|
|
408
|
+
const partMeasureCtx = partPlan.byStart.size > 0
|
|
409
|
+
? derivePartMeasureContext(measureCtx)
|
|
410
|
+
: measureCtx;
|
|
316
411
|
// Placement cursor
|
|
317
412
|
const cursor = { pageIndex: 0, columnIndex: 0 };
|
|
318
413
|
let blockIdCounter = 0;
|
|
@@ -327,6 +422,60 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
327
422
|
forcedBreakPages.add(cursor.pageIndex);
|
|
328
423
|
}
|
|
329
424
|
};
|
|
425
|
+
// --- Column bands: opening block + band caps (span blocks, stage 2) ----
|
|
426
|
+
// The band the cursor last placed into, its opening block (content index
|
|
427
|
+
// + part: a paragraph split across pages opens the next page's band with
|
|
428
|
+
// its continuation), and the cap applied to it, if any. `enterBand` runs
|
|
429
|
+
// at every placement site right before a block is offered to the current
|
|
430
|
+
// column: the first block offered to a band is its opening block, and if
|
|
431
|
+
// a cap names it the band's columns are shortened to the cap BEFORE the
|
|
432
|
+
// block is placed, so the fit / split / keep-with-next rules see the
|
|
433
|
+
// capped height. Atomic placements that advance internally re-run it
|
|
434
|
+
// after landing (the cap then trims whatever the block left).
|
|
435
|
+
let registeredBand = null;
|
|
436
|
+
let bandStart = null;
|
|
437
|
+
let activeCap = null;
|
|
438
|
+
/** True bottoms of capped columns (restored when the span block cuts). */
|
|
439
|
+
const uncappedBottoms = new Map();
|
|
440
|
+
const bandCapProposals = new Map();
|
|
441
|
+
const spanPlacedInBand = new Set();
|
|
442
|
+
const bandCapsApplied = new Set();
|
|
443
|
+
const enterBand = (contentIndex, part) => {
|
|
444
|
+
const page = doc.pages[cursor.pageIndex];
|
|
445
|
+
const band = currentBand(page, cursor);
|
|
446
|
+
if (registeredBand && registeredBand.pageIndex === page.index && registeredBand.band === band)
|
|
447
|
+
return;
|
|
448
|
+
registeredBand = { pageIndex: page.index, band };
|
|
449
|
+
bandStart = { contentIndex, part };
|
|
450
|
+
activeCap = null;
|
|
451
|
+
if (!bandCaps)
|
|
452
|
+
return;
|
|
453
|
+
for (const [spanIndex, cap] of bandCaps) {
|
|
454
|
+
if (cap.startContentIndex !== contentIndex || cap.startPart !== part)
|
|
455
|
+
continue;
|
|
456
|
+
applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms);
|
|
457
|
+
activeCap = { spanIndex, pageIndex: page.index, band };
|
|
458
|
+
bandCapsApplied.add(spanIndex);
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
/** Parity of the page break a closed `:::part` still owes (applied before
|
|
463
|
+
* the next placed block). */
|
|
464
|
+
let pendingPartBreak = null;
|
|
465
|
+
/** `advanceToNextPageBoundary`, except that an empty part page is left
|
|
466
|
+
* behind too (its opener design is content). No floats are reserved on
|
|
467
|
+
* the page opened this way — parity padding may still follow it. */
|
|
468
|
+
const leaveCurrentPage = () => {
|
|
469
|
+
const curPage = doc.pages[cursor.pageIndex];
|
|
470
|
+
if (curPage.partInfo && !pageHasContent(curPage)) {
|
|
471
|
+
const startPageIndex = cursor.pageIndex;
|
|
472
|
+
do {
|
|
473
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
474
|
+
} while (cursor.pageIndex === startPageIndex);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
478
|
+
};
|
|
330
479
|
// Page-numbering segments. The implicit first segment comes from
|
|
331
480
|
// `cfg.page.pageNumbering`; `:::numbering` directives append more,
|
|
332
481
|
// each applied at the next page boundary.
|
|
@@ -353,6 +502,262 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
353
502
|
lastSeenPageIndex = cursor.pageIndex;
|
|
354
503
|
}
|
|
355
504
|
};
|
|
505
|
+
/** Heading blocks that are not part of a callout — the only ones the
|
|
506
|
+
* keep-with-next rollbacks may pull along (a callout is one unbreakable
|
|
507
|
+
* unit; its children never leave it). */
|
|
508
|
+
const isFreeHeading = (b) => b.type === 'heading' && b.containerId === undefined;
|
|
509
|
+
/** Shared tail of callout placement: stamp the frame's source range,
|
|
510
|
+
* convert the laid-out box to absolute coordinates at the frame's placed
|
|
511
|
+
* origin, and push frame + children — in that order — to `doc.blocks`
|
|
512
|
+
* and to the column the frame landed in. */
|
|
513
|
+
const commitCallout = (result, startIdx, plan, col) => {
|
|
514
|
+
const frame = result.frame;
|
|
515
|
+
const startBlock = contentBlocks[startIdx];
|
|
516
|
+
const endBlock = contentBlocks[plan.endIdx];
|
|
517
|
+
frame.contentIndex = startIdx;
|
|
518
|
+
frame.sourceStart = startBlock.sourceStart + bodyOffset;
|
|
519
|
+
frame.sourceEnd = endBlock.sourceEnd + bodyOffset;
|
|
520
|
+
offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
|
|
521
|
+
doc.blocks.push(frame);
|
|
522
|
+
for (const child of result.children) {
|
|
523
|
+
child.pageIndex = frame.pageIndex;
|
|
524
|
+
child.columnIndex = frame.columnIndex;
|
|
525
|
+
col.blocks.push(child);
|
|
526
|
+
doc.blocks.push(child);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
/**
|
|
530
|
+
* Place a `span: 'page'` `:::callout` in a multi-column layout as a span
|
|
531
|
+
* block (stage 1): the box is laid out at the page's content width and
|
|
532
|
+
* gets its own full-width `kind: 'span'` column; the text columns of the
|
|
533
|
+
* current band are closed at the cut line and a fresh band of text columns
|
|
534
|
+
* opens below the box, so the flow continues under it in every column.
|
|
535
|
+
*
|
|
536
|
+
* The band must be LEVEL — every column has consumed the same height:
|
|
537
|
+
* the page top, right after a `span: 'page'` opener heading, right after
|
|
538
|
+
* another span block, or right after a top float band — and leave room
|
|
539
|
+
* for the box plus at least the widow minimum of body lines below it.
|
|
540
|
+
* Otherwise the box moves to the top of the next page (a fresh page is
|
|
541
|
+
* trivially level) WITHOUT marking a forced break, so the page it left
|
|
542
|
+
* stays balanceable. Keep-with-next does not apply here: a heading right
|
|
543
|
+
* before a page-span box stays in its text column.
|
|
544
|
+
*
|
|
545
|
+
* Stage 2 — mid-page bands (`bandCaps.ts`): a box arriving in an UNEVEN
|
|
546
|
+
* band with room below proposes a band cap — the band's columns cut
|
|
547
|
+
* level at `ceil(Σ used / N / grid)` lines — and falls back to the next
|
|
548
|
+
* page for this pass; the driver re-runs the pass with the cap, the
|
|
549
|
+
* capped columns fill and overflow naturally (every placement rule still
|
|
550
|
+
* applies), and when the box arrives in the band its cap was applied to
|
|
551
|
+
* it cuts there: the columns above end level (the last one may keep up
|
|
552
|
+
* to a few lines of slack, which balancing absorbs), the box spans the
|
|
553
|
+
* page and the flow resumes in the band below. When the capped band
|
|
554
|
+
* overflowed instead (the box arrives elsewhere), the driver grows or
|
|
555
|
+
* drops the cap.
|
|
556
|
+
*
|
|
557
|
+
* Geometry stays on the baseline grid: the cut line is the band's used
|
|
558
|
+
* bottom snapped UP to the next grid line (anchored at the content-area
|
|
559
|
+
* top, like every column start), and the span column's height is the
|
|
560
|
+
* box plus its collapsed top spacing and `marginBottom`, rounded up to a
|
|
561
|
+
* grid multiple — so the new band's columns start on the grid. Floats
|
|
562
|
+
* stay in the outer page bands: the band inherits the float-reduced top
|
|
563
|
+
* / bottom of the page's columns, so a span column never overlaps a float.
|
|
564
|
+
*/
|
|
565
|
+
const placeCalloutSpan = (startIdx, plan, layoutAt) => {
|
|
566
|
+
let page = doc.pages[cursor.pageIndex];
|
|
567
|
+
let result = layoutAt(page.contentArea.width);
|
|
568
|
+
const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
|
|
569
|
+
const minRoomPx = minLines * bodyStyle.lineHeightPx;
|
|
570
|
+
/** Where the box would cut the current band, and whether it fits (room
|
|
571
|
+
* is measured against the columns' TRUE bottoms — a capped band keeps
|
|
572
|
+
* its slack below the cap). Null when the band is not level (or the
|
|
573
|
+
* cursor sits on a span column with no band below it) and
|
|
574
|
+
* `requireLevel` is set. */
|
|
575
|
+
const measureBand = (requireLevel) => {
|
|
576
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
577
|
+
if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
|
|
578
|
+
return null;
|
|
579
|
+
const usedBottom = bandUsedBottom(cols);
|
|
580
|
+
const cutY = page.contentArea.y
|
|
581
|
+
+ Math.ceil((usedBottom - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
|
|
582
|
+
const bandHasContent = cols.some((c) => c.blocks.length > 0);
|
|
583
|
+
const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
|
|
584
|
+
const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
|
|
585
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
586
|
+
const room = cutY + need + minRoomPx <= bandBottom + 0.01;
|
|
587
|
+
return { cols, cutY, need, spacing, room };
|
|
588
|
+
};
|
|
589
|
+
// Is this band capped for this very box? Then it cuts at the band's
|
|
590
|
+
// used bottom (at most the cap) even when the last column is short.
|
|
591
|
+
const cap = bandCaps?.get(startIdx);
|
|
592
|
+
const capActive = cap !== undefined
|
|
593
|
+
&& activeCap !== null
|
|
594
|
+
&& activeCap.spanIndex === startIdx
|
|
595
|
+
&& activeCap.pageIndex === page.index
|
|
596
|
+
&& activeCap.band === currentBand(page, cursor);
|
|
597
|
+
let fit = measureBand(!capActive);
|
|
598
|
+
if (fit?.room && capActive) {
|
|
599
|
+
uncapBand(fit.cols, uncappedBottoms);
|
|
600
|
+
spanPlacedInBand.add(startIdx);
|
|
601
|
+
}
|
|
602
|
+
if (!fit && cap === undefined && bandStart && registeredBand
|
|
603
|
+
&& registeredBand.pageIndex === page.index
|
|
604
|
+
&& registeredBand.band === currentBand(page, cursor)) {
|
|
605
|
+
// Uneven band, no cap yet: propose one when a level cut would leave
|
|
606
|
+
// room for the box plus the widow minimum of body lines below it.
|
|
607
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
608
|
+
const lines = bandCapLines(cols, baselineGrid);
|
|
609
|
+
const capBottom = Math.max(...cols.map((c) => c.bbox.y)) + lines * baselineGrid;
|
|
610
|
+
const spacing = Math.max(pendingSpacing, result.marginTopPx);
|
|
611
|
+
const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
|
|
612
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
613
|
+
if (capBottom + need + minRoomPx <= bandBottom + 0.01) {
|
|
614
|
+
bandCapProposals.set(startIdx, {
|
|
615
|
+
startContentIndex: bandStart.contentIndex,
|
|
616
|
+
startPart: bandStart.part,
|
|
617
|
+
lines,
|
|
618
|
+
retries: 0,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (!fit || !fit.room) {
|
|
623
|
+
// Open the next page (flushing pending floats into its bands). A page
|
|
624
|
+
// holding only floats counts as occupied here — its float band is what
|
|
625
|
+
// left no room — but a truly empty page is kept: the box then simply
|
|
626
|
+
// does not fit a page and is force-placed (overflowing, like inline).
|
|
627
|
+
const curPage = doc.pages[cursor.pageIndex];
|
|
628
|
+
if (pageHasContent(curPage) || (curPage.floats?.length ?? 0) > 0) {
|
|
629
|
+
pendingSpacing = 0;
|
|
630
|
+
const startPageIndex = cursor.pageIndex;
|
|
631
|
+
do {
|
|
632
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
633
|
+
} while (cursor.pageIndex === startPageIndex);
|
|
634
|
+
page = doc.pages[cursor.pageIndex];
|
|
635
|
+
if (Math.abs(page.contentArea.width - result.width) > 0.01) {
|
|
636
|
+
result = layoutAt(page.contentArea.width);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// A freshly opened page is level; force-place (overflow) when the box
|
|
640
|
+
// is taller than the page.
|
|
641
|
+
fit = measureBand(true) ?? measureBand(false);
|
|
642
|
+
if (!fit)
|
|
643
|
+
return false; // no text column to cut — leave it to the inline path
|
|
644
|
+
}
|
|
645
|
+
const spanCol = closeBandAndInsertSpan(page, fit.cols, fit.cutY, result.frame, fit.need, cursor, fit.spacing, result.totalHeight);
|
|
646
|
+
commitCallout(result, startIdx, plan, spanCol);
|
|
647
|
+
// Floats first-referenced inside the box enqueue once it is committed,
|
|
648
|
+
// in reading order (same as the inline path).
|
|
649
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++) {
|
|
650
|
+
const fl = floatsByFirstBlock.get(i);
|
|
651
|
+
if (fl)
|
|
652
|
+
pendingFloats.push(...fl);
|
|
653
|
+
}
|
|
654
|
+
// The new band starts on the grid right below the span column; nothing
|
|
655
|
+
// to snap — `need` already bakes in `marginBottom`.
|
|
656
|
+
pendingSpacing = 0;
|
|
657
|
+
return true;
|
|
658
|
+
};
|
|
659
|
+
/**
|
|
660
|
+
* Place a `:::callout` inline at the current column width as one atomic
|
|
661
|
+
* unit: the frame block followed by its children in the same column. The
|
|
662
|
+
* box's `marginTop` collapses with the pending spacing; `marginBottom` is
|
|
663
|
+
* baked into the post-box grid snap. A box that does not fit moves to the
|
|
664
|
+
* next column/page (like a resource), pulling a run of trailing headings
|
|
665
|
+
* along (keep-with-next); a box taller than an empty column is placed
|
|
666
|
+
* anyway and overflows (the sandbox warns). Returns the content index to
|
|
667
|
+
* rewind the main loop to when headings were rolled back, else `undefined`.
|
|
668
|
+
*
|
|
669
|
+
* `span: 'page'` boxes in multi-column layouts take the span-block path
|
|
670
|
+
* (`placeCalloutSpan`) instead; `placement: 'top' | 'bottom'` (floating
|
|
671
|
+
* boxes) still fall back to this inline placement for v1 — the frame's
|
|
672
|
+
* `callout.placement` records the request.
|
|
673
|
+
*/
|
|
674
|
+
const placeCalloutInline = (startIdx, plan) => {
|
|
675
|
+
const style = pickCalloutStyle(resolved.calloutStyles, plan.attrs.type);
|
|
676
|
+
const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
|
|
677
|
+
const frameId = `block-${blockIdCounter++}`;
|
|
678
|
+
const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
|
|
679
|
+
const layoutAt = (width) => {
|
|
680
|
+
let n = 0;
|
|
681
|
+
return layoutCallout({
|
|
682
|
+
style,
|
|
683
|
+
attrs: plan.attrs,
|
|
684
|
+
children,
|
|
685
|
+
childStartIdx: startIdx + 1,
|
|
686
|
+
width,
|
|
687
|
+
ctx: measureCtx,
|
|
688
|
+
resolved,
|
|
689
|
+
containerId: plan.containerId,
|
|
690
|
+
frameId,
|
|
691
|
+
nextChildId: () => `${frameId}-c${n++}`,
|
|
692
|
+
paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
|
|
693
|
+
});
|
|
694
|
+
};
|
|
695
|
+
// Page-span boxes split a multi-column page into column bands (stage 1
|
|
696
|
+
// of span blocks). Floating placements keep the inline fallback.
|
|
697
|
+
{
|
|
698
|
+
const page = doc.pages[cursor.pageIndex];
|
|
699
|
+
if (span === 'page'
|
|
700
|
+
&& placement === 'here'
|
|
701
|
+
&& bandColumns(page, currentBand(page, cursor)).length > 1
|
|
702
|
+
&& placeCalloutSpan(startIdx, plan, layoutAt)) {
|
|
703
|
+
return undefined;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
let curCol = currentColumn(doc, cursor);
|
|
707
|
+
let result = layoutAt(curCol.bbox.width);
|
|
708
|
+
if (curCol.blocks.length > 0) {
|
|
709
|
+
const spacingBefore = Math.max(pendingSpacing, result.marginTopPx);
|
|
710
|
+
if (result.totalHeight > curCol.availableHeight - spacingBefore) {
|
|
711
|
+
// Keep-with-next: a run of headings at the column's tail travels
|
|
712
|
+
// with the box. Skipped when the column holds nothing else (rolling
|
|
713
|
+
// back again would loop) — the headings stay, orphaned.
|
|
714
|
+
let run = 0;
|
|
715
|
+
for (let j = curCol.blocks.length - 1; j >= 0; j--) {
|
|
716
|
+
if (isFreeHeading(curCol.blocks[j]))
|
|
717
|
+
run++;
|
|
718
|
+
else
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
pendingSpacing = 0;
|
|
722
|
+
if (resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
|
|
723
|
+
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
724
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
725
|
+
return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
|
|
726
|
+
}
|
|
727
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
728
|
+
curCol = currentColumn(doc, cursor);
|
|
729
|
+
// Columns of different widths (oneAndHalf): re-lay out for the new one.
|
|
730
|
+
if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
|
|
731
|
+
result = layoutAt(curCol.bbox.width);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// Floats first-referenced inside the box still enqueue in reading order
|
|
736
|
+
// (only once the box is committed, so a keep-with-next replay does not
|
|
737
|
+
// enqueue them twice).
|
|
738
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++) {
|
|
739
|
+
const fl = floatsByFirstBlock.get(i);
|
|
740
|
+
if (fl)
|
|
741
|
+
pendingFloats.push(...fl);
|
|
742
|
+
}
|
|
743
|
+
const frame = result.frame;
|
|
744
|
+
const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
|
|
745
|
+
enterBand(startIdx, 0);
|
|
746
|
+
placeAtomicBlock(frame, result.totalHeight, spacing, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
747
|
+
enterBand(startIdx, 0);
|
|
748
|
+
curCol = currentColumn(doc, cursor);
|
|
749
|
+
commitCallout(result, startIdx, plan, curCol);
|
|
750
|
+
// Snap the flow after the box to the baseline grid, baking in at least
|
|
751
|
+
// `marginBottom` (grid wins, margin is a minimum — the resource rule).
|
|
752
|
+
{
|
|
753
|
+
const usedHeight = curCol.bbox.height - curCol.availableHeight;
|
|
754
|
+
const naturalBottom = usedHeight + result.marginBottomPx;
|
|
755
|
+
const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
|
|
756
|
+
curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
|
|
757
|
+
}
|
|
758
|
+
pendingSpacing = 0;
|
|
759
|
+
return undefined;
|
|
760
|
+
};
|
|
356
761
|
for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
|
|
357
762
|
if (options?.shouldCancel?.())
|
|
358
763
|
throw new BuildCancelledError();
|
|
@@ -381,6 +786,22 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
381
786
|
}
|
|
382
787
|
flushPendingNumberingAtBoundary();
|
|
383
788
|
}
|
|
789
|
+
else if (name === 'columnbreak') {
|
|
790
|
+
// Explicit column break: end the current column here (its bottom
|
|
791
|
+
// gap is intentional, so balancing skips it) and continue in the
|
|
792
|
+
// next column. A no-op in an empty column, so it never opens a
|
|
793
|
+
// blank column or page.
|
|
794
|
+
pendingSpacing = 0;
|
|
795
|
+
const col = currentColumn(doc, cursor);
|
|
796
|
+
if (col.blocks.length > 0) {
|
|
797
|
+
col.forcedBreak = true;
|
|
798
|
+
const page = doc.pages[cursor.pageIndex];
|
|
799
|
+
if (cursor.columnIndex === page.columns.length - 1)
|
|
800
|
+
markForcedBreak();
|
|
801
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
802
|
+
flushPendingNumberingAtBoundary();
|
|
803
|
+
}
|
|
804
|
+
}
|
|
384
805
|
else if (name === 'numbering') {
|
|
385
806
|
const change = {};
|
|
386
807
|
const fmt = attrs.format;
|
|
@@ -396,6 +817,92 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
396
817
|
}
|
|
397
818
|
continue;
|
|
398
819
|
}
|
|
820
|
+
// --- Container markers ---------------------------------------------
|
|
821
|
+
// `:::paragraphs` applies its style's top margin on entry through the
|
|
822
|
+
// pending-spacing mechanism (collapses like any margin, vanishes at a
|
|
823
|
+
// column top). Its bottom margin is normally baked into the last
|
|
824
|
+
// paragraph's grid snap; the pending-spacing fallback covers containers
|
|
825
|
+
// that end with a non-paragraph block. Replaying a marker after a keep-with-next rewind is
|
|
826
|
+
// harmless: the container plan is index-based, and `max` is idempotent.
|
|
827
|
+
// `:::part`: the opener lives on a dedicated single-column page. On
|
|
828
|
+
// entry, break to a fresh page of the configured parity and convert it
|
|
829
|
+
// into a part page; on exit, break again so the following content (and
|
|
830
|
+
// the next chapter's own parity rule) starts clean. A part page counts
|
|
831
|
+
// as content even with an empty body — its opener design fills it — so
|
|
832
|
+
// consecutive parts never share a page.
|
|
833
|
+
if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'part') {
|
|
834
|
+
const plan = partPlan.byStart.get(blockIdx);
|
|
835
|
+
if (plan) {
|
|
836
|
+
pendingSpacing = 0;
|
|
837
|
+
markForcedBreak();
|
|
838
|
+
leaveCurrentPage();
|
|
839
|
+
enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, resolved.parts.breakBefore.parity);
|
|
840
|
+
cursor.columnIndex = 0;
|
|
841
|
+
// Map the opener's title back to the `title="…"` attribute of the
|
|
842
|
+
// fence so the editor can place the cursor from a click on the band.
|
|
843
|
+
const fenceStart = rawBlock.sourceStart + bodyOffset;
|
|
844
|
+
const fenceText = markdownBody.slice(rawBlock.sourceStart, rawBlock.sourceEnd);
|
|
845
|
+
const titleAttr = /\btitle\s*=\s*(["'])/.exec(fenceText);
|
|
846
|
+
const titleSourceStart = titleAttr ? fenceStart + titleAttr.index + titleAttr[0].length : fenceStart;
|
|
847
|
+
const titleSourceEnd = titleAttr ? titleSourceStart + plan.title.length : rawBlock.sourceEnd + bodyOffset;
|
|
848
|
+
createPartPage(doc.pages[cursor.pageIndex], pageMetrics, resolved, {
|
|
849
|
+
number: plan.number,
|
|
850
|
+
title: plan.title,
|
|
851
|
+
titleSourceStart,
|
|
852
|
+
titleSourceEnd,
|
|
853
|
+
});
|
|
854
|
+
flushPendingNumberingAtBoundary();
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (rawBlock.type === 'containerEnd' && rawBlock.containerName === 'part') {
|
|
859
|
+
const plan = partPlan.byEnd.get(blockIdx);
|
|
860
|
+
if (plan) {
|
|
861
|
+
pendingSpacing = 0;
|
|
862
|
+
// Deferred until the next placed block so a part that closes the
|
|
863
|
+
// document leaves no trailing empty page behind.
|
|
864
|
+
if (resolved.parts.breakAfter.enabled)
|
|
865
|
+
pendingPartBreak = resolved.parts.breakAfter.parity;
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (pendingPartBreak !== null) {
|
|
870
|
+
const parity = pendingPartBreak;
|
|
871
|
+
pendingPartBreak = null;
|
|
872
|
+
pendingSpacing = 0;
|
|
873
|
+
markForcedBreak();
|
|
874
|
+
leaveCurrentPage();
|
|
875
|
+
enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
|
|
876
|
+
flushPendingNumberingAtBoundary();
|
|
877
|
+
}
|
|
878
|
+
if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'callout') {
|
|
879
|
+
const plan = calloutPlan.get(blockIdx);
|
|
880
|
+
if (plan && pickCalloutStyle(resolved.calloutStyles, plan.attrs.type)) {
|
|
881
|
+
// `span: 'page'` boxes in multi-column layouts branch to the
|
|
882
|
+
// span-block path inside; `placement: 'top' | 'bottom'` still
|
|
883
|
+
// falls back to inline placement (floating boxes pending).
|
|
884
|
+
const rewind = placeCalloutInline(blockIdx, plan);
|
|
885
|
+
// Children were laid out inside the box — skip them in the main loop
|
|
886
|
+
// (the for-loop's `++` lands just past the closing marker).
|
|
887
|
+
blockIdx = rewind !== undefined ? rewind : plan.endIdx;
|
|
888
|
+
flushPendingNumberingAtBoundary();
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (rawBlock.type === 'containerStart' || rawBlock.type === 'containerEnd') {
|
|
893
|
+
const pc = rawBlock.containerId !== undefined
|
|
894
|
+
? paragraphContainers.byId.get(rawBlock.containerId)
|
|
895
|
+
: undefined;
|
|
896
|
+
if (pc) {
|
|
897
|
+
if (rawBlock.type === 'containerStart') {
|
|
898
|
+
pendingSpacing = Math.max(pendingSpacing, pc.marginTopPx);
|
|
899
|
+
}
|
|
900
|
+
else if (contentBlocks[blockIdx - 1]?.type !== 'paragraph') {
|
|
901
|
+
pendingSpacing = Math.max(pendingSpacing, pc.marginBottomPx);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
399
906
|
// --- Heading `breakBefore` ----------------------------------------
|
|
400
907
|
if (rawBlock.type === 'heading' && rawBlock.level) {
|
|
401
908
|
const level = headingLevelByNumber.get(rawBlock.level);
|
|
@@ -422,59 +929,48 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
422
929
|
}
|
|
423
930
|
}
|
|
424
931
|
const id = `block-${blockIdCounter++}`;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
932
|
+
// Enclosing `:::paragraphs` container (if any). Its last paragraph — the
|
|
933
|
+
// one directly before the closing marker — carries the tail style and
|
|
934
|
+
// snaps the flow back onto the baseline grid.
|
|
935
|
+
const paragraphContainer = paragraphContainers.byBlock[blockIdx];
|
|
936
|
+
const nextRaw = contentBlocks[blockIdx + 1];
|
|
937
|
+
const isContainerTail = paragraphContainer !== undefined
|
|
938
|
+
&& nextRaw?.type === 'containerEnd'
|
|
939
|
+
&& nextRaw.containerId === paragraphContainer.id;
|
|
940
|
+
// Measure against the current column width. `null` means there is nothing
|
|
941
|
+
// to place inline (empty text, unknown resource id, floated resource).
|
|
942
|
+
const col = currentColumn(doc, cursor);
|
|
943
|
+
const blockMeasureCtx = partPlan.byBlock[blockIdx] ? partMeasureCtx : measureCtx;
|
|
944
|
+
const styleOverride = paragraphContainer
|
|
945
|
+
? (isContainerTail ? paragraphContainer.tailStyle : paragraphContainer.style)
|
|
946
|
+
: undefined;
|
|
947
|
+
// Column balancing "run a paragraph long": the loose path is taken only
|
|
948
|
+
// for the paragraphs the driver asked for, so the common case keeps its
|
|
949
|
+
// existing measurement cache keys.
|
|
950
|
+
const looseLines = balanceLooseness?.get(blockIdx);
|
|
951
|
+
const budget = balanceLooseBudget?.get(blockIdx);
|
|
952
|
+
// A candidate already known to have gained its line (from an earlier
|
|
953
|
+
// measurement in this pass) keeps it; one whose column met its budget
|
|
954
|
+
// is left as it is. Others walk the ladder and report their outcome.
|
|
955
|
+
const knownOutcome = looseOutcome.get(blockIdx);
|
|
956
|
+
const budgetMet = budget !== undefined
|
|
957
|
+
&& knownOutcome === undefined
|
|
958
|
+
&& (looseGained.get(budget.group) ?? 0) >= budget.need;
|
|
959
|
+
const tryLoose = looseLines !== undefined && !budgetMet && knownOutcome !== null;
|
|
960
|
+
const measuredBlock = tryLoose
|
|
961
|
+
? measureLooseParagraph(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, styleOverride, looseLines, trackingLadder, looseOutcome)
|
|
962
|
+
: measureContentBlock(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, { styleOverride });
|
|
963
|
+
if (tryLoose && budget !== undefined && knownOutcome === undefined && typeof looseOutcome.get(blockIdx) === 'number') {
|
|
964
|
+
looseGained.set(budget.group, (looseGained.get(budget.group) ?? 0) + 1);
|
|
965
|
+
}
|
|
966
|
+
if (!measuredBlock)
|
|
967
|
+
continue;
|
|
968
|
+
const { kind, contentBlock, measured, prefixLen, absoluteSourceMap, mathDisplayRender, letterSpacingPx } = measuredBlock;
|
|
438
969
|
const { style, vdtType, headingLevel, numberPrefix, listBullet, listDepth, listKind, bulletXOffsetInColumn, strikethroughText } = kind;
|
|
439
|
-
let contentBlock = kind.contentBlock;
|
|
440
970
|
// --- Resource blocks (image / svg / table + caption) -----------------
|
|
441
|
-
//
|
|
442
|
-
// for v1. An unknown resource id produces no output (warnings handle it).
|
|
971
|
+
// Placed atomically (kept-together) — no mid-content split for v1.
|
|
443
972
|
if (vdtType === 'resource') {
|
|
444
|
-
|
|
445
|
-
flushPendingNumberingAtBoundary();
|
|
446
|
-
continue;
|
|
447
|
-
}
|
|
448
|
-
// Floated resources are not placed inline at their `::resource`
|
|
449
|
-
// directive — the directive is just an anchor (already enqueued above);
|
|
450
|
-
// the float lands in a page band. Only `position: 'here'` resources fall
|
|
451
|
-
// through to inline placement.
|
|
452
|
-
if (floatedIds.has(kind.resource.id)) {
|
|
453
|
-
flushPendingNumberingAtBoundary();
|
|
454
|
-
continue;
|
|
455
|
-
}
|
|
456
|
-
const rCol = currentColumn(doc, cursor);
|
|
457
|
-
const { resourceBlock, measured } = runMeasurement({
|
|
458
|
-
vdtType,
|
|
459
|
-
rawBlock,
|
|
460
|
-
contentBlock,
|
|
461
|
-
style,
|
|
462
|
-
measureMaxWidth: rCol.bbox.width,
|
|
463
|
-
measureOptions: { textAlign: style.textAlign },
|
|
464
|
-
mathEnabled: resolved.math.enabled,
|
|
465
|
-
useRich: false,
|
|
466
|
-
resolved,
|
|
467
|
-
resources,
|
|
468
|
-
resourceTypes,
|
|
469
|
-
resourceNumbering,
|
|
470
|
-
resource: kind.resource,
|
|
471
|
-
resourceType: kind.resourceType,
|
|
472
|
-
resourceNumber: kind.resourceNumber,
|
|
473
|
-
});
|
|
474
|
-
if (!resourceBlock) {
|
|
475
|
-
flushPendingNumberingAtBoundary();
|
|
476
|
-
continue;
|
|
477
|
-
}
|
|
973
|
+
const resourceBlock = measuredBlock.resourceBlock;
|
|
478
974
|
const groupHeight = measured.totalHeight;
|
|
479
975
|
const blk = createVDTBlock(id, 'resource', style.fontString, style.color, style.textAlign);
|
|
480
976
|
blk.contentIndex = blockIdx;
|
|
@@ -494,8 +990,10 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
494
990
|
isLastLine: true,
|
|
495
991
|
}];
|
|
496
992
|
const spacingBefore = pendingSpacing;
|
|
497
|
-
|
|
498
|
-
|
|
993
|
+
enterBand(blockIdx, 0);
|
|
994
|
+
placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
995
|
+
enterBand(blockIdx, 0);
|
|
996
|
+
// `placeBlockInColumn` (inside placeAtomicBlock) shifts `blk.lines`; the
|
|
499
997
|
// resource's own caption/table lines live on `resourceBlock` and must be
|
|
500
998
|
// offset to absolute page coordinates here using the placed bbox origin.
|
|
501
999
|
offsetResourceBlockToAbsolute(resourceBlock, blk.bbox.x, blk.bbox.y);
|
|
@@ -515,75 +1013,6 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
515
1013
|
flushPendingNumberingAtBoundary();
|
|
516
1014
|
continue;
|
|
517
1015
|
}
|
|
518
|
-
// Measure text — use rich measurement for blocks with bold spans
|
|
519
|
-
const col = currentColumn(doc, cursor);
|
|
520
|
-
// Resolve inline math on spans (no-op when the block has no math).
|
|
521
|
-
const mathEnabled = resolved.math.enabled;
|
|
522
|
-
contentBlock = enrichMathSpans(contentBlock, style, resolved);
|
|
523
|
-
// Resolve inline `:ref{…}` spans to their computed label so references
|
|
524
|
-
// print their number in the running text. Each label becomes one atomic,
|
|
525
|
-
// non-breaking token tagged with its `refResourceId` (handled by the
|
|
526
|
-
// rich-text measurer), so we always take the rich path for ref blocks.
|
|
527
|
-
if (contentBlock.spans.some((s) => s.ref)) {
|
|
528
|
-
contentBlock = {
|
|
529
|
-
...contentBlock,
|
|
530
|
-
spans: resolveRefSpans(contentBlock.spans, resourceNumbering, resourceTypes, resources, {
|
|
531
|
-
bold: bodyStyle.referenceBold ?? true,
|
|
532
|
-
italic: bodyStyle.referenceItalic ?? false,
|
|
533
|
-
}),
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
const hasRichSpans = contentBlock.spans.some((s) => s.bold || s.italic || s.mathRender || s.ref);
|
|
537
|
-
// List items reserve horizontal space for indent + bullet + gap.
|
|
538
|
-
const { measureMaxWidth, lineXShift, measureFirstLineIndent, measureHangingIndent, } = computeMeasureViewport(col.bbox.width, style, listBullet);
|
|
539
|
-
// First-paragraph-after-heading: typographic convention used in many
|
|
540
|
-
// scientific publications and book styles where the paragraph that
|
|
541
|
-
// immediately follows a heading is rendered without first-line indent.
|
|
542
|
-
// Only applies to regular paragraphs without hanging indent; list items
|
|
543
|
-
// and hanging-indent paragraphs are unaffected.
|
|
544
|
-
let effectiveFirstLineIndent = measureFirstLineIndent;
|
|
545
|
-
if (vdtType === 'paragraph'
|
|
546
|
-
&& !resolved.bodyText.indentAfterHeading
|
|
547
|
-
&& !resolved.bodyText.hangingIndent
|
|
548
|
-
&& blockIdx > 0) {
|
|
549
|
-
let prevIdx = blockIdx - 1;
|
|
550
|
-
while (prevIdx >= 0 && contentBlocks[prevIdx].type === 'directive')
|
|
551
|
-
prevIdx--;
|
|
552
|
-
if (prevIdx >= 0 && contentBlocks[prevIdx].type === 'heading') {
|
|
553
|
-
effectiveFirstLineIndent = 0;
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
const runtActive = resolved.bodyText.avoidRunts
|
|
557
|
-
&& (vdtType === 'paragraph'
|
|
558
|
-
|| (vdtType === 'listItem' && resolved.bodyText.avoidRuntsInLists));
|
|
559
|
-
const measureOptions = {
|
|
560
|
-
textAlign: style.textAlign,
|
|
561
|
-
hyphenate: style.hyphenate,
|
|
562
|
-
firstLineIndentPx: effectiveFirstLineIndent,
|
|
563
|
-
hangingIndent: measureHangingIndent,
|
|
564
|
-
optimal: resolved.bodyText.optimalLineBreaking,
|
|
565
|
-
maxStretchRatio: resolved.bodyText.maxWordSpacing,
|
|
566
|
-
minShrinkRatio: resolved.bodyText.minWordSpacing,
|
|
567
|
-
runtPenalty: runtActive ? resolved.bodyText.runtPenalty : 0,
|
|
568
|
-
runtMinCharacters: runtActive ? resolved.bodyText.runtMinCharacters : 0,
|
|
569
|
-
// Column balancing "run a paragraph long": stays undefined for the
|
|
570
|
-
// common case so existing measurement cache keys are preserved.
|
|
571
|
-
looseness: balanceLooseness?.get(blockIdx),
|
|
572
|
-
};
|
|
573
|
-
const useRich = !!(hasRichSpans && style.boldFontString && style.italicFontString && style.boldItalicFontString);
|
|
574
|
-
const { measured, mathDisplayRender } = runMeasurement({
|
|
575
|
-
vdtType, rawBlock, contentBlock, style, measureMaxWidth, measureOptions, mathEnabled, useRich, cache,
|
|
576
|
-
});
|
|
577
|
-
if (measured.lines.length === 0)
|
|
578
|
-
continue;
|
|
579
|
-
if (lineXShift > 0) {
|
|
580
|
-
for (const line of measured.lines) {
|
|
581
|
-
line.bbox.x += lineXShift;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
// Per-line source-range mapping using the block's plain→source map.
|
|
585
|
-
// Accounts for heading numbering prefix which prepends chars with no source.
|
|
586
|
-
const { prefixLen, absoluteSourceMap } = stampSourceRanges(measured, rawBlock, contentBlock, bodyOffset);
|
|
587
1016
|
const finalizeListItem = (blk, isFirstPart) => {
|
|
588
1017
|
if (!listBullet)
|
|
589
1018
|
return;
|
|
@@ -600,6 +1029,12 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
600
1029
|
// `indentPx + (maxNumberWidth - thisNumberWidth)` for ordered — giving
|
|
601
1030
|
// the right-aligned separator.
|
|
602
1031
|
blk.bulletOffsetX = blk.bbox.x + bulletXOffsetInColumn;
|
|
1032
|
+
if (listBullet.separatorText !== undefined) {
|
|
1033
|
+
blk.separatorText = listBullet.separatorText;
|
|
1034
|
+
blk.separatorFontString = listBullet.separatorFontString;
|
|
1035
|
+
blk.separatorColor = listBullet.separatorColor;
|
|
1036
|
+
blk.separatorX = blk.bulletOffsetX + (listBullet.separatorOffsetPx ?? 0);
|
|
1037
|
+
}
|
|
603
1038
|
if (strikethroughText)
|
|
604
1039
|
blk.strikethroughText = true;
|
|
605
1040
|
// Bullet Y = x-height midpoint of the item's first text line.
|
|
@@ -610,17 +1045,21 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
610
1045
|
blk.bulletY = firstLine.baseline - listBullet.textFontSizePx * 0.3 + listBullet.verticalOffsetPx;
|
|
611
1046
|
}
|
|
612
1047
|
};
|
|
613
|
-
|
|
1048
|
+
// Neighbour lookaheads see through container markers.
|
|
1049
|
+
const nextBlock = nextNonMarkerBlock(contentBlocks, blockIdx) ?? null;
|
|
1050
|
+
const nextIsListItem = nextBlock?.type === 'listItem';
|
|
614
1051
|
// For headings, only snap to baseline grid if the next block is NOT a heading.
|
|
615
1052
|
// Consecutive headings flow without grid snapping; the last heading in the
|
|
616
1053
|
// group snaps so that the following body text realigns with the grid.
|
|
617
1054
|
// Same rule for list items: the LAST item of a list snaps so that text
|
|
618
1055
|
// after the list realigns with the baseline grid, even when non-grid
|
|
619
|
-
// spacings (itemSpacing, marginTop/Bottom) were chosen.
|
|
620
|
-
|
|
1056
|
+
// spacings (itemSpacing, marginTop/Bottom) were chosen. And for the last
|
|
1057
|
+
// paragraph of a `:::paragraphs` container, whose leading and spacing
|
|
1058
|
+
// are off-grid by design.
|
|
621
1059
|
const nextIsHeading = nextBlock?.type === 'heading';
|
|
622
1060
|
const shouldSnapToGrid = (vdtType === 'heading' && !nextIsHeading) ||
|
|
623
1061
|
(vdtType === 'listItem' && !nextIsListItem) ||
|
|
1062
|
+
(vdtType === 'paragraph' && isContainerTail) ||
|
|
624
1063
|
vdtType === 'mathDisplay';
|
|
625
1064
|
// Place block, splitting across columns/pages if needed.
|
|
626
1065
|
// List items may split too — orphan/widow protection per-list is gated by
|
|
@@ -638,6 +1077,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
638
1077
|
&& nextIsListItem
|
|
639
1078
|
&& /:\s*$/.test(contentBlock.text);
|
|
640
1079
|
while (remainingLines.length > 0) {
|
|
1080
|
+
enterBand(blockIdx, partIndex);
|
|
641
1081
|
const curCol = currentColumn(doc, cursor);
|
|
642
1082
|
const isFirstInColumn = curCol.blocks.length === 0;
|
|
643
1083
|
// Compute spacing before this block — margin collapsing between
|
|
@@ -660,7 +1100,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
660
1100
|
}
|
|
661
1101
|
}
|
|
662
1102
|
else if (vdtType === 'listItem') {
|
|
663
|
-
const prevWasList =
|
|
1103
|
+
const prevWasList = prevNonMarkerBlock(contentBlocks, blockIdx)?.type === 'listItem';
|
|
664
1104
|
if (!prevWasList) {
|
|
665
1105
|
spacingBefore = Math.max(spacingBefore, style.marginTopPx);
|
|
666
1106
|
}
|
|
@@ -693,11 +1133,15 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
693
1133
|
.map((ln) => (ln.segments ?? []).map((s) => s.text).join(''))
|
|
694
1134
|
.join(' ');
|
|
695
1135
|
const pref = numberPrefix ?? '';
|
|
696
|
-
const title = pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full;
|
|
1136
|
+
const title = applyTitleBreaks(pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full, rawBlock.titleBreaks, rawBlock.text.length);
|
|
697
1137
|
// Span-page openers lay out across the full content area (both
|
|
698
1138
|
// columns); in-column headings use just the column width.
|
|
699
|
-
const
|
|
700
|
-
const
|
|
1139
|
+
const pageArea = doc.pages[cursor.pageIndex].contentArea;
|
|
1140
|
+
const measureWidth = lvl.span === 'page' ? pageArea.width : curCol.bbox.width;
|
|
1141
|
+
const designBottom = measureHeadingAdvancedDesignHeight(lvl, { titleText: title, formattedNumber: pref, chapterNumber: pref, attrs: rawBlock.attrs }, measureWidth, resolved.page.dpi, doc.metadata, cursor.pageIndex, designFrames, {
|
|
1142
|
+
x: lvl.span === 'page' ? pageArea.x : curCol.bbox.x,
|
|
1143
|
+
y: curCol.bbox.y + (curCol.bbox.height - curCol.availableHeight) + spacingBefore,
|
|
1144
|
+
});
|
|
701
1145
|
if (designBottom > effectiveRemainHeight)
|
|
702
1146
|
effectiveRemainHeight = designBottom;
|
|
703
1147
|
}
|
|
@@ -732,6 +1176,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
732
1176
|
const splitLines = remainingLines.slice(0, splitAt);
|
|
733
1177
|
const blk = createVDTBlock(id, vdtType, style.fontString, style.color, style.textAlign);
|
|
734
1178
|
applyStyleAttrs(blk, style);
|
|
1179
|
+
if (letterSpacingPx !== undefined)
|
|
1180
|
+
blk.letterSpacing = letterSpacingPx;
|
|
735
1181
|
blk.contentIndex = blockIdx;
|
|
736
1182
|
blk.headingLevel = headingLevel;
|
|
737
1183
|
if (numberPrefix)
|
|
@@ -762,7 +1208,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
762
1208
|
let headingRunCount = 0;
|
|
763
1209
|
if (resolved.headings.keepWithNext) {
|
|
764
1210
|
for (let j = curCol.blocks.length - 1; j >= 0; j--) {
|
|
765
|
-
if (curCol.blocks[j].type === 'heading')
|
|
1211
|
+
if (curCol.blocks[j].type === 'heading' && curCol.blocks[j].containerId === undefined)
|
|
766
1212
|
headingRunCount++;
|
|
767
1213
|
else
|
|
768
1214
|
break;
|
|
@@ -776,7 +1222,9 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
776
1222
|
doc.blocks.splice(idx, 1);
|
|
777
1223
|
curCol.availableHeight += p.bbox.height;
|
|
778
1224
|
}
|
|
779
|
-
blockIdx
|
|
1225
|
+
// Rewind so the for-loop's blockIdx++ lands on the first
|
|
1226
|
+
// rolled-back heading (marker blocks in between are replayed).
|
|
1227
|
+
blockIdx = (popped[0].contentIndex ?? blockIdx - headingRunCount) - 1;
|
|
780
1228
|
pendingSpacing = 0;
|
|
781
1229
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
782
1230
|
break;
|
|
@@ -819,11 +1267,11 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
819
1267
|
if (remainAfterHeading < minSpaceAfter) {
|
|
820
1268
|
// Roll back any immediately-preceding heading blocks in this
|
|
821
1269
|
// column so they travel with this one.
|
|
822
|
-
const
|
|
823
|
-
if (
|
|
1270
|
+
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
1271
|
+
if (rolledBack.length > 0) {
|
|
824
1272
|
// Rewind so the for-loop's blockIdx++ lands on the first
|
|
825
|
-
// rolled-back heading.
|
|
826
|
-
blockIdx
|
|
1273
|
+
// rolled-back heading (marker blocks in between are replayed).
|
|
1274
|
+
blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
|
|
827
1275
|
pendingSpacing = 0;
|
|
828
1276
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
829
1277
|
break;
|
|
@@ -840,12 +1288,28 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
840
1288
|
const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
|
|
841
1289
|
const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
|
|
842
1290
|
applyStyleAttrs(blk, style);
|
|
1291
|
+
if (letterSpacingPx !== undefined)
|
|
1292
|
+
blk.letterSpacing = letterSpacingPx;
|
|
843
1293
|
blk.contentIndex = blockIdx;
|
|
844
1294
|
if (partIndex === 0) {
|
|
845
1295
|
blk.headingLevel = headingLevel;
|
|
846
1296
|
if (numberPrefix)
|
|
847
1297
|
blk.numberPrefix = numberPrefix;
|
|
848
1298
|
}
|
|
1299
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrs)
|
|
1300
|
+
blk.attrs = rawBlock.attrs;
|
|
1301
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrSources) {
|
|
1302
|
+
// Parser ranges are body-relative; VDT source offsets are absolute.
|
|
1303
|
+
blk.attrSources = Object.fromEntries(Object.entries(rawBlock.attrSources).map(([k, r]) => [k, { start: r.start + bodyOffset, end: r.end + bodyOffset }]));
|
|
1304
|
+
}
|
|
1305
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
|
|
1306
|
+
blk.titleBreaks = rawBlock.titleBreaks;
|
|
1307
|
+
blk.titleLength = rawBlock.text.length;
|
|
1308
|
+
}
|
|
1309
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
|
|
1310
|
+
blk.titleBreaks = rawBlock.titleBreaks;
|
|
1311
|
+
blk.titleLength = rawBlock.text.length;
|
|
1312
|
+
}
|
|
849
1313
|
if (vdtType === 'mathDisplay' && mathDisplayRender) {
|
|
850
1314
|
blk.mathRender = mathDisplayRender;
|
|
851
1315
|
blk.tex = rawBlock.tex;
|
|
@@ -935,6 +1399,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
935
1399
|
const splitLines = remainingLines.slice(0, choice.splitAt);
|
|
936
1400
|
const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
|
|
937
1401
|
applyStyleAttrs(blk, style);
|
|
1402
|
+
if (letterSpacingPx !== undefined)
|
|
1403
|
+
blk.letterSpacing = letterSpacingPx;
|
|
938
1404
|
blk.contentIndex = blockIdx;
|
|
939
1405
|
if (partIndex === 0) {
|
|
940
1406
|
blk.headingLevel = headingLevel;
|
|
@@ -969,9 +1435,9 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
969
1435
|
// pull those headings along so they don't remain stranded as orphans
|
|
970
1436
|
// at the column's bottom. Mirrors the rollback inside the "fits" path.
|
|
971
1437
|
if (vdtType === 'heading' && resolved.headings.keepWithNext) {
|
|
972
|
-
const
|
|
973
|
-
if (
|
|
974
|
-
blockIdx
|
|
1438
|
+
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
1439
|
+
if (rolledBack.length > 0) {
|
|
1440
|
+
blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
|
|
975
1441
|
pendingSpacing = 0;
|
|
976
1442
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
977
1443
|
break;
|
|
@@ -985,9 +1451,21 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
985
1451
|
const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
|
|
986
1452
|
const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
|
|
987
1453
|
applyStyleAttrs(blk, style);
|
|
1454
|
+
if (letterSpacingPx !== undefined)
|
|
1455
|
+
blk.letterSpacing = letterSpacingPx;
|
|
988
1456
|
blk.contentIndex = blockIdx;
|
|
989
1457
|
if (partIndex === 0)
|
|
990
1458
|
blk.headingLevel = headingLevel;
|
|
1459
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrs)
|
|
1460
|
+
blk.attrs = rawBlock.attrs;
|
|
1461
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrSources) {
|
|
1462
|
+
// Parser ranges are body-relative; VDT source offsets are absolute.
|
|
1463
|
+
blk.attrSources = Object.fromEntries(Object.entries(rawBlock.attrSources).map(([k, r]) => [k, { start: r.start + bodyOffset, end: r.end + bodyOffset }]));
|
|
1464
|
+
}
|
|
1465
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
|
|
1466
|
+
blk.titleBreaks = rawBlock.titleBreaks;
|
|
1467
|
+
blk.titleLength = rawBlock.text.length;
|
|
1468
|
+
}
|
|
991
1469
|
blk.lines = resetLinePositions(remainingLines, style.lineHeightPx);
|
|
992
1470
|
blk.dirty = false;
|
|
993
1471
|
blk.snappedToGrid = false;
|
|
@@ -1027,10 +1505,19 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
1027
1505
|
buildHeadersAndFooters(doc);
|
|
1028
1506
|
doc.converged = true;
|
|
1029
1507
|
doc.iterationCount = 1;
|
|
1030
|
-
return { doc, forcedBreakPages };
|
|
1508
|
+
return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied, looseOutcome };
|
|
1031
1509
|
}
|
|
1032
1510
|
export function buildDocument(content, config, cache, options) {
|
|
1033
|
-
|
|
1511
|
+
// --- Band caps (page-span blocks mid-page) -----------------------------
|
|
1512
|
+
// A span block that arrived in an uneven band proposes a cap; the driver
|
|
1513
|
+
// re-places the document with it (and grows / drops caps whose band
|
|
1514
|
+
// overflowed) before balancing runs. Documents without such blocks get
|
|
1515
|
+
// their first pass back untouched — no extra pass.
|
|
1516
|
+
const bands = resolveBandCaps(buildDocumentPass(content, config, cache, options), (bandCaps) => buildDocumentPass(content, config, cache, options, { bandCaps }));
|
|
1517
|
+
let best = bands.result;
|
|
1518
|
+
const bandCaps = bands.bandCaps;
|
|
1519
|
+
let passCount = bands.passCount;
|
|
1520
|
+
best.doc.iterationCount = passCount;
|
|
1034
1521
|
// --- Column balancing (vertical justification) ------------------------
|
|
1035
1522
|
// Iteratively re-place the document with extra grid lines above headings
|
|
1036
1523
|
// until every balanceable column ends flush with the page bottom (or no
|
|
@@ -1045,7 +1532,6 @@ export function buildDocument(content, config, cache, options) {
|
|
|
1045
1532
|
let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
|
|
1046
1533
|
let applied = { lines: new Map(), loose: new Map() };
|
|
1047
1534
|
const failedLoose = new Set();
|
|
1048
|
-
let passCount = 1;
|
|
1049
1535
|
let converged = bestScore === 0;
|
|
1050
1536
|
while (!converged && passCount < MAX_BALANCING_PASSES) {
|
|
1051
1537
|
const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
|
|
@@ -1053,6 +1539,7 @@ export function buildDocument(content, config, cache, options) {
|
|
|
1053
1539
|
stretchAfterLists: balancing.stretchAfterLists,
|
|
1054
1540
|
maxLinesAfterList: balancing.maxLinesAfterList,
|
|
1055
1541
|
looseParagraphs: balancing.looseParagraphs,
|
|
1542
|
+
maxLooseParagraphs: balancing.maxLooseParagraphs,
|
|
1056
1543
|
optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
|
|
1057
1544
|
failedLoose,
|
|
1058
1545
|
});
|
|
@@ -1064,24 +1551,44 @@ export function buildDocument(content, config, cache, options) {
|
|
|
1064
1551
|
const extraPx = new Map();
|
|
1065
1552
|
for (const [idx, n] of proposal.lines)
|
|
1066
1553
|
extraPx.set(idx, n * best.doc.baselineGrid);
|
|
1067
|
-
const next = buildDocumentPass(content, config, cache, options,
|
|
1554
|
+
const next = buildDocumentPass(content, config, cache, options, {
|
|
1555
|
+
balanceExtraPx: extraPx,
|
|
1556
|
+
balanceLooseness: proposal.loose,
|
|
1557
|
+
balanceLooseBudget: proposal.looseBudget,
|
|
1558
|
+
bandCaps,
|
|
1559
|
+
});
|
|
1068
1560
|
passCount++;
|
|
1069
|
-
|
|
1561
|
+
// Band caps ride along unchanged; a retry that unsettles one (its span
|
|
1562
|
+
// block no longer lands in the capped band) counts as a regression —
|
|
1563
|
+
// capped columns without their box are not a layout we may keep.
|
|
1564
|
+
const capsDelivered = [...bandCaps.keys()].every((i) => next.spanPlacedInBand.has(i));
|
|
1565
|
+
const score = capsDelivered ? totalGapLines(next.doc, next.forcedBreakPages) : Infinity;
|
|
1566
|
+
// Loose paragraphs that gained no line at any tracking rung are
|
|
1567
|
+
// blacklisted whatever the score did, and never counted as applied.
|
|
1568
|
+
// Candidates the pass never tried (their column's budget was met
|
|
1569
|
+
// first) stay eligible for a later proposal.
|
|
1570
|
+
const newlyLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
|
|
1571
|
+
const looseFailed = newlyLoose.filter((k) => next.looseOutcome.get(k) === null);
|
|
1572
|
+
for (const k of looseFailed)
|
|
1573
|
+
failedLoose.add(k);
|
|
1574
|
+
const looseWon = newlyLoose.filter((k) => typeof next.looseOutcome.get(k) === 'number');
|
|
1070
1575
|
if (score < bestScore) {
|
|
1071
1576
|
best = next;
|
|
1072
1577
|
bestScore = score;
|
|
1073
|
-
applied = {
|
|
1578
|
+
applied = {
|
|
1579
|
+
lines: proposal.lines,
|
|
1580
|
+
loose: new Map([...proposal.loose].filter(([k]) => applied.loose.has(k) || looseWon.includes(k))),
|
|
1581
|
+
};
|
|
1074
1582
|
converged = score === 0;
|
|
1075
1583
|
}
|
|
1076
1584
|
else {
|
|
1077
|
-
// Plateau or regression.
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
// the
|
|
1585
|
+
// Plateau or regression. Retry when a loose candidate was just
|
|
1586
|
+
// blacklisted (the proposer falls through to the next one), or when
|
|
1587
|
+
// the new loose paragraphs gained their lines yet the layout did not
|
|
1588
|
+
// improve (the gain landed elsewhere — drop them too). A pure spacing
|
|
1081
1589
|
// plateau means we're done: keep the best layout found so far.
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
for (const k of newlyLoose)
|
|
1590
|
+
if (looseFailed.length > 0 || looseWon.length > 0) {
|
|
1591
|
+
for (const k of looseWon)
|
|
1085
1592
|
failedLoose.add(k);
|
|
1086
1593
|
continue;
|
|
1087
1594
|
}
|