postext 0.3.19 → 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__/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__/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.map +1 -1
- package/dist/defaults/headings.js +12 -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/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 +589 -159
- 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.map +1 -1
- package/dist/pipeline/columnBalancing.js +22 -2
- 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 +454 -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
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, bala
|
|
|
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, bala
|
|
|
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, bala
|
|
|
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, bala
|
|
|
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, bala
|
|
|
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, bala
|
|
|
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, bala
|
|
|
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, bala
|
|
|
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, bala
|
|
|
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, bala
|
|
|
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, bala
|
|
|
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,75 +957,6 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
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
|
-
// 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
960
|
const finalizeListItem = (blk, isFirstPart) => {
|
|
588
961
|
if (!listBullet)
|
|
589
962
|
return;
|
|
@@ -600,6 +973,12 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
600
973
|
// `indentPx + (maxNumberWidth - thisNumberWidth)` for ordered — giving
|
|
601
974
|
// the right-aligned separator.
|
|
602
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
|
+
}
|
|
603
982
|
if (strikethroughText)
|
|
604
983
|
blk.strikethroughText = true;
|
|
605
984
|
// Bullet Y = x-height midpoint of the item's first text line.
|
|
@@ -610,17 +989,21 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
610
989
|
blk.bulletY = firstLine.baseline - listBullet.textFontSizePx * 0.3 + listBullet.verticalOffsetPx;
|
|
611
990
|
}
|
|
612
991
|
};
|
|
613
|
-
|
|
992
|
+
// Neighbour lookaheads see through container markers.
|
|
993
|
+
const nextBlock = nextNonMarkerBlock(contentBlocks, blockIdx) ?? null;
|
|
994
|
+
const nextIsListItem = nextBlock?.type === 'listItem';
|
|
614
995
|
// For headings, only snap to baseline grid if the next block is NOT a heading.
|
|
615
996
|
// Consecutive headings flow without grid snapping; the last heading in the
|
|
616
997
|
// group snaps so that the following body text realigns with the grid.
|
|
617
998
|
// Same rule for list items: the LAST item of a list snaps so that text
|
|
618
999
|
// after the list realigns with the baseline grid, even when non-grid
|
|
619
|
-
// spacings (itemSpacing, marginTop/Bottom) were chosen.
|
|
620
|
-
|
|
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.
|
|
621
1003
|
const nextIsHeading = nextBlock?.type === 'heading';
|
|
622
1004
|
const shouldSnapToGrid = (vdtType === 'heading' && !nextIsHeading) ||
|
|
623
1005
|
(vdtType === 'listItem' && !nextIsListItem) ||
|
|
1006
|
+
(vdtType === 'paragraph' && isContainerTail) ||
|
|
624
1007
|
vdtType === 'mathDisplay';
|
|
625
1008
|
// Place block, splitting across columns/pages if needed.
|
|
626
1009
|
// List items may split too — orphan/widow protection per-list is gated by
|
|
@@ -638,6 +1021,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
638
1021
|
&& nextIsListItem
|
|
639
1022
|
&& /:\s*$/.test(contentBlock.text);
|
|
640
1023
|
while (remainingLines.length > 0) {
|
|
1024
|
+
enterBand(blockIdx, partIndex);
|
|
641
1025
|
const curCol = currentColumn(doc, cursor);
|
|
642
1026
|
const isFirstInColumn = curCol.blocks.length === 0;
|
|
643
1027
|
// Compute spacing before this block — margin collapsing between
|
|
@@ -660,7 +1044,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
660
1044
|
}
|
|
661
1045
|
}
|
|
662
1046
|
else if (vdtType === 'listItem') {
|
|
663
|
-
const prevWasList =
|
|
1047
|
+
const prevWasList = prevNonMarkerBlock(contentBlocks, blockIdx)?.type === 'listItem';
|
|
664
1048
|
if (!prevWasList) {
|
|
665
1049
|
spacingBefore = Math.max(spacingBefore, style.marginTopPx);
|
|
666
1050
|
}
|
|
@@ -693,11 +1077,15 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
693
1077
|
.map((ln) => (ln.segments ?? []).map((s) => s.text).join(''))
|
|
694
1078
|
.join(' ');
|
|
695
1079
|
const pref = numberPrefix ?? '';
|
|
696
|
-
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);
|
|
697
1081
|
// Span-page openers lay out across the full content area (both
|
|
698
1082
|
// columns); in-column headings use just the column width.
|
|
699
|
-
const
|
|
700
|
-
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
|
+
});
|
|
701
1089
|
if (designBottom > effectiveRemainHeight)
|
|
702
1090
|
effectiveRemainHeight = designBottom;
|
|
703
1091
|
}
|
|
@@ -762,7 +1150,7 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
762
1150
|
let headingRunCount = 0;
|
|
763
1151
|
if (resolved.headings.keepWithNext) {
|
|
764
1152
|
for (let j = curCol.blocks.length - 1; j >= 0; j--) {
|
|
765
|
-
if (curCol.blocks[j].type === 'heading')
|
|
1153
|
+
if (curCol.blocks[j].type === 'heading' && curCol.blocks[j].containerId === undefined)
|
|
766
1154
|
headingRunCount++;
|
|
767
1155
|
else
|
|
768
1156
|
break;
|
|
@@ -776,7 +1164,9 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
776
1164
|
doc.blocks.splice(idx, 1);
|
|
777
1165
|
curCol.availableHeight += p.bbox.height;
|
|
778
1166
|
}
|
|
779
|
-
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;
|
|
780
1170
|
pendingSpacing = 0;
|
|
781
1171
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
782
1172
|
break;
|
|
@@ -819,11 +1209,11 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
819
1209
|
if (remainAfterHeading < minSpaceAfter) {
|
|
820
1210
|
// Roll back any immediately-preceding heading blocks in this
|
|
821
1211
|
// column so they travel with this one.
|
|
822
|
-
const
|
|
823
|
-
if (
|
|
1212
|
+
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
1213
|
+
if (rolledBack.length > 0) {
|
|
824
1214
|
// Rewind so the for-loop's blockIdx++ lands on the first
|
|
825
|
-
// rolled-back heading.
|
|
826
|
-
blockIdx
|
|
1215
|
+
// rolled-back heading (marker blocks in between are replayed).
|
|
1216
|
+
blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
|
|
827
1217
|
pendingSpacing = 0;
|
|
828
1218
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
829
1219
|
break;
|
|
@@ -846,6 +1236,20 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
846
1236
|
if (numberPrefix)
|
|
847
1237
|
blk.numberPrefix = numberPrefix;
|
|
848
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
|
+
}
|
|
849
1253
|
if (vdtType === 'mathDisplay' && mathDisplayRender) {
|
|
850
1254
|
blk.mathRender = mathDisplayRender;
|
|
851
1255
|
blk.tex = rawBlock.tex;
|
|
@@ -969,9 +1373,9 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
969
1373
|
// pull those headings along so they don't remain stranded as orphans
|
|
970
1374
|
// at the column's bottom. Mirrors the rollback inside the "fits" path.
|
|
971
1375
|
if (vdtType === 'heading' && resolved.headings.keepWithNext) {
|
|
972
|
-
const
|
|
973
|
-
if (
|
|
974
|
-
blockIdx
|
|
1376
|
+
const rolledBack = rollbackTrailingBlocks(curCol, doc.blocks, isFreeHeading);
|
|
1377
|
+
if (rolledBack.length > 0) {
|
|
1378
|
+
blockIdx = (rolledBack[0].contentIndex ?? blockIdx - rolledBack.length) - 1;
|
|
975
1379
|
pendingSpacing = 0;
|
|
976
1380
|
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
977
1381
|
break;
|
|
@@ -988,6 +1392,16 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
988
1392
|
blk.contentIndex = blockIdx;
|
|
989
1393
|
if (partIndex === 0)
|
|
990
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
|
+
}
|
|
991
1405
|
blk.lines = resetLinePositions(remainingLines, style.lineHeightPx);
|
|
992
1406
|
blk.dirty = false;
|
|
993
1407
|
blk.snappedToGrid = false;
|
|
@@ -1027,10 +1441,19 @@ function buildDocumentPass(content, config, cache, options, balanceExtraPx, bala
|
|
|
1027
1441
|
buildHeadersAndFooters(doc);
|
|
1028
1442
|
doc.converged = true;
|
|
1029
1443
|
doc.iterationCount = 1;
|
|
1030
|
-
return { doc, forcedBreakPages };
|
|
1444
|
+
return { doc, forcedBreakPages, bandCapProposals, spanPlacedInBand, bandCapsApplied };
|
|
1031
1445
|
}
|
|
1032
1446
|
export function buildDocument(content, config, cache, options) {
|
|
1033
|
-
|
|
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;
|
|
1034
1457
|
// --- Column balancing (vertical justification) ------------------------
|
|
1035
1458
|
// Iteratively re-place the document with extra grid lines above headings
|
|
1036
1459
|
// until every balanceable column ends flush with the page bottom (or no
|
|
@@ -1045,7 +1468,6 @@ export function buildDocument(content, config, cache, options) {
|
|
|
1045
1468
|
let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
|
|
1046
1469
|
let applied = { lines: new Map(), loose: new Map() };
|
|
1047
1470
|
const failedLoose = new Set();
|
|
1048
|
-
let passCount = 1;
|
|
1049
1471
|
let converged = bestScore === 0;
|
|
1050
1472
|
while (!converged && passCount < MAX_BALANCING_PASSES) {
|
|
1051
1473
|
const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, applied, {
|
|
@@ -1064,9 +1486,17 @@ export function buildDocument(content, config, cache, options) {
|
|
|
1064
1486
|
const extraPx = new Map();
|
|
1065
1487
|
for (const [idx, n] of proposal.lines)
|
|
1066
1488
|
extraPx.set(idx, n * best.doc.baselineGrid);
|
|
1067
|
-
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
|
+
});
|
|
1068
1494
|
passCount++;
|
|
1069
|
-
|
|
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;
|
|
1070
1500
|
if (score < bestScore) {
|
|
1071
1501
|
best = next;
|
|
1072
1502
|
bestScore = score;
|