@jsonpdf/renderer 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/anchors.d.ts +13 -0
- package/dist/anchors.d.ts.map +1 -0
- package/dist/anchors.js +37 -0
- package/dist/anchors.js.map +1 -0
- package/dist/band-expander.d.ts +25 -0
- package/dist/band-expander.d.ts.map +1 -0
- package/dist/band-expander.js +193 -0
- package/dist/band-expander.js.map +1 -0
- package/dist/bookmarks.d.ts +22 -0
- package/dist/bookmarks.d.ts.map +1 -0
- package/dist/bookmarks.js +78 -0
- package/dist/bookmarks.js.map +1 -0
- package/dist/columns.d.ts +18 -0
- package/dist/columns.d.ts.map +1 -0
- package/dist/columns.js +31 -0
- package/dist/columns.js.map +1 -0
- package/dist/context.d.ts +8 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +38 -0
- package/dist/context.js.map +1 -0
- package/dist/coordinate.d.ts +14 -0
- package/dist/coordinate.d.ts.map +1 -0
- package/dist/coordinate.js +16 -0
- package/dist/coordinate.js.map +1 -0
- package/dist/data.d.ts +18 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +56 -0
- package/dist/data.js.map +1 -0
- package/dist/expression.d.ts +13 -0
- package/dist/expression.d.ts.map +1 -0
- package/dist/expression.js +90 -0
- package/dist/expression.js.map +1 -0
- package/dist/font-loader.d.ts +8 -0
- package/dist/font-loader.d.ts.map +1 -0
- package/dist/font-loader.js +29 -0
- package/dist/font-loader.js.map +1 -0
- package/dist/fonts.d.ts +19 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +124 -0
- package/dist/fonts.js.map +1 -0
- package/dist/footnotes.d.ts +34 -0
- package/dist/footnotes.d.ts.map +1 -0
- package/dist/footnotes.js +103 -0
- package/dist/footnotes.js.map +1 -0
- package/dist/gradient.d.ts +19 -0
- package/dist/gradient.d.ts.map +1 -0
- package/dist/gradient.js +176 -0
- package/dist/gradient.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +54 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +680 -0
- package/dist/layout.js.map +1 -0
- package/dist/renderer.d.ts +19 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +570 -0
- package/dist/renderer.js.map +1 -0
- package/dist/style-resolver.d.ts +15 -0
- package/dist/style-resolver.d.ts.map +1 -0
- package/dist/style-resolver.js +39 -0
- package/dist/style-resolver.js.map +1 -0
- package/package.json +29 -0
package/dist/layout.js
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
import { createMeasureContext } from './context.js';
|
|
2
|
+
import { resolveElementStyle, normalizePadding } from './style-resolver.js';
|
|
3
|
+
import { expandBands } from './band-expander.js';
|
|
4
|
+
import { computeColumnLayout } from './columns.js';
|
|
5
|
+
/** Maximum nesting depth for container elements to prevent infinite recursion. */
|
|
6
|
+
export const MAX_CONTAINER_DEPTH = 10;
|
|
7
|
+
/** Merge section-level page config overrides with the template-level defaults. */
|
|
8
|
+
export function mergePageConfig(base, override) {
|
|
9
|
+
if (!override)
|
|
10
|
+
return base;
|
|
11
|
+
return {
|
|
12
|
+
...base,
|
|
13
|
+
...override,
|
|
14
|
+
margins: { ...base.margins, ...(override.margins ?? {}) },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create a measureBands callback for frame elements during the layout pass.
|
|
19
|
+
* Expands the frame's bands and measures each expanded content band.
|
|
20
|
+
*/
|
|
21
|
+
function createFrameMeasureBands(fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx) {
|
|
22
|
+
return async (bands) => {
|
|
23
|
+
const pseudoSection = { id: '__frame', bands };
|
|
24
|
+
const expanded = await expandBands(pseudoSection, fCtx.data, fCtx.engine, fCtx.totalPagesHint);
|
|
25
|
+
let totalHeight = 0;
|
|
26
|
+
for (const instance of expanded.contentBands) {
|
|
27
|
+
const m = await measureBand(instance.band, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
28
|
+
totalHeight += m.height;
|
|
29
|
+
}
|
|
30
|
+
return { totalHeight };
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Create a measureChild callback for container elements during the layout pass.
|
|
35
|
+
* This allows the container plugin to measure its children during band autoHeight computation.
|
|
36
|
+
*/
|
|
37
|
+
function createLayoutMeasureChild(fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, depth = 0, fCtx) {
|
|
38
|
+
return async (childEl) => {
|
|
39
|
+
if (depth + 1 > MAX_CONTAINER_DEPTH) {
|
|
40
|
+
throw new Error('Maximum container nesting depth exceeded');
|
|
41
|
+
}
|
|
42
|
+
const plugin = getPlugin(childEl.type);
|
|
43
|
+
const props = plugin.resolveProps(childEl.properties);
|
|
44
|
+
const measureCtx = createMeasureContext(childEl, fonts, styles, defaultStyle, pdfDoc, imageCache);
|
|
45
|
+
if (childEl.elements?.length) {
|
|
46
|
+
measureCtx.children = childEl.elements;
|
|
47
|
+
measureCtx.measureChild = createLayoutMeasureChild(fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, depth + 1, fCtx);
|
|
48
|
+
}
|
|
49
|
+
// Provide measureBands callback for frame elements
|
|
50
|
+
if (childEl.type === 'frame' && fCtx) {
|
|
51
|
+
measureCtx.measureBands = createFrameMeasureBands(fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
52
|
+
}
|
|
53
|
+
return plugin.measure(props, measureCtx);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Measure a single band's height. Returns the measured height and per-element heights. */
|
|
57
|
+
async function measureBand(band, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx) {
|
|
58
|
+
const elementHeights = new Map();
|
|
59
|
+
let bandHeight = band.height;
|
|
60
|
+
if (band.autoHeight) {
|
|
61
|
+
let maxElementBottom = band.height;
|
|
62
|
+
for (const element of band.elements) {
|
|
63
|
+
const plugin = getPlugin(element.type);
|
|
64
|
+
const props = plugin.resolveProps(element.properties);
|
|
65
|
+
const propErrors = plugin.validate(props);
|
|
66
|
+
if (propErrors.length > 0) {
|
|
67
|
+
const messages = propErrors.map((e) => `${e.path}: ${e.message}`).join('; ');
|
|
68
|
+
throw new Error(`Invalid properties for ${element.type} element "${element.id}": ${messages}`);
|
|
69
|
+
}
|
|
70
|
+
const measureCtx = createMeasureContext(element, fonts, styles, defaultStyle, pdfDoc, imageCache);
|
|
71
|
+
// Provide child measurement for container elements
|
|
72
|
+
if (element.elements?.length) {
|
|
73
|
+
measureCtx.children = element.elements;
|
|
74
|
+
measureCtx.measureChild = createLayoutMeasureChild(fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, 0, fCtx);
|
|
75
|
+
}
|
|
76
|
+
// Provide measureBands callback for frame elements
|
|
77
|
+
if (element.type === 'frame' && fCtx) {
|
|
78
|
+
measureCtx.measureBands = createFrameMeasureBands(fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
79
|
+
}
|
|
80
|
+
const measured = await plugin.measure(props, measureCtx);
|
|
81
|
+
// measured.height is the content height (within padding-adjusted space).
|
|
82
|
+
// Store the total element height (content + padding) so createRenderContext
|
|
83
|
+
// can correctly subtract padding without double-counting.
|
|
84
|
+
const padding = normalizePadding(resolveElementStyle(element, styles, defaultStyle).padding);
|
|
85
|
+
const totalElementHeight = measured.height + padding.top + padding.bottom;
|
|
86
|
+
elementHeights.set(element.id, totalElementHeight);
|
|
87
|
+
const elementBottom = element.y + totalElementHeight;
|
|
88
|
+
maxElementBottom = Math.max(maxElementBottom, elementBottom);
|
|
89
|
+
}
|
|
90
|
+
bandHeight = maxElementBottom;
|
|
91
|
+
}
|
|
92
|
+
return { height: bandHeight, elementHeights };
|
|
93
|
+
}
|
|
94
|
+
/** Measure a list of bands. Returns total height and per-band measurements. */
|
|
95
|
+
async function measureBandList(bands, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx) {
|
|
96
|
+
const measurements = [];
|
|
97
|
+
let total = 0;
|
|
98
|
+
for (const band of bands) {
|
|
99
|
+
const result = await measureBand(band, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
100
|
+
measurements.push(result);
|
|
101
|
+
total += result.height;
|
|
102
|
+
}
|
|
103
|
+
return { total, measurements };
|
|
104
|
+
}
|
|
105
|
+
/** Create a LayoutBand from a band with measurement results. */
|
|
106
|
+
function createLayoutBand(band, offsetY, measurement, scope, columnInfo) {
|
|
107
|
+
return {
|
|
108
|
+
band,
|
|
109
|
+
offsetY,
|
|
110
|
+
measuredHeight: measurement.height,
|
|
111
|
+
elementHeights: measurement.elementHeights,
|
|
112
|
+
scope,
|
|
113
|
+
...(columnInfo
|
|
114
|
+
? {
|
|
115
|
+
columnIndex: columnInfo.columnIndex,
|
|
116
|
+
columnOffsetX: columnInfo.columnOffsetX,
|
|
117
|
+
columnWidth: columnInfo.columnWidth,
|
|
118
|
+
}
|
|
119
|
+
: {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** Check if a band type should be placed in columns (vs. full-width). */
|
|
123
|
+
function isColumnBandType(type) {
|
|
124
|
+
return type === 'detail' || type === 'groupHeader' || type === 'groupFooter';
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Layout a template across multiple pages.
|
|
128
|
+
*
|
|
129
|
+
* Handles all 13 band types, multi-section support, page breaks,
|
|
130
|
+
* and content overflow. Each LayoutBand carries its Liquid scope
|
|
131
|
+
* for expression resolution during rendering.
|
|
132
|
+
*/
|
|
133
|
+
export async function layoutTemplate(template, fonts, getPlugin, engine, data, totalPagesHint, pdfDoc, imageCache) {
|
|
134
|
+
const allPages = [];
|
|
135
|
+
let globalPageIndex = 0;
|
|
136
|
+
const { defaultStyle } = template;
|
|
137
|
+
for (const [sectionIndex, section] of template.sections.entries()) {
|
|
138
|
+
const pageConfig = mergePageConfig(template.page, section.page);
|
|
139
|
+
const expanded = await expandBands(section, data, engine, totalPagesHint);
|
|
140
|
+
const columnConfig = {
|
|
141
|
+
columns: section.columns ?? 1,
|
|
142
|
+
columnGap: section.columnGap ?? 0,
|
|
143
|
+
columnWidths: section.columnWidths,
|
|
144
|
+
columnMode: section.columnMode,
|
|
145
|
+
};
|
|
146
|
+
const sectionPages = await layoutSection(sectionIndex, pageConfig, expanded, template.styles, defaultStyle, fonts, getPlugin, globalPageIndex, totalPagesHint, pdfDoc, imageCache, columnConfig, engine, data);
|
|
147
|
+
allPages.push(...sectionPages);
|
|
148
|
+
globalPageIndex += sectionPages.length;
|
|
149
|
+
}
|
|
150
|
+
// Collect bookmarks from laid-out pages for TOC (_bookmarks) support
|
|
151
|
+
const bookmarks = [];
|
|
152
|
+
let lastBookmarkSectionIndex = -1;
|
|
153
|
+
for (const page of allPages) {
|
|
154
|
+
// Section bookmark (only on first page of each section)
|
|
155
|
+
if (page.sectionIndex !== lastBookmarkSectionIndex) {
|
|
156
|
+
const section = template.sections[page.sectionIndex];
|
|
157
|
+
if (section.bookmark) {
|
|
158
|
+
const scope = page.bands.length > 0 ? page.bands[0].scope : {};
|
|
159
|
+
const title = await engine.resolve(section.bookmark, scope);
|
|
160
|
+
bookmarks.push({
|
|
161
|
+
title,
|
|
162
|
+
pageNumber: page.pageIndex + 1,
|
|
163
|
+
level: 0,
|
|
164
|
+
anchorId: section.id,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
lastBookmarkSectionIndex = page.sectionIndex;
|
|
169
|
+
// Band bookmarks
|
|
170
|
+
for (const layoutBand of page.bands) {
|
|
171
|
+
if (layoutBand.band.bookmark) {
|
|
172
|
+
const title = await engine.resolve(layoutBand.band.bookmark, layoutBand.scope);
|
|
173
|
+
bookmarks.push({
|
|
174
|
+
title,
|
|
175
|
+
pageNumber: page.pageIndex + 1,
|
|
176
|
+
level: 1,
|
|
177
|
+
anchorId: layoutBand.band.anchor ?? layoutBand.band.id,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { pages: allPages, totalPages: allPages.length, bookmarks };
|
|
183
|
+
}
|
|
184
|
+
async function layoutSection(sectionIndex, pageConfig, expanded, styles, defaultStyle, fonts, getPlugin, globalPageOffset, totalPagesHint, pdfDoc, imageCache, columnConfig, engine, data) {
|
|
185
|
+
const fCtx = { engine, data, totalPagesHint };
|
|
186
|
+
// Measure structural band heights (storing per-band measurements)
|
|
187
|
+
const pageHeaderResult = await measureBandList(expanded.pageHeaderBands, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
188
|
+
const pageFooterResult = await measureBandList(expanded.pageFooterBands, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
189
|
+
const lastPageFooterResult = await measureBandList(expanded.lastPageFooterBands, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
190
|
+
const columnHeaderResult = await measureBandList(expanded.columnHeaderBands, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
191
|
+
const columnFooterResult = await measureBandList(expanded.columnFooterBands, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
192
|
+
const backgroundResult = await measureBandList(expanded.backgroundBands, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
193
|
+
const pageHeaderHeight = pageHeaderResult.total;
|
|
194
|
+
const pageFooterHeight = pageFooterResult.total;
|
|
195
|
+
const lastPageFooterHeight = lastPageFooterResult.total;
|
|
196
|
+
const columnHeaderHeight = columnHeaderResult.total;
|
|
197
|
+
const columnFooterHeight = columnFooterResult.total;
|
|
198
|
+
const totalVerticalMargins = pageConfig.margins.top + pageConfig.margins.bottom;
|
|
199
|
+
// Use the larger footer height to prevent content overlap on the last page
|
|
200
|
+
const effectiveFooterHeight = Math.max(pageFooterHeight, lastPageFooterHeight);
|
|
201
|
+
// Content area = page minus margins, header, effective footer
|
|
202
|
+
const contentAreaHeight = pageConfig.height - totalVerticalMargins - pageHeaderHeight - effectiveFooterHeight;
|
|
203
|
+
// Available space for content bands (after column headers)
|
|
204
|
+
const availableContentHeight = contentAreaHeight - columnHeaderHeight;
|
|
205
|
+
if (expanded.contentBands.length === 0) {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
// Multi-column layout setup
|
|
209
|
+
const numColumns = columnConfig.columns;
|
|
210
|
+
const isMultiColumn = numColumns >= 2;
|
|
211
|
+
const contentWidth = pageConfig.width - pageConfig.margins.left - pageConfig.margins.right;
|
|
212
|
+
const colLayout = isMultiColumn
|
|
213
|
+
? computeColumnLayout(contentWidth, numColumns, columnConfig.columnGap, columnConfig.columnWidths)
|
|
214
|
+
: undefined;
|
|
215
|
+
const pages = [];
|
|
216
|
+
let currentBands = [];
|
|
217
|
+
// cursorY tracks content band offset only (does NOT include column header height).
|
|
218
|
+
// cursorY > 0 means at least one content band has been placed on the current page.
|
|
219
|
+
let cursorY = 0;
|
|
220
|
+
function startNewPage() {
|
|
221
|
+
const page = {
|
|
222
|
+
sectionIndex,
|
|
223
|
+
pageIndex: globalPageOffset + pages.length,
|
|
224
|
+
bands: currentBands,
|
|
225
|
+
};
|
|
226
|
+
// Compute page height for autoHeight pages.
|
|
227
|
+
// Footers are already in currentBands (finalizePage is called before startNewPage).
|
|
228
|
+
if (pageConfig.autoHeight) {
|
|
229
|
+
let maxBottom = 0;
|
|
230
|
+
for (const lb of currentBands) {
|
|
231
|
+
if (lb.band.type === 'background')
|
|
232
|
+
continue;
|
|
233
|
+
const bottom = lb.offsetY + lb.measuredHeight;
|
|
234
|
+
if (bottom > maxBottom)
|
|
235
|
+
maxBottom = bottom;
|
|
236
|
+
}
|
|
237
|
+
page.computedHeight = Math.max(pageConfig.margins.top + pageConfig.margins.bottom + maxBottom, pageConfig.height);
|
|
238
|
+
}
|
|
239
|
+
pages.push(page);
|
|
240
|
+
currentBands = [];
|
|
241
|
+
cursorY = 0;
|
|
242
|
+
}
|
|
243
|
+
// Place page header bands at top of page
|
|
244
|
+
function placePageHeaders(scope) {
|
|
245
|
+
let offsetY = 0;
|
|
246
|
+
for (let i = 0; i < expanded.pageHeaderBands.length; i++) {
|
|
247
|
+
const band = expanded.pageHeaderBands[i];
|
|
248
|
+
const m = pageHeaderResult.measurements[i];
|
|
249
|
+
currentBands.push(createLayoutBand(band, offsetY, m, scope));
|
|
250
|
+
offsetY += m.height;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Place column headers (single-column: once; multi-column: once per column)
|
|
254
|
+
function placeColumnHeaders(scope) {
|
|
255
|
+
if (colLayout) {
|
|
256
|
+
for (let col = 0; col < numColumns; col++) {
|
|
257
|
+
let offsetY = 0;
|
|
258
|
+
for (let i = 0; i < expanded.columnHeaderBands.length; i++) {
|
|
259
|
+
const band = expanded.columnHeaderBands[i];
|
|
260
|
+
const m = columnHeaderResult.measurements[i];
|
|
261
|
+
currentBands.push(createLayoutBand(band, pageHeaderHeight + offsetY, m, scope, {
|
|
262
|
+
columnIndex: col,
|
|
263
|
+
columnOffsetX: colLayout.offsets[col],
|
|
264
|
+
columnWidth: colLayout.widths[col],
|
|
265
|
+
}));
|
|
266
|
+
offsetY += m.height;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
let offsetY = 0;
|
|
272
|
+
for (let i = 0; i < expanded.columnHeaderBands.length; i++) {
|
|
273
|
+
const band = expanded.columnHeaderBands[i];
|
|
274
|
+
const m = columnHeaderResult.measurements[i];
|
|
275
|
+
currentBands.push(createLayoutBand(band, pageHeaderHeight + offsetY, m, scope));
|
|
276
|
+
offsetY += m.height;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Finalize a page by adding footer/background bands
|
|
281
|
+
function finalizePage(scope, isLastPage) {
|
|
282
|
+
// Background bands must be rendered first (behind everything).
|
|
283
|
+
const bgBands = [];
|
|
284
|
+
for (let i = 0; i < expanded.backgroundBands.length; i++) {
|
|
285
|
+
const band = expanded.backgroundBands[i];
|
|
286
|
+
const m = backgroundResult.measurements[i];
|
|
287
|
+
bgBands.push(createLayoutBand(band, 0, m, scope));
|
|
288
|
+
}
|
|
289
|
+
currentBands.unshift(...bgBands);
|
|
290
|
+
// Determine which footer to use
|
|
291
|
+
const useLastFooter = isLastPage && expanded.lastPageFooterBands.length > 0;
|
|
292
|
+
const footerBands = useLastFooter ? expanded.lastPageFooterBands : expanded.pageFooterBands;
|
|
293
|
+
const footerMeasurements = useLastFooter
|
|
294
|
+
? lastPageFooterResult.measurements
|
|
295
|
+
: pageFooterResult.measurements;
|
|
296
|
+
const footerTotalHeight = useLastFooter ? lastPageFooterHeight : pageFooterHeight;
|
|
297
|
+
// Compute per-band column footer offsets
|
|
298
|
+
// Each band independently decides whether to float (sit after content) or use fixed position.
|
|
299
|
+
const fixedColumnFooterBottom = pageConfig.height - totalVerticalMargins - footerTotalHeight;
|
|
300
|
+
let footerStartOffset;
|
|
301
|
+
function computeColumnFooterOffsets() {
|
|
302
|
+
const offsets = [];
|
|
303
|
+
let floatCursor = pageHeaderHeight + columnHeaderHeight + cursorY;
|
|
304
|
+
let fixedCursor = fixedColumnFooterBottom - columnFooterHeight;
|
|
305
|
+
for (let i = 0; i < expanded.columnFooterBands.length; i++) {
|
|
306
|
+
const band = expanded.columnFooterBands[i];
|
|
307
|
+
const m = columnFooterResult.measurements[i];
|
|
308
|
+
if (pageConfig.autoHeight) {
|
|
309
|
+
offsets.push(floatCursor);
|
|
310
|
+
}
|
|
311
|
+
else if (band.float === true) {
|
|
312
|
+
offsets.push(Math.min(floatCursor, fixedCursor));
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
offsets.push(fixedCursor);
|
|
316
|
+
}
|
|
317
|
+
floatCursor += m.height;
|
|
318
|
+
fixedCursor += m.height;
|
|
319
|
+
}
|
|
320
|
+
return offsets;
|
|
321
|
+
}
|
|
322
|
+
const columnFooterOffsets = computeColumnFooterOffsets();
|
|
323
|
+
if (pageConfig.autoHeight) {
|
|
324
|
+
const contentBottom = pageHeaderHeight + columnHeaderHeight + cursorY;
|
|
325
|
+
footerStartOffset = contentBottom + columnFooterHeight;
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
footerStartOffset = fixedColumnFooterBottom;
|
|
329
|
+
}
|
|
330
|
+
// Place column footers (multi-column: per column; single-column: once)
|
|
331
|
+
if (colLayout) {
|
|
332
|
+
for (let col = 0; col < numColumns; col++) {
|
|
333
|
+
for (let i = 0; i < expanded.columnFooterBands.length; i++) {
|
|
334
|
+
const band = expanded.columnFooterBands[i];
|
|
335
|
+
const m = columnFooterResult.measurements[i];
|
|
336
|
+
currentBands.push(createLayoutBand(band, columnFooterOffsets[i], m, scope, {
|
|
337
|
+
columnIndex: col,
|
|
338
|
+
columnOffsetX: colLayout.offsets[col],
|
|
339
|
+
columnWidth: colLayout.widths[col],
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
for (let i = 0; i < expanded.columnFooterBands.length; i++) {
|
|
346
|
+
const band = expanded.columnFooterBands[i];
|
|
347
|
+
const m = columnFooterResult.measurements[i];
|
|
348
|
+
currentBands.push(createLayoutBand(band, columnFooterOffsets[i], m, scope));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Place page footer (always full-width)
|
|
352
|
+
let footerOffset = footerStartOffset;
|
|
353
|
+
for (let i = 0; i < footerBands.length; i++) {
|
|
354
|
+
const band = footerBands[i];
|
|
355
|
+
const m = footerMeasurements[i];
|
|
356
|
+
currentBands.push(createLayoutBand(band, footerOffset, m, scope));
|
|
357
|
+
footerOffset += m.height;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Get the scope for a content band (use its own scope, updated with page number)
|
|
361
|
+
function pageScope(instance, pageIdx) {
|
|
362
|
+
return {
|
|
363
|
+
...instance.scope,
|
|
364
|
+
_pageNumber: pageIdx + 1,
|
|
365
|
+
_totalPages: totalPagesHint,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
// Attempt to split a band that contains a single splittable element.
|
|
369
|
+
// Returns fit/overflow bands on success, or null to fall back to standard overflow.
|
|
370
|
+
async function trySplitBand(instance, availableHeight) {
|
|
371
|
+
// Only split bands with exactly one element that supports splitting
|
|
372
|
+
if (instance.band.elements.length !== 1)
|
|
373
|
+
return null;
|
|
374
|
+
const element = instance.band.elements[0];
|
|
375
|
+
const plugin = getPlugin(element.type);
|
|
376
|
+
if (!plugin.canSplit || !plugin.split)
|
|
377
|
+
return null;
|
|
378
|
+
const props = plugin.resolveProps(element.properties);
|
|
379
|
+
const measureCtx = createMeasureContext(element, fonts, styles, defaultStyle, pdfDoc, imageCache);
|
|
380
|
+
// Account for element's Y offset within the band and padding
|
|
381
|
+
const elPadding = normalizePadding(resolveElementStyle(element, styles, defaultStyle).padding);
|
|
382
|
+
const availableForContent = availableHeight - element.y - elPadding.top - elPadding.bottom;
|
|
383
|
+
if (availableForContent <= 0)
|
|
384
|
+
return null;
|
|
385
|
+
const splitResult = await plugin.split(props, measureCtx, availableForContent);
|
|
386
|
+
if (!splitResult)
|
|
387
|
+
return null;
|
|
388
|
+
const fitElement = {
|
|
389
|
+
...element,
|
|
390
|
+
properties: splitResult.fit,
|
|
391
|
+
id: element.id + '__fit',
|
|
392
|
+
};
|
|
393
|
+
const overflowElement = {
|
|
394
|
+
...element,
|
|
395
|
+
properties: splitResult.overflow,
|
|
396
|
+
id: element.id + '__overflow',
|
|
397
|
+
};
|
|
398
|
+
return {
|
|
399
|
+
fitBand: {
|
|
400
|
+
...instance.band,
|
|
401
|
+
id: instance.band.id + '__fit',
|
|
402
|
+
elements: [fitElement],
|
|
403
|
+
height: 0,
|
|
404
|
+
autoHeight: true,
|
|
405
|
+
},
|
|
406
|
+
overflowBand: {
|
|
407
|
+
...instance.band,
|
|
408
|
+
id: instance.band.id + '__overflow',
|
|
409
|
+
elements: [overflowElement],
|
|
410
|
+
height: 0,
|
|
411
|
+
autoHeight: true,
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
// Place a full-width content band, handling page breaks and overflow.
|
|
416
|
+
// Returns the updated lastUsedInstance.
|
|
417
|
+
async function placeFullWidthBand(instance, lastUsed) {
|
|
418
|
+
const measured = await measureBand(instance.band, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
419
|
+
const bandHeight = measured.height;
|
|
420
|
+
const scope = pageScope(instance, globalPageOffset + pages.length);
|
|
421
|
+
// Forced page break
|
|
422
|
+
if (instance.band.pageBreakBefore && cursorY > 0) {
|
|
423
|
+
finalizePage(scope, false);
|
|
424
|
+
startNewPage();
|
|
425
|
+
placePageHeaders(pageScope(instance, globalPageOffset + pages.length));
|
|
426
|
+
placeColumnHeaders(pageScope(instance, globalPageOffset + pages.length));
|
|
427
|
+
}
|
|
428
|
+
// Natural overflow (skipped for autoHeight pages)
|
|
429
|
+
if (!pageConfig.autoHeight && bandHeight > availableContentHeight - cursorY) {
|
|
430
|
+
// Attempt splitting even at cursorY=0 (splittable elements can be partially placed)
|
|
431
|
+
const splitResult = await trySplitBand(instance, availableContentHeight - cursorY);
|
|
432
|
+
if (splitResult) {
|
|
433
|
+
// Place fit portion on current page
|
|
434
|
+
const fitMeasured = await measureBand(splitResult.fitBand, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
435
|
+
const fitOffsetY = pageHeaderHeight + columnHeaderHeight + cursorY;
|
|
436
|
+
currentBands.push(createLayoutBand(splitResult.fitBand, fitOffsetY, fitMeasured, scope));
|
|
437
|
+
cursorY += fitMeasured.height;
|
|
438
|
+
// Start new page for overflow
|
|
439
|
+
finalizePage(scope, false);
|
|
440
|
+
startNewPage();
|
|
441
|
+
placePageHeaders(pageScope(instance, globalPageOffset + pages.length));
|
|
442
|
+
placeColumnHeaders(pageScope(instance, globalPageOffset + pages.length));
|
|
443
|
+
// Recursively place overflow (may split again)
|
|
444
|
+
const overflowInstance = {
|
|
445
|
+
band: splitResult.overflowBand,
|
|
446
|
+
scope: instance.scope,
|
|
447
|
+
};
|
|
448
|
+
return placeFullWidthBand(overflowInstance, instance);
|
|
449
|
+
}
|
|
450
|
+
// Standard behavior: move entire band to next page (only if not already at top)
|
|
451
|
+
if (cursorY > 0) {
|
|
452
|
+
finalizePage(pageScope(lastUsed, globalPageOffset + pages.length), false);
|
|
453
|
+
startNewPage();
|
|
454
|
+
placePageHeaders(pageScope(instance, globalPageOffset + pages.length));
|
|
455
|
+
placeColumnHeaders(pageScope(instance, globalPageOffset + pages.length));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const contentOffsetY = pageHeaderHeight + columnHeaderHeight + cursorY;
|
|
459
|
+
currentBands.push(createLayoutBand(instance.band, contentOffsetY, measured, scope));
|
|
460
|
+
cursorY += bandHeight;
|
|
461
|
+
return instance;
|
|
462
|
+
}
|
|
463
|
+
// Start first page
|
|
464
|
+
const firstScope = pageScope(expanded.contentBands[0], globalPageOffset + pages.length);
|
|
465
|
+
placePageHeaders(firstScope);
|
|
466
|
+
placeColumnHeaders(firstScope);
|
|
467
|
+
let lastUsedInstance = expanded.contentBands[0];
|
|
468
|
+
const isFlowMode = columnConfig.columnMode === 'flow';
|
|
469
|
+
if (isMultiColumn && colLayout && isFlowMode) {
|
|
470
|
+
// ─── Multi-column flow mode ───
|
|
471
|
+
// Content fills column 1, overflows to column 2, then to new page column 1, etc.
|
|
472
|
+
// Splittable bands can be split mid-column to fill the remaining space.
|
|
473
|
+
const flowColLayout = colLayout; // narrowed — always defined in this branch
|
|
474
|
+
// Same three-phase split as tile mode
|
|
475
|
+
let columnRegionStart = 0;
|
|
476
|
+
let columnRegionEnd = expanded.contentBands.length;
|
|
477
|
+
while (columnRegionStart < expanded.contentBands.length &&
|
|
478
|
+
expanded.contentBands[columnRegionStart].band.type === 'title') {
|
|
479
|
+
columnRegionStart++;
|
|
480
|
+
}
|
|
481
|
+
while (columnRegionEnd > columnRegionStart &&
|
|
482
|
+
!isColumnBandType(expanded.contentBands[columnRegionEnd - 1].band.type)) {
|
|
483
|
+
columnRegionEnd--;
|
|
484
|
+
}
|
|
485
|
+
// Phase 1: Pre-column (title bands) — full-width
|
|
486
|
+
for (let i = 0; i < columnRegionStart; i++) {
|
|
487
|
+
lastUsedInstance = await placeFullWidthBand(expanded.contentBands[i], lastUsedInstance);
|
|
488
|
+
}
|
|
489
|
+
// Phase 2: Column region — flow across columns
|
|
490
|
+
let flowCol = 0;
|
|
491
|
+
let flowColCursor = 0; // vertical cursor within current column
|
|
492
|
+
let flowMaxColCursor = 0; // tallest column height on current page
|
|
493
|
+
function flowStartNewPage(prev, next) {
|
|
494
|
+
finalizePage(pageScope(prev, globalPageOffset + pages.length), false);
|
|
495
|
+
startNewPage();
|
|
496
|
+
const newScope = pageScope(next, globalPageOffset + pages.length);
|
|
497
|
+
placePageHeaders(newScope);
|
|
498
|
+
placeColumnHeaders(newScope);
|
|
499
|
+
flowCol = 0;
|
|
500
|
+
flowColCursor = 0;
|
|
501
|
+
flowMaxColCursor = 0;
|
|
502
|
+
}
|
|
503
|
+
function flowAdvanceColumn(prev, next) {
|
|
504
|
+
flowMaxColCursor = Math.max(flowMaxColCursor, flowColCursor);
|
|
505
|
+
flowCol++;
|
|
506
|
+
flowColCursor = 0;
|
|
507
|
+
if (flowCol >= numColumns) {
|
|
508
|
+
flowStartNewPage(prev, next);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function flowAvailableHeight() {
|
|
512
|
+
return availableContentHeight - cursorY - flowColCursor;
|
|
513
|
+
}
|
|
514
|
+
// Place a band (possibly split) in flow mode, handling column/page overflow.
|
|
515
|
+
async function placeFlowBand(instance) {
|
|
516
|
+
const measured = await measureBand(instance.band, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
517
|
+
const bandHeight = measured.height;
|
|
518
|
+
const scope = pageScope(instance, globalPageOffset + pages.length);
|
|
519
|
+
// Forced page break
|
|
520
|
+
if (instance.band.pageBreakBefore && (flowColCursor > 0 || flowCol > 0 || cursorY > 0)) {
|
|
521
|
+
flowStartNewPage(lastUsedInstance, instance);
|
|
522
|
+
}
|
|
523
|
+
const available = flowAvailableHeight();
|
|
524
|
+
// Band fits in current column
|
|
525
|
+
if (pageConfig.autoHeight || bandHeight <= available) {
|
|
526
|
+
const colOffsetY = pageHeaderHeight + columnHeaderHeight + cursorY + flowColCursor;
|
|
527
|
+
currentBands.push(createLayoutBand(instance.band, colOffsetY, measured, scope, {
|
|
528
|
+
columnIndex: flowCol,
|
|
529
|
+
columnOffsetX: flowColLayout.offsets[flowCol],
|
|
530
|
+
columnWidth: flowColLayout.widths[flowCol],
|
|
531
|
+
}));
|
|
532
|
+
flowColCursor += bandHeight;
|
|
533
|
+
lastUsedInstance = instance;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// Band doesn't fit — try to split
|
|
537
|
+
if (available > 0) {
|
|
538
|
+
const splitResult = await trySplitBand(instance, available);
|
|
539
|
+
if (splitResult) {
|
|
540
|
+
// Place fit portion in current column
|
|
541
|
+
const fitMeasured = await measureBand(splitResult.fitBand, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
542
|
+
const colOffsetY = pageHeaderHeight + columnHeaderHeight + cursorY + flowColCursor;
|
|
543
|
+
currentBands.push(createLayoutBand(splitResult.fitBand, colOffsetY, fitMeasured, scope, {
|
|
544
|
+
columnIndex: flowCol,
|
|
545
|
+
columnOffsetX: flowColLayout.offsets[flowCol],
|
|
546
|
+
columnWidth: flowColLayout.widths[flowCol],
|
|
547
|
+
}));
|
|
548
|
+
flowColCursor += fitMeasured.height;
|
|
549
|
+
// Advance to next column/page for overflow
|
|
550
|
+
const overflowInstance = {
|
|
551
|
+
band: splitResult.overflowBand,
|
|
552
|
+
scope: instance.scope,
|
|
553
|
+
};
|
|
554
|
+
flowAdvanceColumn(lastUsedInstance, overflowInstance);
|
|
555
|
+
lastUsedInstance = instance;
|
|
556
|
+
// Recursively place overflow
|
|
557
|
+
await placeFlowBand(overflowInstance);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// Can't split or nothing fits — advance to next column/page
|
|
562
|
+
if (flowColCursor > 0 || flowCol > 0) {
|
|
563
|
+
flowAdvanceColumn(lastUsedInstance, instance);
|
|
564
|
+
}
|
|
565
|
+
// Band still doesn't fit — it's just too tall for any column — place it anyway
|
|
566
|
+
const colOffsetY = pageHeaderHeight + columnHeaderHeight + cursorY + flowColCursor;
|
|
567
|
+
currentBands.push(createLayoutBand(instance.band, colOffsetY, measured, scope, {
|
|
568
|
+
columnIndex: flowCol,
|
|
569
|
+
columnOffsetX: flowColLayout.offsets[flowCol],
|
|
570
|
+
columnWidth: flowColLayout.widths[flowCol],
|
|
571
|
+
}));
|
|
572
|
+
flowColCursor += measured.height;
|
|
573
|
+
lastUsedInstance = instance;
|
|
574
|
+
}
|
|
575
|
+
for (let i = columnRegionStart; i < columnRegionEnd; i++) {
|
|
576
|
+
await placeFlowBand(expanded.contentBands[i]);
|
|
577
|
+
}
|
|
578
|
+
// Resume full-width cursor after flow columns — use tallest column height
|
|
579
|
+
cursorY += Math.max(flowMaxColCursor, flowColCursor);
|
|
580
|
+
// Phase 3: Post-column (body/summary/noData bands) — full-width
|
|
581
|
+
for (let i = columnRegionEnd; i < expanded.contentBands.length; i++) {
|
|
582
|
+
lastUsedInstance = await placeFullWidthBand(expanded.contentBands[i], lastUsedInstance);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
else if (isMultiColumn && colLayout) {
|
|
586
|
+
// ─── Multi-column tile mode ───
|
|
587
|
+
// Split content bands into three phases:
|
|
588
|
+
// Pre-column (full-width): title bands
|
|
589
|
+
// Column region: detail, groupHeader, groupFooter bands
|
|
590
|
+
// Post-column (full-width): body, summary, noData bands
|
|
591
|
+
// Find phase boundaries
|
|
592
|
+
let columnRegionStart = 0;
|
|
593
|
+
let columnRegionEnd = expanded.contentBands.length;
|
|
594
|
+
// Pre-column: title bands at the start
|
|
595
|
+
while (columnRegionStart < expanded.contentBands.length &&
|
|
596
|
+
expanded.contentBands[columnRegionStart].band.type === 'title') {
|
|
597
|
+
columnRegionStart++;
|
|
598
|
+
}
|
|
599
|
+
// Post-column: body/summary/noData bands at the end
|
|
600
|
+
while (columnRegionEnd > columnRegionStart &&
|
|
601
|
+
!isColumnBandType(expanded.contentBands[columnRegionEnd - 1].band.type)) {
|
|
602
|
+
columnRegionEnd--;
|
|
603
|
+
}
|
|
604
|
+
// Phase 1: Pre-column (title bands) — full-width
|
|
605
|
+
for (let i = 0; i < columnRegionStart; i++) {
|
|
606
|
+
lastUsedInstance = await placeFullWidthBand(expanded.contentBands[i], lastUsedInstance);
|
|
607
|
+
}
|
|
608
|
+
// Phase 2: Column region — tile across columns
|
|
609
|
+
const columnCursors = new Array(numColumns).fill(0);
|
|
610
|
+
let currentCol = 0;
|
|
611
|
+
function startNewColumnPage(prev, next) {
|
|
612
|
+
finalizePage(pageScope(prev, globalPageOffset + pages.length), false);
|
|
613
|
+
startNewPage();
|
|
614
|
+
const newScope = pageScope(next, globalPageOffset + pages.length);
|
|
615
|
+
placePageHeaders(newScope);
|
|
616
|
+
placeColumnHeaders(newScope);
|
|
617
|
+
columnCursors.fill(0);
|
|
618
|
+
currentCol = 0;
|
|
619
|
+
}
|
|
620
|
+
for (let i = columnRegionStart; i < columnRegionEnd; i++) {
|
|
621
|
+
const instance = expanded.contentBands[i];
|
|
622
|
+
const measured = await measureBand(instance.band, fonts, styles, defaultStyle, getPlugin, pdfDoc, imageCache, fCtx);
|
|
623
|
+
const bandHeight = measured.height;
|
|
624
|
+
const scope = pageScope(instance, globalPageOffset + pages.length);
|
|
625
|
+
// Forced page break
|
|
626
|
+
if (instance.band.pageBreakBefore) {
|
|
627
|
+
const hasAnyContent = columnCursors.some((c) => c > 0) || cursorY > 0;
|
|
628
|
+
if (hasAnyContent) {
|
|
629
|
+
startNewColumnPage(lastUsedInstance, instance);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// Check if band fits in current column
|
|
633
|
+
if (!pageConfig.autoHeight &&
|
|
634
|
+
bandHeight > availableContentHeight - cursorY - columnCursors[currentCol]) {
|
|
635
|
+
// Try next columns
|
|
636
|
+
let found = false;
|
|
637
|
+
for (let col = currentCol + 1; col < numColumns; col++) {
|
|
638
|
+
if (bandHeight <= availableContentHeight - cursorY - columnCursors[col]) {
|
|
639
|
+
currentCol = col;
|
|
640
|
+
found = true;
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (!found) {
|
|
645
|
+
// All columns full — start new page (only if there's existing content)
|
|
646
|
+
if (columnCursors.some((c) => c > 0) || cursorY > 0) {
|
|
647
|
+
startNewColumnPage(lastUsedInstance, instance);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// Place band in current column
|
|
652
|
+
const colOffsetY = pageHeaderHeight + columnHeaderHeight + cursorY + columnCursors[currentCol];
|
|
653
|
+
currentBands.push(createLayoutBand(instance.band, colOffsetY, measured, scope, {
|
|
654
|
+
columnIndex: currentCol,
|
|
655
|
+
columnOffsetX: colLayout.offsets[currentCol],
|
|
656
|
+
columnWidth: colLayout.widths[currentCol],
|
|
657
|
+
}));
|
|
658
|
+
columnCursors[currentCol] += bandHeight;
|
|
659
|
+
lastUsedInstance = instance;
|
|
660
|
+
}
|
|
661
|
+
// Resume full-width cursor from tallest column
|
|
662
|
+
cursorY += Math.max(0, ...columnCursors);
|
|
663
|
+
// Phase 3: Post-column (body/summary/noData bands) — full-width
|
|
664
|
+
for (let i = columnRegionEnd; i < expanded.contentBands.length; i++) {
|
|
665
|
+
lastUsedInstance = await placeFullWidthBand(expanded.contentBands[i], lastUsedInstance);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
// ─── Single-column layout ───
|
|
670
|
+
for (let i = 0; i < expanded.contentBands.length; i++) {
|
|
671
|
+
lastUsedInstance = await placeFullWidthBand(expanded.contentBands[i], lastUsedInstance);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// Finalize last page
|
|
675
|
+
const lastScope = pageScope(lastUsedInstance, globalPageOffset + pages.length);
|
|
676
|
+
finalizePage(lastScope, true);
|
|
677
|
+
startNewPage();
|
|
678
|
+
return pages;
|
|
679
|
+
}
|
|
680
|
+
//# sourceMappingURL=layout.js.map
|