postext 0.3.16 → 0.3.18
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.d.ts +2 -0
- package/dist/__tests__/columnBalancing.test.d.ts.map +1 -0
- package/dist/__tests__/columnBalancing.test.js +194 -0
- package/dist/__tests__/columnBalancing.test.js.map +1 -0
- package/dist/__tests__/createLayout.test.js +12 -13
- package/dist/__tests__/createLayout.test.js.map +1 -1
- package/dist/__tests__/defaults/resourceTypes.test.d.ts +2 -0
- package/dist/__tests__/defaults/resourceTypes.test.d.ts.map +1 -0
- package/dist/__tests__/defaults/resourceTypes.test.js +69 -0
- package/dist/__tests__/defaults/resourceTypes.test.js.map +1 -0
- package/dist/__tests__/exports.test.js +60 -0
- package/dist/__tests__/exports.test.js.map +1 -1
- package/dist/__tests__/parse/inlineRef.test.d.ts +2 -0
- package/dist/__tests__/parse/inlineRef.test.d.ts.map +1 -0
- package/dist/__tests__/parse/inlineRef.test.js +83 -0
- package/dist/__tests__/parse/inlineRef.test.js.map +1 -0
- package/dist/__tests__/parse/resourceDirective.test.d.ts +2 -0
- package/dist/__tests__/parse/resourceDirective.test.d.ts.map +1 -0
- package/dist/__tests__/parse/resourceDirective.test.js +55 -0
- package/dist/__tests__/parse/resourceDirective.test.js.map +1 -0
- package/dist/__tests__/pipeline/floatPlacement.test.d.ts +2 -0
- package/dist/__tests__/pipeline/floatPlacement.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/floatPlacement.test.js +262 -0
- package/dist/__tests__/pipeline/floatPlacement.test.js.map +1 -0
- package/dist/__tests__/pipeline/inlineRefRender.test.d.ts +2 -0
- package/dist/__tests__/pipeline/inlineRefRender.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/inlineRefRender.test.js +107 -0
- package/dist/__tests__/pipeline/inlineRefRender.test.js.map +1 -0
- package/dist/__tests__/pipeline/resourceNumbering.test.d.ts +2 -0
- package/dist/__tests__/pipeline/resourceNumbering.test.d.ts.map +1 -0
- package/dist/__tests__/pipeline/resourceNumbering.test.js +186 -0
- package/dist/__tests__/pipeline/resourceNumbering.test.js.map +1 -0
- package/dist/__tests__/singleInk.test.d.ts +2 -0
- package/dist/__tests__/singleInk.test.d.ts.map +1 -0
- package/dist/__tests__/singleInk.test.js +42 -0
- package/dist/__tests__/singleInk.test.js.map +1 -0
- package/dist/__tests__/table/model.test.d.ts +2 -0
- package/dist/__tests__/table/model.test.d.ts.map +1 -0
- package/dist/__tests__/table/model.test.js +187 -0
- package/dist/__tests__/table/model.test.js.map +1 -0
- package/dist/canvas-backend/blockRender.d.ts.map +1 -1
- package/dist/canvas-backend/blockRender.js +103 -66
- package/dist/canvas-backend/blockRender.js.map +1 -1
- package/dist/canvas-backend/decorations.d.ts +4 -1
- package/dist/canvas-backend/decorations.d.ts.map +1 -1
- package/dist/canvas-backend/decorations.js +14 -6
- package/dist/canvas-backend/decorations.js.map +1 -1
- package/dist/canvas-backend/headerFooter.d.ts +2 -2
- package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
- package/dist/canvas-backend/headerFooter.js +62 -2
- package/dist/canvas-backend/headerFooter.js.map +1 -1
- package/dist/canvas-backend/index.d.ts +2 -0
- package/dist/canvas-backend/index.d.ts.map +1 -1
- package/dist/canvas-backend/index.js +20 -10
- package/dist/canvas-backend/index.js.map +1 -1
- package/dist/canvas-backend/renderResourceBlock.d.ts +28 -0
- package/dist/canvas-backend/renderResourceBlock.d.ts.map +1 -0
- package/dist/canvas-backend/renderResourceBlock.js +146 -0
- package/dist/canvas-backend/renderResourceBlock.js.map +1 -0
- package/dist/defaults/bodyText.d.ts.map +1 -1
- package/dist/defaults/bodyText.js +30 -0
- package/dist/defaults/bodyText.js.map +1 -1
- package/dist/defaults/captionStyle.d.ts +9 -0
- package/dist/defaults/captionStyle.d.ts.map +1 -0
- package/dist/defaults/captionStyle.js +74 -0
- package/dist/defaults/captionStyle.js.map +1 -0
- package/dist/defaults/debug.d.ts.map +1 -1
- package/dist/defaults/debug.js +6 -0
- package/dist/defaults/debug.js.map +1 -1
- package/dist/defaults/diagramStyle.d.ts +5 -0
- package/dist/defaults/diagramStyle.d.ts.map +1 -0
- package/dist/defaults/diagramStyle.js +29 -0
- package/dist/defaults/diagramStyle.js.map +1 -0
- package/dist/defaults/headerFooter.d.ts +20 -18
- package/dist/defaults/headerFooter.d.ts.map +1 -1
- package/dist/defaults/headerFooter.js +269 -165
- package/dist/defaults/headerFooter.js.map +1 -1
- package/dist/defaults/headings.d.ts +4 -0
- package/dist/defaults/headings.d.ts.map +1 -1
- package/dist/defaults/headings.js +65 -9
- package/dist/defaults/headings.js.map +1 -1
- package/dist/defaults/index.d.ts +5 -1
- package/dist/defaults/index.d.ts.map +1 -1
- package/dist/defaults/index.js +29 -1
- package/dist/defaults/index.js.map +1 -1
- package/dist/defaults/resourceTypes.d.ts +8 -0
- package/dist/defaults/resourceTypes.d.ts.map +1 -0
- package/dist/defaults/resourceTypes.js +43 -0
- package/dist/defaults/resourceTypes.js.map +1 -0
- package/dist/defaults/shared.d.ts.map +1 -1
- package/dist/defaults/shared.js +40 -0
- package/dist/defaults/shared.js.map +1 -1
- package/dist/defaults/tableStyle.d.ts +11 -0
- package/dist/defaults/tableStyle.d.ts.map +1 -0
- package/dist/defaults/tableStyle.js +114 -0
- package/dist/defaults/tableStyle.js.map +1 -0
- package/dist/design/layout.d.ts +94 -0
- package/dist/design/layout.d.ts.map +1 -0
- package/dist/design/layout.js +642 -0
- package/dist/design/layout.js.map +1 -0
- package/dist/design/placeholders.d.ts +29 -0
- package/dist/design/placeholders.d.ts.map +1 -0
- package/dist/design/placeholders.js +126 -0
- package/dist/design/placeholders.js.map +1 -0
- package/dist/html-backend.d.ts +6 -0
- package/dist/html-backend.d.ts.map +1 -1
- package/dist/html-backend.js +274 -10
- package/dist/html-backend.js.map +1 -1
- package/dist/index.d.ts +14 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/knuthPlass/breakpoints.d.ts.map +1 -1
- package/dist/knuthPlass/breakpoints.js +41 -98
- package/dist/knuthPlass/breakpoints.js.map +1 -1
- package/dist/knuthPlass/richAdapter.d.ts +1 -0
- package/dist/knuthPlass/richAdapter.d.ts.map +1 -1
- package/dist/knuthPlass/richAdapter.js +2 -1
- package/dist/knuthPlass/richAdapter.js.map +1 -1
- package/dist/measure/cache.js +1 -1
- package/dist/measure/cache.js.map +1 -1
- package/dist/measure/canvas.d.ts +3 -0
- package/dist/measure/canvas.d.ts.map +1 -1
- package/dist/measure/canvas.js +40 -2
- package/dist/measure/canvas.js.map +1 -1
- package/dist/measure/font.d.ts +3 -2
- package/dist/measure/font.d.ts.map +1 -1
- package/dist/measure/font.js +5 -2
- package/dist/measure/font.js.map +1 -1
- package/dist/measure/plain.d.ts.map +1 -1
- package/dist/measure/plain.js +14 -7
- package/dist/measure/plain.js.map +1 -1
- package/dist/measure/rich.d.ts +7 -0
- package/dist/measure/rich.d.ts.map +1 -1
- package/dist/measure/rich.js +34 -7
- package/dist/measure/rich.js.map +1 -1
- package/dist/numbering.d.ts +16 -0
- package/dist/numbering.d.ts.map +1 -1
- package/dist/numbering.js +28 -18
- package/dist/numbering.js.map +1 -1
- package/dist/parse/blockParser.d.ts.map +1 -1
- package/dist/parse/blockParser.js +37 -9
- package/dist/parse/blockParser.js.map +1 -1
- package/dist/parse/injectSpans.d.ts +9 -0
- package/dist/parse/injectSpans.d.ts.map +1 -0
- package/dist/parse/injectSpans.js +35 -0
- package/dist/parse/injectSpans.js.map +1 -0
- package/dist/parse/inlineFormatting.d.ts +38 -0
- package/dist/parse/inlineFormatting.d.ts.map +1 -1
- package/dist/parse/inlineFormatting.js +58 -0
- package/dist/parse/inlineFormatting.js.map +1 -1
- package/dist/parse/inlineMath.d.ts.map +1 -1
- package/dist/parse/inlineMath.js +26 -43
- package/dist/parse/inlineMath.js.map +1 -1
- package/dist/parse/sourceMapping.d.ts.map +1 -1
- package/dist/parse/sourceMapping.js +34 -7
- package/dist/parse/sourceMapping.js.map +1 -1
- package/dist/parse/types.d.ts +20 -1
- package/dist/parse/types.d.ts.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +521 -28
- package/dist/pipeline/build.js.map +1 -1
- package/dist/pipeline/buildBlockKind.d.ts +14 -0
- package/dist/pipeline/buildBlockKind.d.ts.map +1 -1
- package/dist/pipeline/buildBlockKind.js +16 -1
- package/dist/pipeline/buildBlockKind.js.map +1 -1
- package/dist/pipeline/buildHelpers.d.ts.map +1 -1
- package/dist/pipeline/buildHelpers.js +7 -1
- package/dist/pipeline/buildHelpers.js.map +1 -1
- package/dist/pipeline/buildMeasurement.d.ts +17 -1
- package/dist/pipeline/buildMeasurement.d.ts.map +1 -1
- package/dist/pipeline/buildMeasurement.js +32 -0
- package/dist/pipeline/buildMeasurement.js.map +1 -1
- package/dist/pipeline/columnBalancing.d.ts +75 -0
- package/dist/pipeline/columnBalancing.d.ts.map +1 -0
- package/dist/pipeline/columnBalancing.js +125 -0
- package/dist/pipeline/columnBalancing.js.map +1 -0
- package/dist/pipeline/config.d.ts +4 -1
- package/dist/pipeline/config.d.ts.map +1 -1
- package/dist/pipeline/config.js +12 -1
- package/dist/pipeline/config.js.map +1 -1
- package/dist/pipeline/floatPlacement.d.ts +45 -0
- package/dist/pipeline/floatPlacement.d.ts.map +1 -0
- package/dist/pipeline/floatPlacement.js +68 -0
- package/dist/pipeline/floatPlacement.js.map +1 -0
- package/dist/pipeline/headerFooter.d.ts +23 -7
- package/dist/pipeline/headerFooter.d.ts.map +1 -1
- package/dist/pipeline/headerFooter.js +260 -100
- package/dist/pipeline/headerFooter.js.map +1 -1
- package/dist/pipeline/lists.d.ts +4 -9
- package/dist/pipeline/lists.d.ts.map +1 -1
- package/dist/pipeline/lists.js +24 -42
- package/dist/pipeline/lists.js.map +1 -1
- package/dist/pipeline/placeholders.d.ts +6 -0
- package/dist/pipeline/placeholders.d.ts.map +1 -1
- package/dist/pipeline/placeholders.js +18 -5
- package/dist/pipeline/placeholders.js.map +1 -1
- package/dist/pipeline/placement.d.ts +15 -2
- package/dist/pipeline/placement.d.ts.map +1 -1
- package/dist/pipeline/placement.js +38 -3
- package/dist/pipeline/placement.js.map +1 -1
- package/dist/pipeline/resourceLayout.d.ts +58 -0
- package/dist/pipeline/resourceLayout.d.ts.map +1 -0
- package/dist/pipeline/resourceLayout.js +338 -0
- package/dist/pipeline/resourceLayout.js.map +1 -0
- package/dist/pipeline/resourceNumbering.d.ts +54 -0
- package/dist/pipeline/resourceNumbering.d.ts.map +1 -0
- package/dist/pipeline/resourceNumbering.js +218 -0
- package/dist/pipeline/resourceNumbering.js.map +1 -0
- package/dist/pipeline/styles.d.ts +6 -0
- package/dist/pipeline/styles.d.ts.map +1 -1
- package/dist/pipeline/styles.js +1 -1
- package/dist/pipeline/styles.js.map +1 -1
- package/dist/svg/singleInk.d.ts +10 -0
- package/dist/svg/singleInk.d.ts.map +1 -0
- package/dist/svg/singleInk.js +86 -0
- package/dist/svg/singleInk.js.map +1 -0
- package/dist/table/model.d.ts +53 -0
- package/dist/table/model.d.ts.map +1 -0
- package/dist/table/model.js +253 -0
- package/dist/table/model.js.map +1 -0
- package/dist/types.d.ts +429 -41
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +181 -18
- package/dist/vdt.d.ts.map +1 -1
- package/dist/vdt.js +34 -0
- package/dist/vdt.js.map +1 -1
- package/package.json +6 -6
package/dist/pipeline/build.js
CHANGED
|
@@ -1,26 +1,47 @@
|
|
|
1
1
|
import { dimensionToPx } from '../units';
|
|
2
|
-
import { createVDTDocument, createVDTBlock, } from '../vdt';
|
|
2
|
+
import { createVDTDocument, createVDTBlock, createBoundingBox, } from '../vdt';
|
|
3
3
|
import { parseMarkdownMemo } from '../parse';
|
|
4
4
|
import { buildPageLabels, computeHeadingNumbers, } from '../numbering';
|
|
5
5
|
import { extractFrontmatter } from '../frontmatter';
|
|
6
6
|
import { initHyphenator } from '../measure';
|
|
7
|
-
import { resolveAllConfig, computeBaselineGrid } from './config';
|
|
7
|
+
import { resolveAllConfig, computeBaselineGrid, buildHeadingLevelMap } from './config';
|
|
8
8
|
import { resolveBodyStyle, resolveBlockquoteStyle } from './styles';
|
|
9
9
|
import { computeLevelIndentsPx, computeOrderedLevelIndentsPx, computeOrderedListRunMetrics, } from './lists';
|
|
10
|
-
import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, } from './placement';
|
|
10
|
+
import { resetLinePositions, createPageWithColumns, currentColumn, advanceToNextColumn, advanceToNextPageBoundary, enforcePageParity, placeBlockInColumn, placeResourceBlock, } from './placement';
|
|
11
11
|
import { chooseParagraphSplit } from './orphanWidow';
|
|
12
12
|
import { applyStyleAttrs, computeMeasureViewport, computePageMetrics, enrichMathSpans, rollbackTrailingBlocks, stampSourceRanges, } from './buildHelpers';
|
|
13
13
|
import { resolveBlockKind } from './buildBlockKind';
|
|
14
14
|
import { runMeasurement } from './buildMeasurement';
|
|
15
|
-
import {
|
|
15
|
+
import { resolveRefSpans, layoutResourceBlock } from './resourceLayout';
|
|
16
|
+
import { computeFloatPlan, floatedResourceIds, } from './floatPlacement';
|
|
17
|
+
import { computeHeadingContext, computeResourceNumbering, } from './resourceNumbering';
|
|
18
|
+
import { defaultResourceTypes } from '../defaults/resourceTypes';
|
|
19
|
+
import { buildHeadersAndFooters, measureHeadingAdvancedDesignHeight } from './headerFooter';
|
|
20
|
+
import { totalGapLines, proposeBalanceLines, MAX_BALANCING_PASSES } from './columnBalancing';
|
|
16
21
|
export class BuildCancelledError extends Error {
|
|
17
22
|
constructor() {
|
|
18
23
|
super('Build cancelled');
|
|
19
24
|
this.name = 'BuildCancelledError';
|
|
20
25
|
}
|
|
21
26
|
}
|
|
22
|
-
|
|
27
|
+
/** Page-number formats accepted by the `::numbering` directive. */
|
|
28
|
+
const ALLOWED_PAGE_FORMATS = new Set([
|
|
29
|
+
'decimal',
|
|
30
|
+
'lower-roman',
|
|
31
|
+
'upper-roman',
|
|
32
|
+
'lower-alpha',
|
|
33
|
+
'upper-alpha',
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* Single placement pass. `balanceExtraPx` carries the column-balancing
|
|
37
|
+
* adjustments (extra top spacing per heading, keyed by content-block index);
|
|
38
|
+
* `forcedBreakPages` reports the pages whose break into the next page was
|
|
39
|
+
* explicit (`:::pagebreak`, heading `breakBefore`, chapter opener) rather
|
|
40
|
+
* than natural content overflow — those pages keep their short last column.
|
|
41
|
+
*/
|
|
42
|
+
function buildDocumentPass(content, config, cache, options, balanceExtraPx) {
|
|
23
43
|
const resolved = resolveAllConfig(config);
|
|
44
|
+
const headingLevelByNumber = buildHeadingLevelMap(resolved);
|
|
24
45
|
const dpi = resolved.page.dpi;
|
|
25
46
|
// Initialize hyphenator if needed
|
|
26
47
|
if (resolved.bodyText.hyphenation.enabled && resolved.bodyText.textAlign === 'justify') {
|
|
@@ -46,16 +67,266 @@ export function buildDocument(content, config, cache, options) {
|
|
|
46
67
|
}
|
|
47
68
|
}
|
|
48
69
|
const headingPrefixes = computeHeadingNumbers(contentBlocks, headingTemplates);
|
|
70
|
+
// Resource numbering — computed up front (before the placement loop) so that
|
|
71
|
+
// captions and inline `:ref`s can resolve their rendered number strings
|
|
72
|
+
// before measurement. Numbering follows order of first reference in the
|
|
73
|
+
// document.
|
|
74
|
+
const resourceTypes = config?.resourceTypes ?? defaultResourceTypes();
|
|
75
|
+
const resources = content.resources ?? [];
|
|
76
|
+
const headingContext = computeHeadingContext(contentBlocks);
|
|
77
|
+
const resourceNumbering = computeResourceNumbering(contentBlocks, resourceTypes, resources, headingContext);
|
|
78
|
+
// Lookups threaded into block-kind resolution + measurement.
|
|
79
|
+
const resourceById = new Map();
|
|
80
|
+
for (const r of resources)
|
|
81
|
+
resourceById.set(r.id, r);
|
|
82
|
+
const resourceTypeById = new Map();
|
|
83
|
+
for (const t of resourceTypes)
|
|
84
|
+
resourceTypeById.set(t.id, t);
|
|
85
|
+
const resourceNumberById = new Map();
|
|
86
|
+
for (const [id, entry] of Object.entries(resourceNumbering)) {
|
|
87
|
+
resourceNumberById.set(id, entry.number);
|
|
88
|
+
}
|
|
49
89
|
// Resolve styles
|
|
50
90
|
const bodyStyle = resolveBodyStyle(resolved);
|
|
51
91
|
const blockquoteStyle = resolveBlockquoteStyle(resolved);
|
|
52
92
|
const listLevelIndentsPx = computeLevelIndentsPx(resolved, bodyStyle.fontSizePx);
|
|
53
93
|
const orderedMetrics = computeOrderedListRunMetrics(contentBlocks, resolved, bodyStyle.fontSizePx);
|
|
54
94
|
const orderedLevelIndentsPx = computeOrderedLevelIndentsPx(resolved, bodyStyle.fontSizePx, orderedMetrics.maxWidthByDepth);
|
|
95
|
+
// --- Float planning (issue #49 — resources float to page bands) ----------
|
|
96
|
+
// A resource is incorporated by its first reference (an inline `:ref` or a
|
|
97
|
+
// `::resource` directive, whichever comes first in reading order). Floated
|
|
98
|
+
// resources detach from the running text and reserve a band at the top or
|
|
99
|
+
// bottom of the next page opened after that reference; the text flows past
|
|
100
|
+
// the reference uninterrupted. `position: 'here'` resources keep inline
|
|
101
|
+
// `::resource` placement and are not floated.
|
|
102
|
+
const floatPlan = computeFloatPlan(contentBlocks, resources, resourceTypes);
|
|
103
|
+
const floatedIds = floatedResourceIds(floatPlan);
|
|
104
|
+
const floatsByFirstBlock = new Map();
|
|
105
|
+
for (const f of floatPlan) {
|
|
106
|
+
const list = floatsByFirstBlock.get(f.firstBlockIdx);
|
|
107
|
+
if (list)
|
|
108
|
+
list.push(f);
|
|
109
|
+
else
|
|
110
|
+
floatsByFirstBlock.set(f.firstBlockIdx, [f]);
|
|
111
|
+
}
|
|
112
|
+
// Floats whose first reference has been passed but which are not yet placed
|
|
113
|
+
// into a page band, in reading order.
|
|
114
|
+
const pendingFloats = [];
|
|
115
|
+
const floatGapPx = bodyStyle.lineHeightPx;
|
|
116
|
+
const minTextPx = bodyStyle.lineHeightPx * 3;
|
|
117
|
+
/** Offset a resolved resource block's caption/table geometry from
|
|
118
|
+
* block-relative to absolute page coordinates (mirrors inline placement). */
|
|
119
|
+
const offsetResourceBlockToAbsolute = (rb, ox, oy) => {
|
|
120
|
+
for (const ln of rb.captionLines) {
|
|
121
|
+
ln.bbox.x += ox;
|
|
122
|
+
ln.bbox.y += oy;
|
|
123
|
+
ln.baseline += oy;
|
|
124
|
+
}
|
|
125
|
+
if (rb.table) {
|
|
126
|
+
for (const cell of rb.table.cells) {
|
|
127
|
+
cell.rect.x += ox;
|
|
128
|
+
cell.rect.y += oy;
|
|
129
|
+
for (const cl of cell.lines) {
|
|
130
|
+
cl.bbox.x += ox;
|
|
131
|
+
cl.bbox.y += oy;
|
|
132
|
+
cl.baseline += oy;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
/** Measure + build a float block at horizontal offset `x` (y = 0), or null
|
|
138
|
+
* when the resource id is unknown. Caller offsets it to its final `y`. */
|
|
139
|
+
const buildFloatBlock = (resourceId, x, width) => {
|
|
140
|
+
const resource = resourceById.get(resourceId);
|
|
141
|
+
if (!resource)
|
|
142
|
+
return null;
|
|
143
|
+
const { block: rb, totalHeight } = layoutResourceBlock({
|
|
144
|
+
resource,
|
|
145
|
+
resourceType: resourceTypeById.get(resource.typeId),
|
|
146
|
+
number: resourceNumberById.get(resourceId) ?? '',
|
|
147
|
+
resolved,
|
|
148
|
+
columnWidth: width,
|
|
149
|
+
resourceNumbering,
|
|
150
|
+
resourceTypes,
|
|
151
|
+
resources,
|
|
152
|
+
});
|
|
153
|
+
const blk = createVDTBlock(`float-${resourceId}`, 'resource', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
|
|
154
|
+
blk.resourceBlock = rb;
|
|
155
|
+
blk.dirty = false;
|
|
156
|
+
blk.snappedToGrid = false;
|
|
157
|
+
blk.bbox = createBoundingBox(x, 0, width, totalHeight);
|
|
158
|
+
blk.lines = [];
|
|
159
|
+
offsetResourceBlockToAbsolute(rb, x, 0);
|
|
160
|
+
return { block: blk, height: totalHeight };
|
|
161
|
+
};
|
|
162
|
+
/** Reserve top/bottom bands on a freshly opened page and position as many
|
|
163
|
+
* pending floats as fit, shrinking the affected columns so body text flows
|
|
164
|
+
* around them. Preserves reading order: stops at the first float that does
|
|
165
|
+
* not fit (so figures never reorder relative to their references), except
|
|
166
|
+
* on a band that is still all-text, where a dominating/oversized float is
|
|
167
|
+
* force-placed so the queue always makes progress. */
|
|
168
|
+
const flushFloatsIntoPage = (page) => {
|
|
169
|
+
if (pendingFloats.length === 0)
|
|
170
|
+
return;
|
|
171
|
+
const topUsed = page.columns.map(() => 0);
|
|
172
|
+
const botUsed = page.columns.map(() => 0);
|
|
173
|
+
const floats = page.floats ?? [];
|
|
174
|
+
/** Try to place one float on this page. Returns whether it was placed,
|
|
175
|
+
* must be deferred (does not fit), or skipped (unknown id). Only mutates
|
|
176
|
+
* page geometry when it actually places. */
|
|
177
|
+
const attemptFloat = (f) => {
|
|
178
|
+
const pageSpan = f.span === 'page' && page.columns.length > 1;
|
|
179
|
+
let targetCols;
|
|
180
|
+
if (pageSpan) {
|
|
181
|
+
targetCols = page.columns.map((_, i) => i);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// Single-column float: pick the column with the most room left.
|
|
185
|
+
let best = 0;
|
|
186
|
+
for (let i = 1; i < page.columns.length; i++) {
|
|
187
|
+
if (topUsed[i] + botUsed[i] < topUsed[best] + botUsed[best])
|
|
188
|
+
best = i;
|
|
189
|
+
}
|
|
190
|
+
targetCols = [best];
|
|
191
|
+
}
|
|
192
|
+
const firstCol = page.columns[targetCols[0]];
|
|
193
|
+
const width = pageSpan ? contentArea.width : firstCol.bbox.width;
|
|
194
|
+
const xLeft = pageSpan ? contentArea.x : firstCol.bbox.x;
|
|
195
|
+
const built = buildFloatBlock(f.resourceId, xLeft, width);
|
|
196
|
+
if (!built)
|
|
197
|
+
return 'skip';
|
|
198
|
+
let minAvail = Infinity;
|
|
199
|
+
let anyReserved = false;
|
|
200
|
+
for (const c of targetCols) {
|
|
201
|
+
minAvail = Math.min(minAvail, page.columns[c].availableHeight);
|
|
202
|
+
if (topUsed[c] > 0 || botUsed[c] > 0)
|
|
203
|
+
anyReserved = true;
|
|
204
|
+
}
|
|
205
|
+
// Both band kinds are corrected against the baseline grid so the text
|
|
206
|
+
// around them — and the facing page — stays on the global rhythm:
|
|
207
|
+
// - top: the band pushes the column start (`col.bbox.y`) downward, and
|
|
208
|
+
// all grid snapping inside the column is anchored at that start. The
|
|
209
|
+
// band height is rounded up to a grid multiple (growing the gap
|
|
210
|
+
// below the float) or every line in the displaced column would land
|
|
211
|
+
// off-grid, visibly misaligned with neighbouring columns.
|
|
212
|
+
// - bottom: the float is anchored so its visual bottom sits on the
|
|
213
|
+
// grid — the caption's last line shares its baseline with the last
|
|
214
|
+
// text line of the other columns (captionless content aligns its
|
|
215
|
+
// bottom edge to the last grid slot). Pages then end at the same
|
|
216
|
+
// height across columns and across facing pages.
|
|
217
|
+
let need;
|
|
218
|
+
let floatY = 0;
|
|
219
|
+
if (f.position === 'top') {
|
|
220
|
+
const rawNeed = built.height + floatGapPx;
|
|
221
|
+
need = Math.ceil((rawNeed - 0.01) / baselineGrid) * baselineGrid;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
const bottomLimit = Math.min(...targetCols.map((c) => {
|
|
225
|
+
const cb = page.columns[c].bbox;
|
|
226
|
+
return cb.y + cb.height;
|
|
227
|
+
}));
|
|
228
|
+
const gridAlignedBottom = contentArea.y
|
|
229
|
+
+ Math.floor((bottomLimit - contentArea.y + 0.01) / baselineGrid) * baselineGrid;
|
|
230
|
+
const capLines = built.block.resourceBlock.captionLines;
|
|
231
|
+
if (capLines.length > 0) {
|
|
232
|
+
// Body baselines sit at 0.2 × grid above each slot bottom; anchor
|
|
233
|
+
// the caption's last baseline there.
|
|
234
|
+
const lastBaseline = capLines[capLines.length - 1].baseline; // block-relative
|
|
235
|
+
floatY = gridAlignedBottom - 0.2 * baselineGrid - lastBaseline;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
floatY = gridAlignedBottom - built.height;
|
|
239
|
+
}
|
|
240
|
+
need = 0;
|
|
241
|
+
for (const c of targetCols) {
|
|
242
|
+
const col = page.columns[c];
|
|
243
|
+
need = Math.max(need, col.bbox.height - (floatY - floatGapPx - col.bbox.y));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Keep some text room, unless this band is still all-text (then a
|
|
247
|
+
// dominating / oversized float is force-placed so the queue progresses).
|
|
248
|
+
if (need > minAvail - minTextPx && anyReserved)
|
|
249
|
+
return 'defer';
|
|
250
|
+
let y = 0;
|
|
251
|
+
for (const c of targetCols) {
|
|
252
|
+
const col = page.columns[c];
|
|
253
|
+
if (f.position === 'top') {
|
|
254
|
+
y = col.bbox.y; // float sits at the current top edge
|
|
255
|
+
col.bbox.y += need; // push column content below the band
|
|
256
|
+
col.bbox.height = Math.max(0, col.bbox.height - need);
|
|
257
|
+
col.availableHeight = Math.max(0, col.availableHeight - need);
|
|
258
|
+
topUsed[c] += need;
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
y = floatY;
|
|
262
|
+
const newHeight = Math.max(0, floatY - floatGapPx - col.bbox.y);
|
|
263
|
+
const reserved = col.bbox.height - newHeight;
|
|
264
|
+
col.bbox.height = newHeight;
|
|
265
|
+
col.availableHeight = Math.max(0, col.availableHeight - reserved);
|
|
266
|
+
botUsed[c] += reserved;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
offsetResourceBlockToAbsolute(built.block.resourceBlock, 0, y);
|
|
270
|
+
built.block.bbox = createBoundingBox(xLeft, y, width, built.height);
|
|
271
|
+
built.block.pageIndex = page.index;
|
|
272
|
+
built.block.columnIndex = targetCols[0];
|
|
273
|
+
floats.push(built.block);
|
|
274
|
+
return 'placed';
|
|
275
|
+
};
|
|
276
|
+
// Full-width (page-span) floats reserve the outermost bands first, so a
|
|
277
|
+
// later single-column float nests inside the remaining column space rather
|
|
278
|
+
// than overlapping a full-width band. Within each pass, stop at the first
|
|
279
|
+
// float that does not fit to preserve reading order.
|
|
280
|
+
for (const pageSpanPass of [true, false]) {
|
|
281
|
+
let i = 0;
|
|
282
|
+
while (i < pendingFloats.length) {
|
|
283
|
+
const f = pendingFloats[i];
|
|
284
|
+
const isPageSpan = f.span === 'page' && page.columns.length > 1;
|
|
285
|
+
if (isPageSpan !== pageSpanPass) {
|
|
286
|
+
i++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const r = attemptFloat(f);
|
|
290
|
+
if (r === 'placed' || r === 'skip')
|
|
291
|
+
pendingFloats.splice(i, 1);
|
|
292
|
+
else
|
|
293
|
+
break; // defer: leave this and the rest of the pass for a later page
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (floats.length > 0)
|
|
297
|
+
page.floats = floats;
|
|
298
|
+
};
|
|
299
|
+
/** Drain floats still pending after body placement (referenced on the last
|
|
300
|
+
* page, or never followed by a content-overflow page break) onto freshly
|
|
301
|
+
* appended pages. Each new page force-places at least one float. */
|
|
302
|
+
const finalizeFloats = () => {
|
|
303
|
+
let guard = 0;
|
|
304
|
+
while (pendingFloats.length > 0 && guard++ < 1000) {
|
|
305
|
+
const before = pendingFloats.length;
|
|
306
|
+
const page = createPageWithColumns(doc.pages.length, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
307
|
+
doc.pages.push(page);
|
|
308
|
+
flushFloatsIntoPage(page);
|
|
309
|
+
if (pendingFloats.length === before)
|
|
310
|
+
break; // safety: no progress
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
/** Reserve floats on each freshly opened content page. Passed only to the
|
|
314
|
+
* content-flow column advances — parity / force-blank pages never get it. */
|
|
315
|
+
const onNewPage = (page) => flushFloatsIntoPage(page);
|
|
55
316
|
// Placement cursor
|
|
56
317
|
const cursor = { pageIndex: 0, columnIndex: 0 };
|
|
57
318
|
let blockIdCounter = 0;
|
|
58
319
|
let pendingSpacing = 0;
|
|
320
|
+
// Pages whose break into the next page is explicit rather than natural
|
|
321
|
+
// overflow. Column balancing leaves their last column short (a chapter's
|
|
322
|
+
// closing page legitimately ends early).
|
|
323
|
+
const forcedBreakPages = new Set();
|
|
324
|
+
const markForcedBreak = () => {
|
|
325
|
+
const curPage = doc.pages[cursor.pageIndex];
|
|
326
|
+
if (curPage.columns.some((c) => c.blocks.length > 0)) {
|
|
327
|
+
forcedBreakPages.add(cursor.pageIndex);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
59
330
|
// Page-numbering segments. The implicit first segment comes from
|
|
60
331
|
// `cfg.page.pageNumbering`; `:::numbering` directives append more,
|
|
61
332
|
// each applied at the next page boundary.
|
|
@@ -82,23 +353,24 @@ export function buildDocument(content, config, cache, options) {
|
|
|
82
353
|
lastSeenPageIndex = cursor.pageIndex;
|
|
83
354
|
}
|
|
84
355
|
};
|
|
85
|
-
const ALLOWED_PAGE_FORMATS = new Set([
|
|
86
|
-
'decimal',
|
|
87
|
-
'lower-roman',
|
|
88
|
-
'upper-roman',
|
|
89
|
-
'lower-alpha',
|
|
90
|
-
'upper-alpha',
|
|
91
|
-
]);
|
|
92
356
|
for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) {
|
|
93
357
|
if (options?.shouldCancel?.())
|
|
94
358
|
throw new BuildCancelledError();
|
|
95
359
|
const rawBlock = contentBlocks[blockIdx];
|
|
360
|
+
// Enqueue floats first-referenced in this block so the next page opened
|
|
361
|
+
// while placing it (or any later block) reserves their band. Done before
|
|
362
|
+
// placement so a reference near a column/page boundary still floats onto
|
|
363
|
+
// the page that follows it.
|
|
364
|
+
const floatsHere = floatsByFirstBlock.get(blockIdx);
|
|
365
|
+
if (floatsHere)
|
|
366
|
+
pendingFloats.push(...floatsHere);
|
|
96
367
|
// --- Directives ----------------------------------------------------
|
|
97
368
|
if (rawBlock.type === 'directive') {
|
|
98
369
|
const name = rawBlock.directiveName;
|
|
99
370
|
const attrs = rawBlock.directiveAttrs ?? {};
|
|
100
371
|
if (name === 'pagebreak') {
|
|
101
372
|
pendingSpacing = 0;
|
|
373
|
+
markForcedBreak();
|
|
102
374
|
advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
103
375
|
const parity = attrs.parity;
|
|
104
376
|
if (parity === 'odd'
|
|
@@ -126,16 +398,28 @@ export function buildDocument(content, config, cache, options) {
|
|
|
126
398
|
}
|
|
127
399
|
// --- Heading `breakBefore` ----------------------------------------
|
|
128
400
|
if (rawBlock.type === 'heading' && rawBlock.level) {
|
|
129
|
-
const level =
|
|
401
|
+
const level = headingLevelByNumber.get(rawBlock.level);
|
|
130
402
|
const bb = level?.breakBefore;
|
|
131
403
|
if (bb && bb.enabled) {
|
|
132
404
|
pendingSpacing = 0;
|
|
405
|
+
markForcedBreak();
|
|
133
406
|
advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
134
407
|
if (bb.parity !== 'any') {
|
|
135
408
|
enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, bb.parity);
|
|
136
409
|
}
|
|
137
410
|
flushPendingNumberingAtBoundary();
|
|
138
411
|
}
|
|
412
|
+
// `span: 'page'` headings open a chapter band across the full content
|
|
413
|
+
// width. Always start on a fresh page boundary so the band sits at the
|
|
414
|
+
// page top, and reset the cursor to column 0 so all other columns will
|
|
415
|
+
// have their availableHeight reduced symmetrically after placement.
|
|
416
|
+
if (level?.span === 'page') {
|
|
417
|
+
pendingSpacing = 0;
|
|
418
|
+
markForcedBreak();
|
|
419
|
+
advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
420
|
+
cursor.columnIndex = 0;
|
|
421
|
+
flushPendingNumberingAtBoundary();
|
|
422
|
+
}
|
|
139
423
|
}
|
|
140
424
|
const id = `block-${blockIdCounter++}`;
|
|
141
425
|
const kind = resolveBlockKind(rawBlock, {
|
|
@@ -147,24 +431,135 @@ export function buildDocument(content, config, cache, options) {
|
|
|
147
431
|
listLevelIndentsPx,
|
|
148
432
|
orderedLevelIndentsPx,
|
|
149
433
|
orderedMetrics,
|
|
434
|
+
resourceById,
|
|
435
|
+
resourceTypeById,
|
|
436
|
+
resourceNumberById,
|
|
150
437
|
});
|
|
151
438
|
const { style, vdtType, headingLevel, numberPrefix, listBullet, listDepth, listKind, bulletXOffsetInColumn, strikethroughText } = kind;
|
|
152
439
|
let contentBlock = kind.contentBlock;
|
|
440
|
+
// --- Resource blocks (image / svg / table + caption) -----------------
|
|
441
|
+
// Measured and placed atomically (kept-together) — no mid-content split
|
|
442
|
+
// for v1. An unknown resource id produces no output (warnings handle it).
|
|
443
|
+
if (vdtType === 'resource') {
|
|
444
|
+
if (!kind.resource) {
|
|
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
|
+
}
|
|
478
|
+
const groupHeight = measured.totalHeight;
|
|
479
|
+
const blk = createVDTBlock(id, 'resource', style.fontString, style.color, style.textAlign);
|
|
480
|
+
blk.contentIndex = blockIdx;
|
|
481
|
+
blk.resourceBlock = resourceBlock;
|
|
482
|
+
blk.dirty = false;
|
|
483
|
+
blk.snappedToGrid = false;
|
|
484
|
+
blk.sourceStart = rawBlock.sourceStart + bodyOffset;
|
|
485
|
+
blk.sourceEnd = rawBlock.sourceEnd + bodyOffset;
|
|
486
|
+
// The single placeholder line carries the group height; caption lines are
|
|
487
|
+
// carried on `resourceBlock` and offset to absolute coords below.
|
|
488
|
+
blk.lines = [{
|
|
489
|
+
text: '',
|
|
490
|
+
bbox: { x: 0, y: 0, width: resourceBlock.bodyRect.width, height: groupHeight },
|
|
491
|
+
baseline: 0,
|
|
492
|
+
hyphenated: false,
|
|
493
|
+
segments: [],
|
|
494
|
+
isLastLine: true,
|
|
495
|
+
}];
|
|
496
|
+
const spacingBefore = pendingSpacing;
|
|
497
|
+
placeResourceBlock(blk, groupHeight, spacingBefore, cursor, doc, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
498
|
+
// `placeBlockInColumn` (inside placeResourceBlock) shifts `blk.lines`; the
|
|
499
|
+
// resource's own caption/table lines live on `resourceBlock` and must be
|
|
500
|
+
// offset to absolute page coordinates here using the placed bbox origin.
|
|
501
|
+
offsetResourceBlockToAbsolute(resourceBlock, blk.bbox.x, blk.bbox.y);
|
|
502
|
+
doc.blocks.push(blk);
|
|
503
|
+
// Snap the flow position after the resource to the baseline grid (the
|
|
504
|
+
// group height is arbitrary), baking in at least marginBottom — same
|
|
505
|
+
// convention as snapped headings — so the following text lands back on
|
|
506
|
+
// the global grid instead of inheriting the resource's offset.
|
|
507
|
+
{
|
|
508
|
+
const rCol = currentColumn(doc, cursor);
|
|
509
|
+
const usedHeight = rCol.bbox.height - rCol.availableHeight;
|
|
510
|
+
const naturalBottom = usedHeight + style.marginBottomPx;
|
|
511
|
+
const snappedBottom = Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid;
|
|
512
|
+
rCol.availableHeight = Math.max(0, rCol.bbox.height - snappedBottom);
|
|
513
|
+
}
|
|
514
|
+
pendingSpacing = 0;
|
|
515
|
+
flushPendingNumberingAtBoundary();
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
153
518
|
// Measure text — use rich measurement for blocks with bold spans
|
|
154
519
|
const col = currentColumn(doc, cursor);
|
|
155
520
|
// Resolve inline math on spans (no-op when the block has no math).
|
|
156
521
|
const mathEnabled = resolved.math.enabled;
|
|
157
522
|
contentBlock = enrichMathSpans(contentBlock, style, resolved);
|
|
158
|
-
|
|
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);
|
|
159
537
|
// List items reserve horizontal space for indent + bullet + gap.
|
|
160
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
|
+
}
|
|
161
556
|
const runtActive = resolved.bodyText.avoidRunts
|
|
162
557
|
&& (vdtType === 'paragraph'
|
|
163
558
|
|| (vdtType === 'listItem' && resolved.bodyText.avoidRuntsInLists));
|
|
164
559
|
const measureOptions = {
|
|
165
560
|
textAlign: style.textAlign,
|
|
166
561
|
hyphenate: style.hyphenate,
|
|
167
|
-
firstLineIndentPx:
|
|
562
|
+
firstLineIndentPx: effectiveFirstLineIndent,
|
|
168
563
|
hangingIndent: measureHangingIndent,
|
|
169
564
|
optimal: resolved.bodyText.optimalLineBreaking,
|
|
170
565
|
maxStretchRatio: resolved.bodyText.maxWordSpacing,
|
|
@@ -249,6 +644,17 @@ export function buildDocument(content, config, cache, options) {
|
|
|
249
644
|
spacingBefore = pendingSpacing;
|
|
250
645
|
if (vdtType === 'heading' || vdtType === 'mathDisplay') {
|
|
251
646
|
spacingBefore = Math.max(spacingBefore, style.marginTopPx);
|
|
647
|
+
// Column balancing: extra whole grid lines above this heading so
|
|
648
|
+
// the column it sits in ends flush with the page bottom. Applied
|
|
649
|
+
// after margin collapsing — the heading's effective top margin
|
|
650
|
+
// grows by the assigned lines. Suppressed for first-in-column
|
|
651
|
+
// headings (no top margin there), keeping columns starting at the
|
|
652
|
+
// page top.
|
|
653
|
+
if (vdtType === 'heading') {
|
|
654
|
+
const extraPx = balanceExtraPx?.get(blockIdx);
|
|
655
|
+
if (extraPx)
|
|
656
|
+
spacingBefore += extraPx;
|
|
657
|
+
}
|
|
252
658
|
}
|
|
253
659
|
else if (vdtType === 'listItem') {
|
|
254
660
|
const prevWasList = blockIdx > 0 && contentBlocks[blockIdx - 1].type === 'listItem';
|
|
@@ -264,6 +670,27 @@ export function buildDocument(content, config, cache, options) {
|
|
|
264
670
|
const totalRemainHeight = vdtType === 'mathDisplay'
|
|
265
671
|
? (remainingLines[0]?.bbox.height ?? style.lineHeightPx)
|
|
266
672
|
: remainingLines.length * style.lineHeightPx;
|
|
673
|
+
// For headings with an enabled advanced-design slot, the rendered
|
|
674
|
+
// overlay may extend below the natural text bottom. Measure the slot's
|
|
675
|
+
// actual content bottom so the reserved block height (and the
|
|
676
|
+
// subsequent marginBottom + grid snap) starts from there.
|
|
677
|
+
let effectiveRemainHeight = totalRemainHeight;
|
|
678
|
+
if (vdtType === 'heading' && headingLevel !== undefined && partIndex === 0) {
|
|
679
|
+
const lvl = headingLevelByNumber.get(headingLevel);
|
|
680
|
+
if (lvl) {
|
|
681
|
+
const full = remainingLines
|
|
682
|
+
.map((ln) => (ln.segments ?? []).map((s) => s.text).join(''))
|
|
683
|
+
.join(' ');
|
|
684
|
+
const pref = numberPrefix ?? '';
|
|
685
|
+
const title = pref && full.startsWith(`${pref} `) ? full.slice(pref.length + 1) : full;
|
|
686
|
+
// Span-page openers lay out across the full content area (both
|
|
687
|
+
// columns); in-column headings use just the column width.
|
|
688
|
+
const measureWidth = lvl.span === 'page' ? contentArea.width : curCol.bbox.width;
|
|
689
|
+
const designBottom = measureHeadingAdvancedDesignHeight(lvl, { titleText: title, formattedNumber: pref, chapterNumber: pref }, measureWidth, resolved.page.dpi, doc.metadata, cursor.pageIndex);
|
|
690
|
+
if (designBottom > effectiveRemainHeight)
|
|
691
|
+
effectiveRemainHeight = designBottom;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
267
694
|
// Keep-with-list: if this colon-paragraph would fit but would leave no
|
|
268
695
|
// room for the first list item, split off the colon line (or push the
|
|
269
696
|
// whole paragraph when it is a single line or the split would leave a
|
|
@@ -294,6 +721,7 @@ export function buildDocument(content, config, cache, options) {
|
|
|
294
721
|
const splitLines = remainingLines.slice(0, splitAt);
|
|
295
722
|
const blk = createVDTBlock(id, vdtType, style.fontString, style.color, style.textAlign);
|
|
296
723
|
applyStyleAttrs(blk, style);
|
|
724
|
+
blk.contentIndex = blockIdx;
|
|
297
725
|
blk.headingLevel = headingLevel;
|
|
298
726
|
if (numberPrefix)
|
|
299
727
|
blk.numberPrefix = numberPrefix;
|
|
@@ -309,7 +737,7 @@ export function buildDocument(content, config, cache, options) {
|
|
|
309
737
|
remainingLines = remainingLines.slice(splitAt);
|
|
310
738
|
partIndex++;
|
|
311
739
|
pendingSpacing = 0;
|
|
312
|
-
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
740
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
313
741
|
continue;
|
|
314
742
|
}
|
|
315
743
|
// Can't cleanly split the colon line off — would create a widow.
|
|
@@ -339,19 +767,19 @@ export function buildDocument(content, config, cache, options) {
|
|
|
339
767
|
}
|
|
340
768
|
blockIdx -= headingRunCount + 1;
|
|
341
769
|
pendingSpacing = 0;
|
|
342
|
-
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
770
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
343
771
|
break;
|
|
344
772
|
}
|
|
345
773
|
if (headingRunCount === 0) {
|
|
346
774
|
pendingSpacing = 0;
|
|
347
|
-
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
775
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
348
776
|
continue;
|
|
349
777
|
}
|
|
350
778
|
// headingRunCount === curCol.blocks.length: fall through to place.
|
|
351
779
|
}
|
|
352
780
|
}
|
|
353
781
|
// Block fits in current column
|
|
354
|
-
if (
|
|
782
|
+
if (effectiveRemainHeight <= effectiveAvailable) {
|
|
355
783
|
// Heading keep-with-next: never leave a heading as the last block of a
|
|
356
784
|
// column. If the following (non-heading) block wouldn't have room to
|
|
357
785
|
// place at least its widow-minimum number of lines after this heading,
|
|
@@ -368,7 +796,7 @@ export function buildDocument(content, config, cache, options) {
|
|
|
368
796
|
&& nextBlock !== null
|
|
369
797
|
&& curCol.blocks.length > 0) {
|
|
370
798
|
const wouldUsedHeight = (curCol.bbox.height - curCol.availableHeight) + spacingBefore;
|
|
371
|
-
const naturalBottom = wouldUsedHeight +
|
|
799
|
+
const naturalBottom = wouldUsedHeight + effectiveRemainHeight + style.marginBottomPx;
|
|
372
800
|
const snappedBottom = shouldSnapToGrid
|
|
373
801
|
? Math.ceil((naturalBottom - 0.01) / baselineGrid) * baselineGrid
|
|
374
802
|
: naturalBottom;
|
|
@@ -386,11 +814,11 @@ export function buildDocument(content, config, cache, options) {
|
|
|
386
814
|
// rolled-back heading.
|
|
387
815
|
blockIdx -= rollbackCount + 1;
|
|
388
816
|
pendingSpacing = 0;
|
|
389
|
-
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
817
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
390
818
|
break;
|
|
391
819
|
}
|
|
392
820
|
pendingSpacing = 0;
|
|
393
|
-
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
821
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
394
822
|
continue;
|
|
395
823
|
}
|
|
396
824
|
}
|
|
@@ -401,6 +829,7 @@ export function buildDocument(content, config, cache, options) {
|
|
|
401
829
|
const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
|
|
402
830
|
const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
|
|
403
831
|
applyStyleAttrs(blk, style);
|
|
832
|
+
blk.contentIndex = blockIdx;
|
|
404
833
|
if (partIndex === 0) {
|
|
405
834
|
blk.headingLevel = headingLevel;
|
|
406
835
|
if (numberPrefix)
|
|
@@ -428,7 +857,7 @@ export function buildDocument(content, config, cache, options) {
|
|
|
428
857
|
}
|
|
429
858
|
blk.sourceMap = absoluteSourceMap;
|
|
430
859
|
blk.plainPrefixLen = prefixLen;
|
|
431
|
-
let h =
|
|
860
|
+
let h = effectiveRemainHeight;
|
|
432
861
|
if (shouldSnapToGrid && partIndex === 0) {
|
|
433
862
|
// Snap using the absolute position in the column so that the block
|
|
434
863
|
// bottom lands on a baseline grid line. This accounts for off-grid
|
|
@@ -436,7 +865,7 @@ export function buildDocument(content, config, cache, options) {
|
|
|
436
865
|
// the minimum marginBottom — the grid always wins, but the margin
|
|
437
866
|
// below is guaranteed to be at least marginBottomPx.
|
|
438
867
|
const usedHeight = curCol.bbox.height - curCol.availableHeight;
|
|
439
|
-
const naturalBottom = usedHeight +
|
|
868
|
+
const naturalBottom = usedHeight + effectiveRemainHeight + style.marginBottomPx;
|
|
440
869
|
// Tolerance guards against FP drift: if naturalBottom is already on
|
|
441
870
|
// the grid (e.g. marginBottom is an exact multiple of baselineGrid),
|
|
442
871
|
// don't round up to the next line.
|
|
@@ -446,6 +875,20 @@ export function buildDocument(content, config, cache, options) {
|
|
|
446
875
|
placeBlockInColumn(blk, h, curCol, cursor);
|
|
447
876
|
finalizeListItem(blk, partIndex === 0);
|
|
448
877
|
doc.blocks.push(blk);
|
|
878
|
+
// Page-spanning heading: reserve the same vertical band in every
|
|
879
|
+
// other column on this page so body text under the opener band
|
|
880
|
+
// starts below it in ALL columns, not just the one it was placed in.
|
|
881
|
+
if (vdtType === 'heading' && headingLevel !== undefined) {
|
|
882
|
+
const lvl = headingLevelByNumber.get(headingLevel);
|
|
883
|
+
if (lvl?.span === 'page') {
|
|
884
|
+
const page = doc.pages[cursor.pageIndex];
|
|
885
|
+
for (const otherCol of page.columns) {
|
|
886
|
+
if (otherCol !== curCol) {
|
|
887
|
+
otherCol.availableHeight = Math.max(0, otherCol.availableHeight - h);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
449
892
|
// For snapped headings/list-tails the margin is baked into the snap;
|
|
450
893
|
// for unsnapped ones (consecutive) track it for collapsing
|
|
451
894
|
if (vdtType === 'listItem' && nextIsListItem) {
|
|
@@ -481,6 +924,7 @@ export function buildDocument(content, config, cache, options) {
|
|
|
481
924
|
const splitLines = remainingLines.slice(0, choice.splitAt);
|
|
482
925
|
const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
|
|
483
926
|
applyStyleAttrs(blk, style);
|
|
927
|
+
blk.contentIndex = blockIdx;
|
|
484
928
|
if (partIndex === 0) {
|
|
485
929
|
blk.headingLevel = headingLevel;
|
|
486
930
|
if (numberPrefix)
|
|
@@ -502,7 +946,7 @@ export function buildDocument(content, config, cache, options) {
|
|
|
502
946
|
remainingLines = remainingLines.slice(choice.splitAt);
|
|
503
947
|
partIndex++;
|
|
504
948
|
pendingSpacing = 0;
|
|
505
|
-
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
949
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
506
950
|
continue;
|
|
507
951
|
}
|
|
508
952
|
// choice.splitAt === 0: fall through to push whole paragraph to next column
|
|
@@ -518,18 +962,19 @@ export function buildDocument(content, config, cache, options) {
|
|
|
518
962
|
if (rollbackCount > 0) {
|
|
519
963
|
blockIdx -= rollbackCount + 1;
|
|
520
964
|
pendingSpacing = 0;
|
|
521
|
-
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
965
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
522
966
|
break;
|
|
523
967
|
}
|
|
524
968
|
}
|
|
525
969
|
pendingSpacing = 0;
|
|
526
|
-
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx);
|
|
970
|
+
advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, onNewPage);
|
|
527
971
|
continue;
|
|
528
972
|
}
|
|
529
973
|
// Empty column but block still doesn't fit (block taller than page) — place anyway
|
|
530
974
|
const partId = partIndex === 0 ? id : `${id}-cont-${partIndex}`;
|
|
531
975
|
const blk = createVDTBlock(partId, vdtType, style.fontString, style.color, style.textAlign);
|
|
532
976
|
applyStyleAttrs(blk, style);
|
|
977
|
+
blk.contentIndex = blockIdx;
|
|
533
978
|
if (partIndex === 0)
|
|
534
979
|
blk.headingLevel = headingLevel;
|
|
535
980
|
blk.lines = resetLinePositions(remainingLines, style.lineHeightPx);
|
|
@@ -554,6 +999,9 @@ export function buildDocument(content, config, cache, options) {
|
|
|
554
999
|
}
|
|
555
1000
|
flushPendingNumberingAtBoundary();
|
|
556
1001
|
}
|
|
1002
|
+
// Place any floats still pending (referenced on the last page, or never
|
|
1003
|
+
// followed by a content-overflow page break) onto freshly appended pages.
|
|
1004
|
+
finalizeFloats();
|
|
557
1005
|
// Stamp page-number info onto every page (including blank parity pages).
|
|
558
1006
|
const labels = buildPageLabels(doc.pages.length, pageNumberSegments);
|
|
559
1007
|
for (let i = 0; i < doc.pages.length; i++) {
|
|
@@ -568,6 +1016,51 @@ export function buildDocument(content, config, cache, options) {
|
|
|
568
1016
|
buildHeadersAndFooters(doc);
|
|
569
1017
|
doc.converged = true;
|
|
570
1018
|
doc.iterationCount = 1;
|
|
571
|
-
return doc;
|
|
1019
|
+
return { doc, forcedBreakPages };
|
|
1020
|
+
}
|
|
1021
|
+
export function buildDocument(content, config, cache, options) {
|
|
1022
|
+
let best = buildDocumentPass(content, config, cache, options);
|
|
1023
|
+
// --- Column balancing (vertical justification) ------------------------
|
|
1024
|
+
// Iteratively re-place the document with extra grid lines above headings
|
|
1025
|
+
// until every balanceable column ends flush with the page bottom (or no
|
|
1026
|
+
// further adjustment is possible). Each retry recomputes the remaining
|
|
1027
|
+
// gaps on the freshly placed document, so split/keep-with-next decisions
|
|
1028
|
+
// that shift under the new spacing are accounted for. The best layout
|
|
1029
|
+
// (fewest leftover gap lines) always wins — a retry that regresses is
|
|
1030
|
+
// discarded.
|
|
1031
|
+
const balancing = best.doc.config.headings.balancing;
|
|
1032
|
+
if (!balancing.enabled)
|
|
1033
|
+
return best.doc;
|
|
1034
|
+
let bestScore = totalGapLines(best.doc, best.forcedBreakPages);
|
|
1035
|
+
let appliedLines = new Map();
|
|
1036
|
+
let passCount = 1;
|
|
1037
|
+
let converged = bestScore === 0;
|
|
1038
|
+
while (!converged && passCount < MAX_BALANCING_PASSES) {
|
|
1039
|
+
const proposal = proposeBalanceLines(best.doc, best.forcedBreakPages, appliedLines, balancing.maxLinesPerHeading);
|
|
1040
|
+
if (!proposal.changed) {
|
|
1041
|
+
// No heading can absorb the remaining gaps — stable.
|
|
1042
|
+
converged = true;
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
const extraPx = new Map();
|
|
1046
|
+
for (const [idx, n] of proposal.lines)
|
|
1047
|
+
extraPx.set(idx, n * best.doc.baselineGrid);
|
|
1048
|
+
const next = buildDocumentPass(content, config, cache, options, extraPx);
|
|
1049
|
+
passCount++;
|
|
1050
|
+
const score = totalGapLines(next.doc, next.forcedBreakPages);
|
|
1051
|
+
if (score < bestScore) {
|
|
1052
|
+
best = next;
|
|
1053
|
+
bestScore = score;
|
|
1054
|
+
appliedLines = proposal.lines;
|
|
1055
|
+
converged = score === 0;
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
// Plateau or regression — keep the best layout found so far.
|
|
1059
|
+
break;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
best.doc.iterationCount = passCount;
|
|
1063
|
+
best.doc.converged = converged || bestScore === 0;
|
|
1064
|
+
return best.doc;
|
|
572
1065
|
}
|
|
573
1066
|
//# sourceMappingURL=build.js.map
|