@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/dist/anchors.d.ts +13 -0
  4. package/dist/anchors.d.ts.map +1 -0
  5. package/dist/anchors.js +37 -0
  6. package/dist/anchors.js.map +1 -0
  7. package/dist/band-expander.d.ts +25 -0
  8. package/dist/band-expander.d.ts.map +1 -0
  9. package/dist/band-expander.js +193 -0
  10. package/dist/band-expander.js.map +1 -0
  11. package/dist/bookmarks.d.ts +22 -0
  12. package/dist/bookmarks.d.ts.map +1 -0
  13. package/dist/bookmarks.js +78 -0
  14. package/dist/bookmarks.js.map +1 -0
  15. package/dist/columns.d.ts +18 -0
  16. package/dist/columns.d.ts.map +1 -0
  17. package/dist/columns.js +31 -0
  18. package/dist/columns.js.map +1 -0
  19. package/dist/context.d.ts +8 -0
  20. package/dist/context.d.ts.map +1 -0
  21. package/dist/context.js +38 -0
  22. package/dist/context.js.map +1 -0
  23. package/dist/coordinate.d.ts +14 -0
  24. package/dist/coordinate.d.ts.map +1 -0
  25. package/dist/coordinate.js +16 -0
  26. package/dist/coordinate.js.map +1 -0
  27. package/dist/data.d.ts +18 -0
  28. package/dist/data.d.ts.map +1 -0
  29. package/dist/data.js +56 -0
  30. package/dist/data.js.map +1 -0
  31. package/dist/expression.d.ts +13 -0
  32. package/dist/expression.d.ts.map +1 -0
  33. package/dist/expression.js +90 -0
  34. package/dist/expression.js.map +1 -0
  35. package/dist/font-loader.d.ts +8 -0
  36. package/dist/font-loader.d.ts.map +1 -0
  37. package/dist/font-loader.js +29 -0
  38. package/dist/font-loader.js.map +1 -0
  39. package/dist/fonts.d.ts +19 -0
  40. package/dist/fonts.d.ts.map +1 -0
  41. package/dist/fonts.js +124 -0
  42. package/dist/fonts.js.map +1 -0
  43. package/dist/footnotes.d.ts +34 -0
  44. package/dist/footnotes.d.ts.map +1 -0
  45. package/dist/footnotes.js +103 -0
  46. package/dist/footnotes.js.map +1 -0
  47. package/dist/gradient.d.ts +19 -0
  48. package/dist/gradient.d.ts.map +1 -0
  49. package/dist/gradient.js +176 -0
  50. package/dist/gradient.js.map +1 -0
  51. package/dist/index.d.ts +11 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +10 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/layout.d.ts +54 -0
  56. package/dist/layout.d.ts.map +1 -0
  57. package/dist/layout.js +680 -0
  58. package/dist/layout.js.map +1 -0
  59. package/dist/renderer.d.ts +19 -0
  60. package/dist/renderer.d.ts.map +1 -0
  61. package/dist/renderer.js +570 -0
  62. package/dist/renderer.js.map +1 -0
  63. package/dist/style-resolver.d.ts +15 -0
  64. package/dist/style-resolver.d.ts.map +1 -0
  65. package/dist/style-resolver.js +39 -0
  66. package/dist/style-resolver.js.map +1 -0
  67. 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