postext 0.3.18 → 0.3.20
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 +183 -5
- 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__/knuthPlass.test.js +60 -0
- package/dist/__tests__/knuthPlass.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__/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/canvas-backend/blockRender.d.ts.map +1 -1
- package/dist/canvas-backend/blockRender.js +13 -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/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 +71 -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 +34 -0
- package/dist/knuthPlass/breakpoints.js.map +1 -1
- package/dist/knuthPlass/types.d.ts +7 -0
- package/dist/knuthPlass/types.d.ts.map +1 -1
- package/dist/measure/cache.js +2 -2
- package/dist/measure/cache.js.map +1 -1
- package/dist/measure/plain.d.ts.map +1 -1
- package/dist/measure/plain.js +1 -0
- package/dist/measure/plain.js.map +1 -1
- package/dist/measure/rich.d.ts.map +1 -1
- package/dist/measure/rich.js +1 -0
- 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 +32 -0
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +621 -162
- 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 +54 -10
- package/dist/pipeline/columnBalancing.d.ts.map +1 -1
- package/dist/pipeline/columnBalancing.js +136 -22
- 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 +59 -0
- package/dist/pipeline/measureContentBlock.d.ts.map +1 -0
- package/dist/pipeline/measureContentBlock.js +124 -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 +466 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +128 -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
|
-
import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES } from './columnBalancing';
|
|
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,35 @@ const ALLOWED_PAGE_FORMATS = new Set([
|
|
|
33
37
|
'upper-alpha',
|
|
34
38
|
]);
|
|
35
39
|
/**
|
|
36
|
-
* Single placement pass. `
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
40
|
+
* Single placement pass. `hints` carries the cross-pass adjustments: the
|
|
41
|
+
* column-balancing spacing / looseness and the band caps of page-span
|
|
42
|
+
* blocks. Besides the document it reports `forcedBreakPages` (for
|
|
43
|
+
* balancing) and the band-cap bookkeeping the driver in `buildDocument`
|
|
44
|
+
* needs — new cap proposals, the span blocks that landed in their capped
|
|
45
|
+
* band, and the caps that were actually applied.
|
|
46
|
+
*
|
|
47
|
+
* @internal Exposed for tests only; use `buildDocument`.
|
|
41
48
|
*/
|
|
42
|
-
function buildDocumentPass(content, config, cache, options,
|
|
49
|
+
export function buildDocumentPass(content, config, cache, options, hints = {}) {
|
|
50
|
+
const { balanceExtraPx, balanceLooseness, bandCaps } = hints;
|
|
43
51
|
const resolved = resolveAllConfig(config);
|
|
44
52
|
const headingLevelByNumber = buildHeadingLevelMap(resolved);
|
|
45
53
|
const dpi = resolved.page.dpi;
|
|
46
|
-
// Initialize hyphenator if needed
|
|
47
|
-
|
|
54
|
+
// Initialize hyphenator if needed (body text, or any justified paragraph
|
|
55
|
+
// style that hyphenates — they share the document locale).
|
|
56
|
+
const needsHyphenator = (resolved.bodyText.hyphenation.enabled && resolved.bodyText.textAlign === 'justify')
|
|
57
|
+
|| resolved.paragraphStyles.some((s) => s.hyphenation && s.textAlign === 'justify');
|
|
58
|
+
if (needsHyphenator) {
|
|
48
59
|
initHyphenator(resolved.bodyText.hyphenation.locale);
|
|
49
60
|
}
|
|
50
61
|
// Compute baseline grid
|
|
51
62
|
const baselineGrid = computeBaselineGrid(resolved);
|
|
52
63
|
// Create document
|
|
53
64
|
const doc = createVDTDocument(resolved, baselineGrid);
|
|
54
|
-
const
|
|
65
|
+
const pageMetrics = computePageMetrics(resolved);
|
|
66
|
+
const { pageWidthPx, pageHeightPx, trimOffset, contentArea } = pageMetrics;
|
|
67
|
+
// Page/bleed frames for design elements anchored to `'page'` / `'bleed'`.
|
|
68
|
+
const designFrames = { page: pageMetrics.trimBox, bleed: pageMetrics.bleedBox };
|
|
55
69
|
doc.trimOffset = trimOffset;
|
|
56
70
|
// Create first page
|
|
57
71
|
const firstPage = createPageWithColumns(0, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
@@ -92,6 +106,12 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
92
106
|
const listLevelIndentsPx = computeLevelIndentsPx(resolved, bodyStyle.fontSizePx);
|
|
93
107
|
const orderedMetrics = computeOrderedListRunMetrics(contentBlocks, resolved, bodyStyle.fontSizePx);
|
|
94
108
|
const orderedLevelIndentsPx = computeOrderedLevelIndentsPx(resolved, bodyStyle.fontSizePx, orderedMetrics.maxWidthByDepth);
|
|
109
|
+
// `:::paragraphs{style="…"}` containers, resolved per content-block index.
|
|
110
|
+
const paragraphContainers = planParagraphContainers(contentBlocks, resolved);
|
|
111
|
+
// `:::callout` ranges keyed by their start marker index (same rationale).
|
|
112
|
+
const calloutPlan = planCallouts(contentBlocks);
|
|
113
|
+
// `:::part` ranges: start/end marker indices and the enclosed blocks.
|
|
114
|
+
const partPlan = planParts(contentBlocks);
|
|
95
115
|
// --- Float planning (issue #49 — resources float to page bands) ----------
|
|
96
116
|
// A resource is incorporated by its first reference (an inline `:ref` or a
|
|
97
117
|
// `::resource` directive, whichever comes first in reading order). Floated
|
|
@@ -122,6 +142,15 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
122
142
|
ln.bbox.y += oy;
|
|
123
143
|
ln.baseline += oy;
|
|
124
144
|
}
|
|
145
|
+
for (const ln of rb.noteLines) {
|
|
146
|
+
ln.bbox.x += ox;
|
|
147
|
+
ln.bbox.y += oy;
|
|
148
|
+
ln.baseline += oy;
|
|
149
|
+
}
|
|
150
|
+
if (rb.captionBar) {
|
|
151
|
+
rb.captionBar.rect.x += ox;
|
|
152
|
+
rb.captionBar.rect.y += oy;
|
|
153
|
+
}
|
|
125
154
|
if (rb.table) {
|
|
126
155
|
for (const cell of rb.table.cells) {
|
|
127
156
|
cell.rect.x += ox;
|
|
@@ -190,8 +219,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
190
219
|
targetCols = [best];
|
|
191
220
|
}
|
|
192
221
|
const firstCol = page.columns[targetCols[0]];
|
|
193
|
-
const width = pageSpan ? contentArea.width : firstCol.bbox.width;
|
|
194
|
-
const xLeft = pageSpan ? contentArea.x : firstCol.bbox.x;
|
|
222
|
+
const width = pageSpan ? page.contentArea.width : firstCol.bbox.width;
|
|
223
|
+
const xLeft = pageSpan ? page.contentArea.x : firstCol.bbox.x;
|
|
195
224
|
const built = buildFloatBlock(f.resourceId, xLeft, width);
|
|
196
225
|
if (!built)
|
|
197
226
|
return 'skip';
|
|
@@ -225,8 +254,8 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
225
254
|
const cb = page.columns[c].bbox;
|
|
226
255
|
return cb.y + cb.height;
|
|
227
256
|
}));
|
|
228
|
-
const gridAlignedBottom = contentArea.y
|
|
229
|
-
+ Math.floor((bottomLimit - contentArea.y + 0.01) / baselineGrid) * baselineGrid;
|
|
257
|
+
const gridAlignedBottom = page.contentArea.y
|
|
258
|
+
+ Math.floor((bottomLimit - page.contentArea.y + 0.01) / baselineGrid) * baselineGrid;
|
|
230
259
|
const capLines = built.block.resourceBlock.captionLines;
|
|
231
260
|
if (capLines.length > 0) {
|
|
232
261
|
// Body baselines sit at 0.2 × grid above each slot bottom; anchor
|
|
@@ -313,6 +342,30 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
313
342
|
/** Reserve floats on each freshly opened content page. Passed only to the
|
|
314
343
|
* content-flow column advances — parity / force-blank pages never get it. */
|
|
315
344
|
const onNewPage = (page) => flushFloatsIntoPage(page);
|
|
345
|
+
// Everything per-block measurement needs that is constant for this pass.
|
|
346
|
+
const measureCtx = {
|
|
347
|
+
resolved,
|
|
348
|
+
bodyStyle,
|
|
349
|
+
blockquoteStyle,
|
|
350
|
+
headingPrefixes,
|
|
351
|
+
listLevelIndentsPx,
|
|
352
|
+
orderedLevelIndentsPx,
|
|
353
|
+
orderedMetrics,
|
|
354
|
+
resourceById,
|
|
355
|
+
resourceTypeById,
|
|
356
|
+
resourceNumberById,
|
|
357
|
+
contentBlocks,
|
|
358
|
+
cache,
|
|
359
|
+
bodyOffset,
|
|
360
|
+
resources,
|
|
361
|
+
resourceTypes,
|
|
362
|
+
resourceNumbering,
|
|
363
|
+
floatedIds,
|
|
364
|
+
};
|
|
365
|
+
// Blocks inside a `:::part` measure with the part body typography.
|
|
366
|
+
const partMeasureCtx = partPlan.byStart.size > 0
|
|
367
|
+
? derivePartMeasureContext(measureCtx)
|
|
368
|
+
: measureCtx;
|
|
316
369
|
// Placement cursor
|
|
317
370
|
const cursor = { pageIndex: 0, columnIndex: 0 };
|
|
318
371
|
let blockIdCounter = 0;
|
|
@@ -327,6 +380,60 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
327
380
|
forcedBreakPages.add(cursor.pageIndex);
|
|
328
381
|
}
|
|
329
382
|
};
|
|
383
|
+
// --- Column bands: opening block + band caps (span blocks, stage 2) ----
|
|
384
|
+
// The band the cursor last placed into, its opening block (content index
|
|
385
|
+
// + part: a paragraph split across pages opens the next page's band with
|
|
386
|
+
// its continuation), and the cap applied to it, if any. `enterBand` runs
|
|
387
|
+
// at every placement site right before a block is offered to the current
|
|
388
|
+
// column: the first block offered to a band is its opening block, and if
|
|
389
|
+
// a cap names it the band's columns are shortened to the cap BEFORE the
|
|
390
|
+
// block is placed, so the fit / split / keep-with-next rules see the
|
|
391
|
+
// capped height. Atomic placements that advance internally re-run it
|
|
392
|
+
// after landing (the cap then trims whatever the block left).
|
|
393
|
+
let registeredBand = null;
|
|
394
|
+
let bandStart = null;
|
|
395
|
+
let activeCap = null;
|
|
396
|
+
/** True bottoms of capped columns (restored when the span block cuts). */
|
|
397
|
+
const uncappedBottoms = new Map();
|
|
398
|
+
const bandCapProposals = new Map();
|
|
399
|
+
const spanPlacedInBand = new Set();
|
|
400
|
+
const bandCapsApplied = new Set();
|
|
401
|
+
const enterBand = (contentIndex, part) => {
|
|
402
|
+
const page = doc.pages[cursor.pageIndex];
|
|
403
|
+
const band = currentBand(page, cursor);
|
|
404
|
+
if (registeredBand && registeredBand.pageIndex === page.index && registeredBand.band === band)
|
|
405
|
+
return;
|
|
406
|
+
registeredBand = { pageIndex: page.index, band };
|
|
407
|
+
bandStart = { contentIndex, part };
|
|
408
|
+
activeCap = null;
|
|
409
|
+
if (!bandCaps)
|
|
410
|
+
return;
|
|
411
|
+
for (const [spanIndex, cap] of bandCaps) {
|
|
412
|
+
if (cap.startContentIndex !== contentIndex || cap.startPart !== part)
|
|
413
|
+
continue;
|
|
414
|
+
applyBandCap(bandColumns(page, band), cap.lines * baselineGrid, uncappedBottoms);
|
|
415
|
+
activeCap = { spanIndex, pageIndex: page.index, band };
|
|
416
|
+
bandCapsApplied.add(spanIndex);
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
/** Parity of the page break a closed `:::part` still owes (applied before
|
|
421
|
+
* the next placed block). */
|
|
422
|
+
let pendingPartBreak = null;
|
|
423
|
+
/** `advanceToNextPageBoundary`, except that an empty part page is left
|
|
424
|
+
* behind too (its opener design is content). No floats are reserved on
|
|
425
|
+
* the page opened this way — parity padding may still follow it. */
|
|
426
|
+
const leaveCurrentPage = () => {
|
|
427
|
+
const curPage = doc.pages[cursor.pageIndex];
|
|
428
|
+
if (curPage.partInfo && !pageHasContent(curPage)) {
|
|
429
|
+
const startPageIndex = cursor.pageIndex;
|
|
430
|
+
do {
|
|
431
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
432
|
+
} while (cursor.pageIndex === startPageIndex);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
436
|
+
};
|
|
330
437
|
// Page-numbering segments. The implicit first segment comes from
|
|
331
438
|
// `cfg.page.pageNumbering`; `:::numbering` directives append more,
|
|
332
439
|
// each applied at the next page boundary.
|
|
@@ -353,6 +460,262 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
353
460
|
lastSeenPageIndex = cursor.pageIndex;
|
|
354
461
|
}
|
|
355
462
|
};
|
|
463
|
+
/** Heading blocks that are not part of a callout — the only ones the
|
|
464
|
+
* keep-with-next rollbacks may pull along (a callout is one unbreakable
|
|
465
|
+
* unit; its children never leave it). */
|
|
466
|
+
const isFreeHeading = (b) => b.type === 'heading' && b.containerId === undefined;
|
|
467
|
+
/** Shared tail of callout placement: stamp the frame's source range,
|
|
468
|
+
* convert the laid-out box to absolute coordinates at the frame's placed
|
|
469
|
+
* origin, and push frame + children — in that order — to `doc.blocks`
|
|
470
|
+
* and to the column the frame landed in. */
|
|
471
|
+
const commitCallout = (result, startIdx, plan, col) => {
|
|
472
|
+
const frame = result.frame;
|
|
473
|
+
const startBlock = contentBlocks[startIdx];
|
|
474
|
+
const endBlock = contentBlocks[plan.endIdx];
|
|
475
|
+
frame.contentIndex = startIdx;
|
|
476
|
+
frame.sourceStart = startBlock.sourceStart + bodyOffset;
|
|
477
|
+
frame.sourceEnd = endBlock.sourceEnd + bodyOffset;
|
|
478
|
+
offsetCalloutToAbsolute(result, frame.bbox.x, frame.bbox.y);
|
|
479
|
+
doc.blocks.push(frame);
|
|
480
|
+
for (const child of result.children) {
|
|
481
|
+
child.pageIndex = frame.pageIndex;
|
|
482
|
+
child.columnIndex = frame.columnIndex;
|
|
483
|
+
col.blocks.push(child);
|
|
484
|
+
doc.blocks.push(child);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
/**
|
|
488
|
+
* Place a `span: 'page'` `:::callout` in a multi-column layout as a span
|
|
489
|
+
* block (stage 1): the box is laid out at the page's content width and
|
|
490
|
+
* gets its own full-width `kind: 'span'` column; the text columns of the
|
|
491
|
+
* current band are closed at the cut line and a fresh band of text columns
|
|
492
|
+
* opens below the box, so the flow continues under it in every column.
|
|
493
|
+
*
|
|
494
|
+
* The band must be LEVEL — every column has consumed the same height:
|
|
495
|
+
* the page top, right after a `span: 'page'` opener heading, right after
|
|
496
|
+
* another span block, or right after a top float band — and leave room
|
|
497
|
+
* for the box plus at least the widow minimum of body lines below it.
|
|
498
|
+
* Otherwise the box moves to the top of the next page (a fresh page is
|
|
499
|
+
* trivially level) WITHOUT marking a forced break, so the page it left
|
|
500
|
+
* stays balanceable. Keep-with-next does not apply here: a heading right
|
|
501
|
+
* before a page-span box stays in its text column.
|
|
502
|
+
*
|
|
503
|
+
* Stage 2 — mid-page bands (`bandCaps.ts`): a box arriving in an UNEVEN
|
|
504
|
+
* band with room below proposes a band cap — the band's columns cut
|
|
505
|
+
* level at `ceil(Σ used / N / grid)` lines — and falls back to the next
|
|
506
|
+
* page for this pass; the driver re-runs the pass with the cap, the
|
|
507
|
+
* capped columns fill and overflow naturally (every placement rule still
|
|
508
|
+
* applies), and when the box arrives in the band its cap was applied to
|
|
509
|
+
* it cuts there: the columns above end level (the last one may keep up
|
|
510
|
+
* to a few lines of slack, which balancing absorbs), the box spans the
|
|
511
|
+
* page and the flow resumes in the band below. When the capped band
|
|
512
|
+
* overflowed instead (the box arrives elsewhere), the driver grows or
|
|
513
|
+
* drops the cap.
|
|
514
|
+
*
|
|
515
|
+
* Geometry stays on the baseline grid: the cut line is the band's used
|
|
516
|
+
* bottom snapped UP to the next grid line (anchored at the content-area
|
|
517
|
+
* top, like every column start), and the span column's height is the
|
|
518
|
+
* box plus its collapsed top spacing and `marginBottom`, rounded up to a
|
|
519
|
+
* grid multiple — so the new band's columns start on the grid. Floats
|
|
520
|
+
* stay in the outer page bands: the band inherits the float-reduced top
|
|
521
|
+
* / bottom of the page's columns, so a span column never overlaps a float.
|
|
522
|
+
*/
|
|
523
|
+
const placeCalloutSpan = (startIdx, plan, layoutAt) => {
|
|
524
|
+
let page = doc.pages[cursor.pageIndex];
|
|
525
|
+
let result = layoutAt(page.contentArea.width);
|
|
526
|
+
const minLines = resolved.bodyText.avoidWidows ? Math.max(1, resolved.bodyText.widowMinLines) : 1;
|
|
527
|
+
const minRoomPx = minLines * bodyStyle.lineHeightPx;
|
|
528
|
+
/** Where the box would cut the current band, and whether it fits (room
|
|
529
|
+
* is measured against the columns' TRUE bottoms — a capped band keeps
|
|
530
|
+
* its slack below the cap). Null when the band is not level (or the
|
|
531
|
+
* cursor sits on a span column with no band below it) and
|
|
532
|
+
* `requireLevel` is set. */
|
|
533
|
+
const measureBand = (requireLevel) => {
|
|
534
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
535
|
+
if (cols.length === 0 || (requireLevel && !isBandLevel(cols)))
|
|
536
|
+
return null;
|
|
537
|
+
const usedBottom = bandUsedBottom(cols);
|
|
538
|
+
const cutY = page.contentArea.y
|
|
539
|
+
+ Math.ceil((usedBottom - page.contentArea.y - 0.01) / baselineGrid) * baselineGrid;
|
|
540
|
+
const bandHasContent = cols.some((c) => c.blocks.length > 0);
|
|
541
|
+
const spacing = bandHasContent ? Math.max(pendingSpacing, result.marginTopPx) : 0;
|
|
542
|
+
const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
|
|
543
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
544
|
+
const room = cutY + need + minRoomPx <= bandBottom + 0.01;
|
|
545
|
+
return { cols, cutY, need, spacing, room };
|
|
546
|
+
};
|
|
547
|
+
// Is this band capped for this very box? Then it cuts at the band's
|
|
548
|
+
// used bottom (at most the cap) even when the last column is short.
|
|
549
|
+
const cap = bandCaps?.get(startIdx);
|
|
550
|
+
const capActive = cap !== undefined
|
|
551
|
+
&& activeCap !== null
|
|
552
|
+
&& activeCap.spanIndex === startIdx
|
|
553
|
+
&& activeCap.pageIndex === page.index
|
|
554
|
+
&& activeCap.band === currentBand(page, cursor);
|
|
555
|
+
let fit = measureBand(!capActive);
|
|
556
|
+
if (fit?.room && capActive) {
|
|
557
|
+
uncapBand(fit.cols, uncappedBottoms);
|
|
558
|
+
spanPlacedInBand.add(startIdx);
|
|
559
|
+
}
|
|
560
|
+
if (!fit && cap === undefined && bandStart && registeredBand
|
|
561
|
+
&& registeredBand.pageIndex === page.index
|
|
562
|
+
&& registeredBand.band === currentBand(page, cursor)) {
|
|
563
|
+
// Uneven band, no cap yet: propose one when a level cut would leave
|
|
564
|
+
// room for the box plus the widow minimum of body lines below it.
|
|
565
|
+
const cols = bandColumns(page, currentBand(page, cursor));
|
|
566
|
+
const lines = bandCapLines(cols, baselineGrid);
|
|
567
|
+
const capBottom = Math.max(...cols.map((c) => c.bbox.y)) + lines * baselineGrid;
|
|
568
|
+
const spacing = Math.max(pendingSpacing, result.marginTopPx);
|
|
569
|
+
const need = Math.ceil((spacing + result.totalHeight + result.marginBottomPx - 0.01) / baselineGrid) * baselineGrid;
|
|
570
|
+
const bandBottom = Math.min(...cols.map((c) => columnBottom(c, uncappedBottoms)));
|
|
571
|
+
if (capBottom + need + minRoomPx <= bandBottom + 0.01) {
|
|
572
|
+
bandCapProposals.set(startIdx, {
|
|
573
|
+
startContentIndex: bandStart.contentIndex,
|
|
574
|
+
startPart: bandStart.part,
|
|
575
|
+
lines,
|
|
576
|
+
retries: 0,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (!fit || !fit.room) {
|
|
581
|
+
// Open the next page (flushing pending floats into its bands). A page
|
|
582
|
+
// holding only floats counts as occupied here — its float band is what
|
|
583
|
+
// left no room — but a truly empty page is kept: the box then simply
|
|
584
|
+
// does not fit a page and is force-placed (overflowing, like inline).
|
|
585
|
+
const curPage = doc.pages[cursor.pageIndex];
|
|
586
|
+
if (pageHasContent(curPage) || (curPage.floats?.length ?? 0) > 0) {
|
|
587
|
+
pendingSpacing = 0;
|
|
588
|
+
const startPageIndex = cursor.pageIndex;
|
|
589
|
+
do {
|
|
590
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
591
|
+
} while (cursor.pageIndex === startPageIndex);
|
|
592
|
+
page = doc.pages[cursor.pageIndex];
|
|
593
|
+
if (Math.abs(page.contentArea.width - result.width) > 0.01) {
|
|
594
|
+
result = layoutAt(page.contentArea.width);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// A freshly opened page is level; force-place (overflow) when the box
|
|
598
|
+
// is taller than the page.
|
|
599
|
+
fit = measureBand(true) ?? measureBand(false);
|
|
600
|
+
if (!fit)
|
|
601
|
+
return false; // no text column to cut — leave it to the inline path
|
|
602
|
+
}
|
|
603
|
+
const spanCol = closeBandAndInsertSpan(page, fit.cols, fit.cutY, result.frame, fit.need, cursor, fit.spacing, result.totalHeight);
|
|
604
|
+
commitCallout(result, startIdx, plan, spanCol);
|
|
605
|
+
// Floats first-referenced inside the box enqueue once it is committed,
|
|
606
|
+
// in reading order (same as the inline path).
|
|
607
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++) {
|
|
608
|
+
const fl = floatsByFirstBlock.get(i);
|
|
609
|
+
if (fl)
|
|
610
|
+
pendingFloats.push(...fl);
|
|
611
|
+
}
|
|
612
|
+
// The new band starts on the grid right below the span column; nothing
|
|
613
|
+
// to snap — `need` already bakes in `marginBottom`.
|
|
614
|
+
pendingSpacing = 0;
|
|
615
|
+
return true;
|
|
616
|
+
};
|
|
617
|
+
/**
|
|
618
|
+
* Place a `:::callout` inline at the current column width as one atomic
|
|
619
|
+
* unit: the frame block followed by its children in the same column. The
|
|
620
|
+
* box's `marginTop` collapses with the pending spacing; `marginBottom` is
|
|
621
|
+
* baked into the post-box grid snap. A box that does not fit moves to the
|
|
622
|
+
* next column/page (like a resource), pulling a run of trailing headings
|
|
623
|
+
* along (keep-with-next); a box taller than an empty column is placed
|
|
624
|
+
* anyway and overflows (the sandbox warns). Returns the content index to
|
|
625
|
+
* rewind the main loop to when headings were rolled back, else `undefined`.
|
|
626
|
+
*
|
|
627
|
+
* `span: 'page'` boxes in multi-column layouts take the span-block path
|
|
628
|
+
* (`placeCalloutSpan`) instead; `placement: 'top' | 'bottom'` (floating
|
|
629
|
+
* boxes) still fall back to this inline placement for v1 — the frame's
|
|
630
|
+
* `callout.placement` records the request.
|
|
631
|
+
*/
|
|
632
|
+
const placeCalloutInline = (startIdx, plan) => {
|
|
633
|
+
const style = pickCalloutStyle(resolved.calloutStyles, plan.attrs.type);
|
|
634
|
+
const children = contentBlocks.slice(startIdx + 1, plan.endIdx);
|
|
635
|
+
const frameId = `block-${blockIdCounter++}`;
|
|
636
|
+
const { span, placement } = resolveCalloutAttrs(style, plan.attrs);
|
|
637
|
+
const layoutAt = (width) => {
|
|
638
|
+
let n = 0;
|
|
639
|
+
return layoutCallout({
|
|
640
|
+
style,
|
|
641
|
+
attrs: plan.attrs,
|
|
642
|
+
children,
|
|
643
|
+
childStartIdx: startIdx + 1,
|
|
644
|
+
width,
|
|
645
|
+
ctx: measureCtx,
|
|
646
|
+
resolved,
|
|
647
|
+
containerId: plan.containerId,
|
|
648
|
+
frameId,
|
|
649
|
+
nextChildId: () => `${frameId}-c${n++}`,
|
|
650
|
+
paragraphStyleFor: (idx) => paragraphContainers.byBlock[idx]?.style,
|
|
651
|
+
});
|
|
652
|
+
};
|
|
653
|
+
// Page-span boxes split a multi-column page into column bands (stage 1
|
|
654
|
+
// of span blocks). Floating placements keep the inline fallback.
|
|
655
|
+
{
|
|
656
|
+
const page = doc.pages[cursor.pageIndex];
|
|
657
|
+
if (span === 'page'
|
|
658
|
+
&& placement === 'here'
|
|
659
|
+
&& bandColumns(page, currentBand(page, cursor)).length > 1
|
|
660
|
+
&& placeCalloutSpan(startIdx, plan, layoutAt)) {
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
let curCol = currentColumn(doc, cursor);
|
|
665
|
+
let result = layoutAt(curCol.bbox.width);
|
|
666
|
+
if (curCol.blocks.length > 0) {
|
|
667
|
+
const spacingBefore = Math.max(pendingSpacing, result.marginTopPx);
|
|
668
|
+
if (result.totalHeight > curCol.availableHeight - spacingBefore) {
|
|
669
|
+
// Keep-with-next: a run of headings at the column's tail travels
|
|
670
|
+
// with the box. Skipped when the column holds nothing else (rolling
|
|
671
|
+
// back again would loop) — the headings stay, orphaned.
|
|
672
|
+
let run = 0;
|
|
673
|
+
for (let j = curCol.blocks.length - 1; j >= 0; j--) {
|
|
674
|
+
if (isFreeHeading(curCol.blocks[j]))
|
|
675
|
+
run++;
|
|
676
|
+
else
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
pendingSpacing = 0;
|
|
680
|
+
if (resolved.headings.keepWithNext && run > 0 && run < curCol.blocks.length) {
|
|
681
|
+
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
682
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
683
|
+
return (rolledBack[0].contentIndex ?? startIdx - rolledBack.length) - 1;
|
|
684
|
+
}
|
|
685
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
686
|
+
curCol = currentColumn(doc, cursor);
|
|
687
|
+
// Columns of different widths (oneAndHalf): re-lay out for the new one.
|
|
688
|
+
if (Math.abs(curCol.bbox.width - result.width) > 0.01 && style.width !== 'auto') {
|
|
689
|
+
result = layoutAt(curCol.bbox.width);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Floats first-referenced inside the box still enqueue in reading order
|
|
694
|
+
// (only once the box is committed, so a keep-with-next replay does not
|
|
695
|
+
// enqueue them twice).
|
|
696
|
+
for (let i = startIdx + 1; i <= plan.endIdx; i++) {
|
|
697
|
+
const fl = floatsByFirstBlock.get(i);
|
|
698
|
+
if (fl)
|
|
699
|
+
pendingFloats.push(...fl);
|
|
700
|
+
}
|
|
701
|
+
const frame = result.frame;
|
|
702
|
+
const spacing = curCol.blocks.length === 0 ? 0 : Math.max(pendingSpacing, result.marginTopPx);
|
|
703
|
+
enterBand(startIdx, 0);
|
|
704
|
+
placeAtomicBlock(frame, result.totalHeight, spacing, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
705
|
+
enterBand(startIdx, 0);
|
|
706
|
+
curCol = currentColumn(doc, cursor);
|
|
707
|
+
commitCallout(result, startIdx, plan, curCol);
|
|
708
|
+
// Snap the flow after the box to the baseline grid, baking in at least
|
|
709
|
+
// `marginBottom` (grid wins, margin is a minimum — the resource rule).
|
|
710
|
+
{
|
|
711
|
+
const usedHeight = curCol.bbox.height - curCol.availableHeight;
|
|
712
|
+
const naturalBottom = usedHeight + result.marginBottomPx;
|
|
713
|
+
const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
|
|
714
|
+
curCol.availableHeight = Math.max(0, curCol.bbox.height - snappedBottom);
|
|
715
|
+
}
|
|
716
|
+
pendingSpacing = 0;
|
|
717
|
+
return undefined;
|
|
718
|
+
};
|
|
356
719
|
for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
|
|
357
720
|
if (options?.shouldCancel?.())
|
|
358
721
|
throw new BuildCancelledError();
|
|
@@ -381,6 +744,22 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
381
744
|
}
|
|
382
745
|
flushPendingNumberingAtBoundary();
|
|
383
746
|
}
|
|
747
|
+
else if (name === 'columnbreak') {
|
|
748
|
+
// Explicit column break: end the current column here (its bottom
|
|
749
|
+
// gap is intentional, so balancing skips it) and continue in the
|
|
750
|
+
// next column. A no-op in an empty column, so it never opens a
|
|
751
|
+
// blank column or page.
|
|
752
|
+
pendingSpacing = 0;
|
|
753
|
+
const col = currentColumn(doc, cursor);
|
|
754
|
+
if (col.blocks.length > 0) {
|
|
755
|
+
col.forcedBreak = true;
|
|
756
|
+
const page = doc.pages[cursor.pageIndex];
|
|
757
|
+
if (cursor.columnIndex === page.columns.length - 1)
|
|
758
|
+
markForcedBreak();
|
|
759
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
760
|
+
flushPendingNumberingAtBoundary();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
384
763
|
else if (name === 'numbering') {
|
|
385
764
|
const change = {};
|
|
386
765
|
const fmt = attrs.format;
|
|
@@ -396,6 +775,92 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
396
775
|
}
|
|
397
776
|
continue;
|
|
398
777
|
}
|
|
778
|
+
// --- Container markers ---------------------------------------------
|
|
779
|
+
// `:::paragraphs` applies its style's top margin on entry through the
|
|
780
|
+
// pending-spacing mechanism (collapses like any margin, vanishes at a
|
|
781
|
+
// column top). Its bottom margin is normally baked into the last
|
|
782
|
+
// paragraph's grid snap; the pending-spacing fallback covers containers
|
|
783
|
+
// that end with a non-paragraph block. Replaying a marker after a keep-with-next rewind is
|
|
784
|
+
// harmless: the container plan is index-based, and `max` is idempotent.
|
|
785
|
+
// `:::part`: the opener lives on a dedicated single-column page. On
|
|
786
|
+
// entry, break to a fresh page of the configured parity and convert it
|
|
787
|
+
// into a part page; on exit, break again so the following content (and
|
|
788
|
+
// the next chapter's own parity rule) starts clean. A part page counts
|
|
789
|
+
// as content even with an empty body — its opener design fills it — so
|
|
790
|
+
// consecutive parts never share a page.
|
|
791
|
+
if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'part') {
|
|
792
|
+
const plan = partPlan.byStart.get(blockIdx);
|
|
793
|
+
if (plan) {
|
|
794
|
+
pendingSpacing = 0;
|
|
795
|
+
markForcedBreak();
|
|
796
|
+
leaveCurrentPage();
|
|
797
|
+
enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, resolved.parts.breakBefore.parity);
|
|
798
|
+
cursor.columnIndex = 0;
|
|
799
|
+
// Map the opener's title back to the `title="…"` attribute of the
|
|
800
|
+
// fence so the editor can place the cursor from a click on the band.
|
|
801
|
+
const fenceStart = rawBlock.sourceStart + bodyOffset;
|
|
802
|
+
const fenceText = markdownBody.slice(rawBlock.sourceStart, rawBlock.sourceEnd);
|
|
803
|
+
const titleAttr = /\btitle\s*=\s*(["'])/.exec(fenceText);
|
|
804
|
+
const titleSourceStart = titleAttr ? fenceStart + titleAttr.index + titleAttr[0].length : fenceStart;
|
|
805
|
+
const titleSourceEnd = titleAttr ? titleSourceStart + plan.title.length : rawBlock.sourceEnd + bodyOffset;
|
|
806
|
+
createPartPage(doc.pages[cursor.pageIndex], pageMetrics, resolved, {
|
|
807
|
+
number: plan.number,
|
|
808
|
+
title: plan.title,
|
|
809
|
+
titleSourceStart,
|
|
810
|
+
titleSourceEnd,
|
|
811
|
+
});
|
|
812
|
+
flushPendingNumberingAtBoundary();
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (rawBlock.type === 'containerEnd' && rawBlock.containerName === 'part') {
|
|
817
|
+
const plan = partPlan.byEnd.get(blockIdx);
|
|
818
|
+
if (plan) {
|
|
819
|
+
pendingSpacing = 0;
|
|
820
|
+
// Deferred until the next placed block so a part that closes the
|
|
821
|
+
// document leaves no trailing empty page behind.
|
|
822
|
+
if (resolved.parts.breakAfter.enabled)
|
|
823
|
+
pendingPartBreak = resolved.parts.breakAfter.parity;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (pendingPartBreak !== null) {
|
|
828
|
+
const parity = pendingPartBreak;
|
|
829
|
+
pendingPartBreak = null;
|
|
830
|
+
pendingSpacing = 0;
|
|
831
|
+
markForcedBreak();
|
|
832
|
+
leaveCurrentPage();
|
|
833
|
+
enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity);
|
|
834
|
+
flushPendingNumberingAtBoundary();
|
|
835
|
+
}
|
|
836
|
+
if (rawBlock.type === 'containerStart' && rawBlock.containerName === 'callout') {
|
|
837
|
+
const plan = calloutPlan.get(blockIdx);
|
|
838
|
+
if (plan && pickCalloutStyle(resolved.calloutStyles, plan.attrs.type)) {
|
|
839
|
+
// `span: 'page'` boxes in multi-column layouts branch to the
|
|
840
|
+
// span-block path inside; `placement: 'top' | 'bottom'` still
|
|
841
|
+
// falls back to inline placement (floating boxes pending).
|
|
842
|
+
const rewind = placeCalloutInline(blockIdx, plan);
|
|
843
|
+
// Children were laid out inside the box — skip them in the main loop
|
|
844
|
+
// (the for-loop's `++` lands just past the closing marker).
|
|
845
|
+
blockIdx = rewind !== undefined ? rewind : plan.endIdx;
|
|
846
|
+
flushPendingNumberingAtBoundary();
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (rawBlock.type === 'containerStart' || rawBlock.type === 'containerEnd') {
|
|
851
|
+
const pc = rawBlock.containerId !== undefined
|
|
852
|
+
? paragraphContainers.byId.get(rawBlock.containerId)
|
|
853
|
+
: undefined;
|
|
854
|
+
if (pc) {
|
|
855
|
+
if (rawBlock.type === 'containerStart') {
|
|
856
|
+
pendingSpacing = Math.max(pendingSpacing, pc.marginTopPx);
|
|
857
|
+
}
|
|
858
|
+
else if (contentBlocks[blockIdx - 1]?.type !== 'paragraph') {
|
|
859
|
+
pendingSpacing = Math.max(pendingSpacing, pc.marginBottomPx);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
399
864
|
// --- Heading `breakBefore` ----------------------------------------
|
|
400
865
|
if (rawBlock.type === 'heading' && rawBlock.level) {
|
|
401
866
|
const level = headingLevelByNumber.get(rawBlock.level);
|
|
@@ -422,59 +887,34 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
422
887
|
}
|
|
423
888
|
}
|
|
424
889
|
const id = `block-${blockIdCounter++}`;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
890
|
+
// Enclosing `:::paragraphs` container (if any). Its last paragraph — the
|
|
891
|
+
// one directly before the closing marker — carries the tail style and
|
|
892
|
+
// snaps the flow back onto the baseline grid.
|
|
893
|
+
const paragraphContainer = paragraphContainers.byBlock[blockIdx];
|
|
894
|
+
const nextRaw = contentBlocks[blockIdx + 1];
|
|
895
|
+
const isContainerTail = paragraphContainer !== undefined
|
|
896
|
+
&& nextRaw?.type === 'containerEnd'
|
|
897
|
+
&& nextRaw.containerId === paragraphContainer.id;
|
|
898
|
+
// Measure against the current column width. `null` means there is nothing
|
|
899
|
+
// to place inline (empty text, unknown resource id, floated resource).
|
|
900
|
+
const col = currentColumn(doc, cursor);
|
|
901
|
+
const blockMeasureCtx = partPlan.byBlock[blockIdx] ? partMeasureCtx : measureCtx;
|
|
902
|
+
const measuredBlock = measureContentBlock(rawBlock, blockIdx, col.bbox.width, blockMeasureCtx, {
|
|
903
|
+
// Column balancing "run a paragraph long": stays undefined for the
|
|
904
|
+
// common case so existing measurement cache keys are preserved.
|
|
905
|
+
looseness: balanceLooseness?.get(blockIdx),
|
|
906
|
+
styleOverride: paragraphContainer
|
|
907
|
+
? (isContainerTail ? paragraphContainer.tailStyle : paragraphContainer.style)
|
|
908
|
+
: undefined,
|
|
437
909
|
});
|
|
910
|
+
if (!measuredBlock)
|
|
911
|
+
continue;
|
|
912
|
+
const { kind, contentBlock, measured, prefixLen, absoluteSourceMap, mathDisplayRender } = measuredBlock;
|
|
438
913
|
const { style, vdtType, headingLevel, numberPrefix, listBullet, listDepth, listKind, bulletXOffsetInColumn, strikethroughText } = kind;
|
|
439
|
-
let contentBlock = kind.contentBlock;
|
|
440
914
|
// --- Resource blocks (image / svg / table + caption) -----------------
|
|
441
|
-
//
|
|
442
|
-
// for v1. An unknown resource id produces no output (warnings handle it).
|
|
915
|
+
// Placed atomically (kept-together) — no mid-content split for v1.
|
|
443
916
|
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
|
-
}
|
|
917
|
+
const resourceBlock = measuredBlock.resourceBlock;
|
|
478
918
|
const groupHeight = measured.totalHeight;
|
|
479
919
|
const blk = createVDTBlock(id, 'resource', style.fontString, style.color, style.textAlign);
|
|
480
920
|
blk.contentIndex = blockIdx;
|
|
@@ -494,8 +934,10 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
494
934
|
isLastLine: true,
|
|
495
935
|
}];
|
|
496
936
|
const spacingBefore = pendingSpacing;
|
|
497
|
-
|
|
498
|
-
|
|
937
|
+
enterBand(blockIdx, 0);
|
|
938
|
+
placeAtomicBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
939
|
+
enterBand(blockIdx, 0);
|
|
940
|
+
// `placeBlockInColumn` (inside placeAtomicBlock) shifts `blk.lines`; the
|
|
499
941
|
// resource's own caption/table lines live on `resourceBlock` and must be
|
|
500
942
|
// offset to absolute page coordinates here using the placed bbox origin.
|
|
501
943
|
offsetResourceBlockToAbsolute(resourceBlock, blk.bbox.x, blk.bbox.y);
|
|
@@ -515,72 +957,6 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
515
957
|
flushPendingNumberingAtBoundary();
|
|
516
958
|
continue;
|
|
517
959
|
}
|
|
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
|
-
};
|
|
570
|
-
const useRich = !!(hasRichSpans && style.boldFontString && style.italicFontString && style.boldItalicFontString);
|
|
571
|
-
const { measured, mathDisplayRender } = runMeasurement({
|
|
572
|
-
vdtType, rawBlock, contentBlock, style, measureMaxWidth, measureOptions, mathEnabled, useRich, cache,
|
|
573
|
-
});
|
|
574
|
-
if (measured.lines.length === 0)
|
|
575
|
-
continue;
|
|
576
|
-
if (lineXShift > 0) {
|
|
577
|
-
for (const line of measured.lines) {
|
|
578
|
-
line.bbox.x += lineXShift;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
// Per-line source-range mapping using the block's plain→source map.
|
|
582
|
-
// Accounts for heading numbering prefix which prepends chars with no source.
|
|
583
|
-
const { prefixLen, absoluteSourceMap } = stampSourceRanges(measured, rawBlock, contentBlock, bodyOffset);
|
|
584
960
|
const finalizeListItem = (blk, isFirstPart) => {
|
|
585
961
|
if (!listBullet)
|
|
586
962
|
return;
|
|
@@ -597,6 +973,12 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
597
973
|
// `indentPx + (maxNumberWidth - thisNumberWidth)` for ordered — giving
|
|
598
974
|
// the right-aligned separator.
|
|
599
975
|
blk.bulletOffsetX = blk.bbox.x + bulletXOffsetInColumn;
|
|
976
|
+
if (listBullet.separatorText !== undefined) {
|
|
977
|
+
blk.separatorText = listBullet.separatorText;
|
|
978
|
+
blk.separatorFontString = listBullet.separatorFontString;
|
|
979
|
+
blk.separatorColor = listBullet.separatorColor;
|
|
980
|
+
blk.separatorX = blk.bulletOffsetX + (listBullet.separatorOffsetPx ?? 0);
|
|
981
|
+
}
|
|
600
982
|
if (strikethroughText)
|
|
601
983
|
blk.strikethroughText = true;
|
|
602
984
|
// Bullet Y = x-height midpoint of the item's first text line.
|
|
@@ -607,17 +989,21 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
607
989
|
blk.bulletY = firstLine.baseline - listBullet.textFontSizePx * 0.3 + listBullet.verticalOffsetPx;
|
|
608
990
|
}
|
|
609
991
|
};
|
|
610
|
-
|
|
992
|
+
// Neighbour lookaheads see through container markers.
|
|
993
|
+
const nextBlock = nextNonMarkerBlock(contentBlocks, blockIdx) ?? null;
|
|
994
|
+
const nextIsListItem = nextBlock?.type === 'listItem';
|
|
611
995
|
// For headings, only snap to baseline grid if the next block is NOT a heading.
|
|
612
996
|
// Consecutive headings flow without grid snapping; the last heading in the
|
|
613
997
|
// group snaps so that the following body text realigns with the grid.
|
|
614
998
|
// Same rule for list items: the LAST item of a list snaps so that text
|
|
615
999
|
// after the list realigns with the baseline grid, even when non-grid
|
|
616
|
-
// spacings (itemSpacing, marginTop/Bottom) were chosen.
|
|
617
|
-
|
|
1000
|
+
// spacings (itemSpacing, marginTop/Bottom) were chosen. And for the last
|
|
1001
|
+
// paragraph of a `:::paragraphs` container, whose leading and spacing
|
|
1002
|
+
// are off-grid by design.
|
|
618
1003
|
const nextIsHeading = nextBlock?.type === 'heading';
|
|
619
1004
|
const shouldSnapToGrid = (vdtType === 'heading' && !nextIsHeading) ||
|
|
620
1005
|
(vdtType === 'listItem' && !nextIsListItem) ||
|
|
1006
|
+
(vdtType === 'paragraph' && isContainerTail) ||
|
|
621
1007
|
vdtType === 'mathDisplay';
|
|
622
1008
|
// Place block, splitting across columns/pages if needed.
|
|
623
1009
|
// List items may split too — orphan/widow protection per-list is gated by
|
|
@@ -635,6 +1021,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
635
1021
|
&& nextIsListItem
|
|
636
1022
|
&& /:\s*$/.test(contentBlock.text);
|
|
637
1023
|
while (remainingLines.length > 0) {
|
|
1024
|
+
enterBand(blockIdx, partIndex);
|
|
638
1025
|
const curCol = currentColumn(doc, cursor);
|
|
639
1026
|
const isFirstInColumn = curCol.blocks.length === 0;
|
|
640
1027
|
// Compute spacing before this block — margin collapsing between
|
|
@@ -657,11 +1044,19 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
657
1044
|
}
|
|
658
1045
|
}
|
|
659
1046
|
else if (vdtType === 'listItem') {
|
|
660
|
-
const prevWasList =
|
|
1047
|
+
const prevWasList = prevNonMarkerBlock(contentBlocks, blockIdx)?.type === 'listItem';
|
|
661
1048
|
if (!prevWasList) {
|
|
662
1049
|
spacingBefore = Math.max(spacingBefore, style.marginTopPx);
|
|
663
1050
|
}
|
|
664
1051
|
}
|
|
1052
|
+
// Column balancing: extra grid lines above a non-heading balance
|
|
1053
|
+
// target — the first block after a list end. Heading targets are
|
|
1054
|
+
// handled inside the heading branch above (after margin collapsing).
|
|
1055
|
+
if (vdtType !== 'heading' && vdtType !== 'mathDisplay' && partIndex === 0) {
|
|
1056
|
+
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
1057
|
+
if (extraPx)
|
|
1058
|
+
spacingBefore += extraPx;
|
|
1059
|
+
}
|
|
665
1060
|
}
|
|
666
1061
|
const effectiveAvailable = curCol.availableHeight - spacingBefore;
|
|
667
1062
|
const linesPerAvailable = Math.floor(effectiveAvailable / style.lineHeightPx);
|
|
@@ -682,11 +1077,15 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
682
1077
|
.map((ln) => (ln.segments ?? []).map((s) => s.text).join(''))
|
|
683
1078
|
.join(' ');
|
|
684
1079
|
const pref = numberPrefix ?? '';
|
|
685
|
-
const title = pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full;
|
|
1080
|
+
const title = applyTitleBreaks(pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full, rawBlock.titleBreaks, rawBlock.text.length);
|
|
686
1081
|
// Span-page openers lay out across the full content area (both
|
|
687
1082
|
// columns); in-column headings use just the column width.
|
|
688
|
-
const
|
|
689
|
-
const
|
|
1083
|
+
const pageArea = doc.pages[cursor.pageIndex].contentArea;
|
|
1084
|
+
const measureWidth = lvl.span === 'page' ? pageArea.width : curCol.bbox.width;
|
|
1085
|
+
const designBottom = measureHeadingAdvancedDesignHeight(lvl, { titleText: title, formattedNumber: pref, chapterNumber: pref, attrs: rawBlock.attrs }, measureWidth, resolved.page.dpi, doc.metadata, cursor.pageIndex, designFrames, {
|
|
1086
|
+
x: lvl.span === 'page' ? pageArea.x : curCol.bbox.x,
|
|
1087
|
+
y: curCol.bbox.y + (curCol.bbox.height - curCol.availableHeight) + spacingBefore,
|
|
1088
|
+
});
|
|
690
1089
|
if (designBottom > effectiveRemainHeight)
|
|
691
1090
|
effectiveRemainHeight = designBottom;
|
|
692
1091
|
}
|
|
@@ -751,7 +1150,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
751
1150
|
let headingRunCount = 0;
|
|
752
1151
|
if (resolved.headings.keepWithNext) {
|
|
753
1152
|
for (let j = curCol.blocks.length - 1; j >= 0; j--) {
|
|
754
|
-
if (curCol.blocks[j].type === 'heading')
|
|
1153
|
+
if (curCol.blocks[j].type === 'heading' && curCol.blocks[j].containerId === undefined)
|
|
755
1154
|
headingRunCount++;
|
|
756
1155
|
else
|
|
757
1156
|
break;
|
|
@@ -765,7 +1164,9 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
765
1164
|
doc.blocks.splice(idx, 1);
|
|
766
1165
|
curCol.availableHeight += p.bbox.height;
|
|
767
1166
|
}
|
|
768
|
-
blockIdx
|
|
1167
|
+
// Rewind so the for-loop's blockIdx++ lands on the first
|
|
1168
|
+
// rolled-back heading (marker blocks in between are replayed).
|
|
1169
|
+
blockIdx = (popped[0].contentIndex ?? blockIdx - headingRunCount) - 1;
|
|
769
1170
|
pendingSpacing = 0;
|
|
770
1171
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
771
1172
|
break;
|
|
@@ -808,11 +1209,11 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
808
1209
|
if (remainAfterHeading < minSpaceAfter) {
|
|
809
1210
|
// Roll back any immediately-preceding heading blocks in this
|
|
810
1211
|
// column so they travel with this one.
|
|
811
|
-
const
|
|
812
|
-
if (
|
|
1212
|
+
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
1213
|
+
if (rolledBack.length > 0) {
|
|
813
1214
|
// Rewind so the for-loop's blockIdx++ lands on the first
|
|
814
|
-
// rolled-back heading.
|
|
815
|
-
blockIdx
|
|
1215
|
+
// rolled-back heading (marker blocks in between are replayed).
|
|
1216
|
+
blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
|
|
816
1217
|
pendingSpacing = 0;
|
|
817
1218
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
818
1219
|
break;
|
|
@@ -835,6 +1236,20 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
835
1236
|
if (numberPrefix)
|
|
836
1237
|
blk.numberPrefix = numberPrefix;
|
|
837
1238
|
}
|
|
1239
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrs)
|
|
1240
|
+
blk.attrs = rawBlock.attrs;
|
|
1241
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrSources) {
|
|
1242
|
+
// Parser ranges are body-relative; VDT source offsets are absolute.
|
|
1243
|
+
blk.attrSources = Object.fromEntries(Object.entries(rawBlock.attrSources).map(([k, r]) => [k, { start: r.start + bodyOffset, end: r.end + bodyOffset }]));
|
|
1244
|
+
}
|
|
1245
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
|
|
1246
|
+
blk.titleBreaks = rawBlock.titleBreaks;
|
|
1247
|
+
blk.titleLength = rawBlock.text.length;
|
|
1248
|
+
}
|
|
1249
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
|
|
1250
|
+
blk.titleBreaks = rawBlock.titleBreaks;
|
|
1251
|
+
blk.titleLength = rawBlock.text.length;
|
|
1252
|
+
}
|
|
838
1253
|
if (vdtType === 'mathDisplay' && mathDisplayRender) {
|
|
839
1254
|
blk.mathRender = mathDisplayRender;
|
|
840
1255
|
blk.tex = rawBlock.tex;
|
|
@@ -958,9 +1373,9 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
958
1373
|
// pull those headings along so they don't remain stranded as orphans
|
|
959
1374
|
// at the column's bottom. Mirrors the rollback inside the "fits" path.
|
|
960
1375
|
if (vdtType === 'heading' && resolved.headings.keepWithNext) {
|
|
961
|
-
const
|
|
962
|
-
if (
|
|
963
|
-
blockIdx
|
|
1376
|
+
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
1377
|
+
if (rolledBack.length > 0) {
|
|
1378
|
+
blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
|
|
964
1379
|
pendingSpacing = 0;
|
|
965
1380
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
966
1381
|
break;
|
|
@@ -977,6 +1392,16 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
977
1392
|
blk.contentIndex = blockIdx;
|
|
978
1393
|
if (partIndex === 0)
|
|
979
1394
|
blk.headingLevel = headingLevel;
|
|
1395
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrs)
|
|
1396
|
+
blk.attrs = rawBlock.attrs;
|
|
1397
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.attrSources) {
|
|
1398
|
+
// Parser ranges are body-relative; VDT source offsets are absolute.
|
|
1399
|
+
blk.attrSources = Object.fromEntries(Object.entries(rawBlock.attrSources).map(([k, r]) => [k, { start: r.start + bodyOffset, end: r.end + bodyOffset }]));
|
|
1400
|
+
}
|
|
1401
|
+
if (partIndex === 0 && vdtType === 'heading' && rawBlock.titleBreaks) {
|
|
1402
|
+
blk.titleBreaks = rawBlock.titleBreaks;
|
|
1403
|
+
blk.titleLength = rawBlock.text.length;
|
|
1404
|
+
}
|
|
980
1405
|
blk.lines = resetLinePositions(remainingLines, style.lineHeightPx);
|
|
981
1406
|
blk.dirty = false;
|
|
982
1407
|
blk.snappedToGrid = false;
|
|
@@ -1016,10 +1441,19 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
|
1016
1441
|
buildHeadersAndFooters(doc);
|
|
1017
1442
|
doc.converged = true;
|
|
1018
1443
|
doc.iterationCount = 1;
|
|
1019
|
-
return { doc, forcedBreakPages };
|
|
1444
|
+
return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied };
|
|
1020
1445
|
}
|
|
1021
1446
|
export function buildDocument(content, config, cache, options) {
|
|
1022
|
-
|
|
1447
|
+
// --- Band caps (page-span blocks mid-page) -----------------------------
|
|
1448
|
+
// A span block that arrived in an uneven band proposes a cap; the driver
|
|
1449
|
+
// re-places the document with it (and grows / drops caps whose band
|
|
1450
|
+
// overflowed) before balancing runs. Documents without such blocks get
|
|
1451
|
+
// their first pass back untouched — no extra pass.
|
|
1452
|
+
const bands = resolveBandCaps(buildDocumentPass(content, config, cache, options), (bandCaps) => buildDocumentPass(content, config, cache, options, { bandCaps }));
|
|
1453
|
+
let best = bands.result;
|
|
1454
|
+
const bandCaps = bands.bandCaps;
|
|
1455
|
+
let passCount = bands.passCount;
|
|
1456
|
+
best.doc.iterationCount = passCount;
|
|
1023
1457
|
// --- Column balancing (vertical justification) ------------------------
|
|
1024
1458
|
// Iteratively re-place the document with extra grid lines above headings
|
|
1025
1459
|
// until every balanceable column ends flush with the page bottom (or no
|
|
@@ -1032,30 +1466,55 @@ export function buildDocument(content, config, cache, options) {
|
|
|
1032
1466
|
if (!balancing.enabled)
|
|
1033
1467
|
return best.doc;
|
|
1034
1468
|
let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
|
|
1035
|
-
let
|
|
1036
|
-
|
|
1469
|
+
let applied = { lines: new Map(), loose: new Map() };
|
|
1470
|
+
const failedLoose = new Set();
|
|
1037
1471
|
let converged = bestScore === 0;
|
|
1038
1472
|
while (!converged && passCount < MAX_BALANCING_PASSES) {
|
|
1039
|
-
const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages,
|
|
1473
|
+
const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
|
|
1474
|
+
maxLinesPerHeading: balancing.maxLinesPerHeading,
|
|
1475
|
+
stretchAfterLists: balancing.stretchAfterLists,
|
|
1476
|
+
maxLinesAfterList: balancing.maxLinesAfterList,
|
|
1477
|
+
looseParagraphs: balancing.looseParagraphs,
|
|
1478
|
+
optimalLineBreaking: best.doc.config.bodyText.optimalLineBreaking,
|
|
1479
|
+
failedLoose,
|
|
1480
|
+
});
|
|
1040
1481
|
if (!proposal.changed) {
|
|
1041
|
-
// No
|
|
1482
|
+
// No stretch point can absorb the remaining gaps — stable.
|
|
1042
1483
|
converged = true;
|
|
1043
1484
|
break;
|
|
1044
1485
|
}
|
|
1045
1486
|
const extraPx = new Map();
|
|
1046
1487
|
for (const [idx, n] of proposal.lines)
|
|
1047
1488
|
extraPx.set(idx, n * best.doc.baselineGrid);
|
|
1048
|
-
const next = buildDocumentPass(content, config, cache, options,
|
|
1489
|
+
const next = buildDocumentPass(content, config, cache, options, {
|
|
1490
|
+
balanceExtraPx: extraPx,
|
|
1491
|
+
balanceLooseness: proposal.loose,
|
|
1492
|
+
bandCaps,
|
|
1493
|
+
});
|
|
1049
1494
|
passCount++;
|
|
1050
|
-
|
|
1495
|
+
// Band caps ride along unchanged; a retry that unsettles one (its span
|
|
1496
|
+
// block no longer lands in the capped band) counts as a regression —
|
|
1497
|
+
// capped columns without their box are not a layout we may keep.
|
|
1498
|
+
const capsDelivered = [...bandCaps.keys()].every((i) => next.spanPlacedInBand.has(i));
|
|
1499
|
+
const score = capsDelivered ? totalGapLines(next.doc, next.forcedBreakPages) : Infinity;
|
|
1051
1500
|
if (score < bestScore) {
|
|
1052
1501
|
best = next;
|
|
1053
1502
|
bestScore = score;
|
|
1054
|
-
|
|
1503
|
+
applied = { lines: proposal.lines, loose: proposal.loose };
|
|
1055
1504
|
converged = score === 0;
|
|
1056
1505
|
}
|
|
1057
1506
|
else {
|
|
1058
|
-
// Plateau or regression
|
|
1507
|
+
// Plateau or regression. When this attempt introduced NEW loose
|
|
1508
|
+
// paragraphs, the K-P looseness fallback may simply have failed to
|
|
1509
|
+
// gain a line within the stretch limit — blacklist them and retry so
|
|
1510
|
+
// the proposer falls through to the next candidate. A pure spacing
|
|
1511
|
+
// plateau means we're done: keep the best layout found so far.
|
|
1512
|
+
const newlyLoose = [...proposal.loose.keys()].filter((k) => !applied.loose.has(k));
|
|
1513
|
+
if (newlyLoose.length > 0) {
|
|
1514
|
+
for (const k of newlyLoose)
|
|
1515
|
+
failedLoose.add(k);
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1059
1518
|
break;
|
|
1060
1519
|
}
|
|
1061
1520
|
}
|