@pdfme/common 5.5.7-dev.2 → 5.5.7-dev.4

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.
@@ -1,6 +1,9 @@
1
1
  import { Schema, Template, BasePdf, BlankPdf, CommonOptions } from './types.js';
2
2
  import { cloneDeep, isBlankPdf } from './helper.js';
3
3
 
4
+ /** Floating point tolerance for comparisons */
5
+ const EPSILON = 0.01;
6
+
4
7
  interface ModifyTemplateForDynamicTableArg {
5
8
  template: Template;
6
9
  input: Record<string, string>;
@@ -17,333 +20,300 @@ interface ModifyTemplateForDynamicTableArg {
17
20
  ) => Promise<number[]>;
18
21
  }
19
22
 
20
- class LayoutNode {
21
- index = 0;
22
-
23
- schema?: Schema;
24
-
25
- children: LayoutNode[] = [];
26
-
27
- width = 0;
28
- height = 0;
29
- padding: [number, number, number, number] = [0, 0, 0, 0];
30
- position: { x: number; y: number } = { x: 0, y: 0 };
31
-
32
- constructor({ width = 0, height = 0 } = {}) {
33
- this.width = width;
34
- this.height = height;
35
- }
36
-
37
- setIndex(index: number): void {
38
- this.index = index;
39
- }
40
-
41
- setSchema(schema: Schema): void {
42
- this.schema = schema;
43
- }
44
-
45
- setWidth(width: number): void {
46
- this.width = width;
47
- }
48
-
49
- setHeight(height: number): void {
50
- this.height = height;
51
- }
52
-
53
- setPadding(padding: [number, number, number, number]): void {
54
- this.padding = padding;
55
- }
56
-
57
- setPosition(position: { x: number; y: number }): void {
58
- this.position = position;
59
- }
60
-
61
- insertChild(child: LayoutNode): void {
62
- const index = this.getChildCount();
63
- child.setIndex(index);
64
- this.children.splice(index, 0, child);
65
- }
66
-
67
- getChildCount(): number {
68
- return this.children.length;
69
- }
70
-
71
- getChild(index: number): LayoutNode {
72
- return this.children[index];
73
- }
74
- }
75
-
76
- function createPage(basePdf: BlankPdf) {
77
- const page = new LayoutNode({ ...basePdf });
78
- page.setPadding(basePdf.padding);
79
- return page;
80
- }
81
-
82
- function createNode(arg: {
23
+ interface LayoutItem {
83
24
  schema: Schema;
84
- position: { x: number; y: number };
85
- width: number;
25
+ baseY: number;
86
26
  height: number;
87
- }) {
88
- const { position, width, height, schema } = arg;
89
- const node = new LayoutNode({ width, height });
90
- node.setPosition(position);
91
- node.setSchema(schema);
92
- return node;
27
+ dynamicHeights: number[];
93
28
  }
94
29
 
95
- function resortChildren(page: LayoutNode, orderMap: Map<string, number>): void {
96
- page.children = page.children
97
- .sort((a, b) => {
98
- const orderA = orderMap.get(a.schema?.name ?? '');
99
- const orderB = orderMap.get(b.schema?.name ?? '');
100
- if (orderA === undefined || orderB === undefined) {
101
- throw new Error('[@pdfme/common] order is not defined');
102
- }
103
- return orderA - orderB;
104
- })
105
- .map((child, index) => {
106
- child.setIndex(index);
107
- return child;
108
- });
109
- }
110
-
111
- async function createOnePage(
112
- arg: {
113
- basePdf: BlankPdf;
114
- schemaPage: Schema[];
115
- orderMap: Map<string, number>;
116
- } & Omit<ModifyTemplateForDynamicTableArg, 'template'>,
117
- ): Promise<LayoutNode> {
118
- const { basePdf, schemaPage, orderMap, input, options, _cache, getDynamicHeights } = arg;
119
- const page = createPage(basePdf);
120
-
121
- const schemaPositions: number[] = [];
122
- const sortedSchemaEntries = cloneDeep(schemaPage).sort((a, b) => a.position.y - b.position.y);
123
- const diffMap = new Map();
124
- for (const schema of sortedSchemaEntries) {
125
- const { position, width } = schema;
126
-
127
- const opt = { schema, basePdf, options, _cache };
128
- const value = (schema.readOnly ? schema.content : input?.[schema.name]) || '';
129
- const heights = await getDynamicHeights(value, opt);
130
-
131
- const heightsSum = heights.reduce((acc, cur) => acc + cur, 0);
132
- const originalHeight = schema.height;
133
- if (heightsSum !== originalHeight) {
134
- diffMap.set(position.y + originalHeight, heightsSum - originalHeight);
135
- }
136
- heights.forEach((height, index) => {
137
- let y = schema.position.y + heights.reduce((acc, cur, i) => (i < index ? acc + cur : acc), 0);
138
- for (const [diffY, diff] of diffMap.entries()) {
139
- if (diffY <= schema.position.y) {
140
- y += diff;
141
- }
142
- }
143
- const node = createNode({ schema, position: { ...position, y }, width, height });
30
+ /** Calculate the content height of a page (drawable area excluding padding) */
31
+ const getContentHeight = (basePdf: BlankPdf): number =>
32
+ basePdf.height - basePdf.padding[0] - basePdf.padding[2];
33
+
34
+ /** Get the input value for a schema */
35
+ const getSchemaValue = (schema: Schema, input: Record<string, string>): string =>
36
+ (schema.readOnly ? schema.content : input?.[schema.name]) || '';
37
+
38
+ /**
39
+ * Normalize schemas within a single page into layout items.
40
+ * Returns items sorted by Y coordinate with their order preserved.
41
+ */
42
+ function normalizePageSchemas(
43
+ pageSchemas: Schema[],
44
+ paddingTop: number,
45
+ ): { items: LayoutItem[]; orderMap: Map<string, number> } {
46
+ const items: LayoutItem[] = [];
47
+ const orderMap = new Map<string, number>();
144
48
 
145
- schemaPositions.push(y + height + basePdf.padding[2]);
146
- page.insertChild(node);
49
+ pageSchemas.forEach((schema, index) => {
50
+ const localY = schema.position.y - paddingTop;
51
+ items.push({
52
+ schema: cloneDeep(schema),
53
+ baseY: localY,
54
+ height: schema.height,
55
+ dynamicHeights: [schema.height], // Will be updated later
147
56
  });
148
- }
149
-
150
- const pageHeight = Math.max(...schemaPositions, basePdf.height - basePdf.padding[2]);
151
- page.setHeight(pageHeight);
57
+ orderMap.set(schema.name, index);
58
+ });
152
59
 
153
- resortChildren(page, orderMap);
60
+ // Sort by Y coordinate (preserve original order for same position)
61
+ items.sort((a, b) => {
62
+ if (Math.abs(a.baseY - b.baseY) > EPSILON) {
63
+ return a.baseY - b.baseY;
64
+ }
65
+ return (orderMap.get(a.schema.name) ?? 0) - (orderMap.get(b.schema.name) ?? 0);
66
+ });
154
67
 
155
- return page;
68
+ return { items, orderMap };
156
69
  }
157
70
 
158
- function breakIntoPages(arg: {
159
- longPage: LayoutNode;
160
- orderMap: Map<string, number>;
161
- basePdf: BlankPdf;
162
- }): LayoutNode[] {
163
- const { longPage, orderMap, basePdf } = arg;
164
- const pages: LayoutNode[] = [createPage(basePdf)];
165
- const [paddingTop, , paddingBottom] = basePdf.padding;
166
- const yAdjustments: { page: number; value: number }[] = [];
167
-
168
- const getPageHeight = (pageIndex: number) =>
169
- basePdf.height - paddingBottom - (pageIndex > 0 ? paddingTop : 0);
170
-
171
- const calculateNewY = (y: number, pageIndex: number) => {
172
- const newY = y - pageIndex * (basePdf.height - paddingTop - paddingBottom);
173
-
174
- while (pages.length <= pageIndex) {
175
- if (!pages[pageIndex]) {
176
- pages.push(createPage(basePdf));
177
- yAdjustments.push({ page: pageIndex, value: (newY - paddingTop) * -1 });
71
+ /**
72
+ * Place rows on pages, splitting across pages as needed.
73
+ * @returns The final global Y coordinate after placement
74
+ */
75
+ function placeRowsOnPages(
76
+ schema: Schema,
77
+ dynamicHeights: number[],
78
+ startGlobalY: number,
79
+ contentHeight: number,
80
+ paddingTop: number,
81
+ pages: Schema[][],
82
+ ): number {
83
+ let currentRowIndex = 0;
84
+ let currentPageIndex = Math.floor(startGlobalY / contentHeight);
85
+ let currentYInPage = startGlobalY % contentHeight;
86
+
87
+ if (currentYInPage < 0) currentYInPage = 0;
88
+
89
+ let actualGlobalEndY = 0;
90
+ const isSplittable = dynamicHeights.length > 1;
91
+
92
+ while (currentRowIndex < dynamicHeights.length) {
93
+ // Ensure page exists
94
+ while (pages.length <= currentPageIndex) pages.push([]);
95
+
96
+ const spaceLeft = contentHeight - currentYInPage;
97
+ const rowHeight = dynamicHeights[currentRowIndex];
98
+
99
+ // If row doesn't fit, move to next page
100
+ if (rowHeight > spaceLeft + EPSILON) {
101
+ const isAtPageStart = Math.abs(spaceLeft - contentHeight) <= EPSILON;
102
+
103
+ if (!isAtPageStart) {
104
+ currentPageIndex++;
105
+ currentYInPage = 0;
106
+ continue;
178
107
  }
108
+ // Force placement for oversized rows that don't fit even on a fresh page
179
109
  }
180
110
 
181
- return newY + (yAdjustments.find((adj) => adj.page === pageIndex)?.value || 0);
182
- };
183
-
184
- const calculateTargetPageIndex = (y: number) => {
185
- let accumulatedHeight = 0;
186
- let pageIndex = 0;
111
+ // Pack as many rows as possible on this page
112
+ let chunkHeight = 0;
113
+ const startRowIndex = currentRowIndex;
187
114
 
188
- while (true) {
189
- const currentPageHeight = getPageHeight(pageIndex);
190
-
191
- if (y <= accumulatedHeight + currentPageHeight) {
192
- return pageIndex;
115
+ while (currentRowIndex < dynamicHeights.length) {
116
+ const h = dynamicHeights[currentRowIndex];
117
+ if (currentYInPage + chunkHeight + h <= contentHeight + EPSILON) {
118
+ chunkHeight += h;
119
+ currentRowIndex++;
120
+ } else {
121
+ break;
193
122
  }
194
-
195
- accumulatedHeight += currentPageHeight;
196
- pageIndex++;
197
123
  }
198
- };
199
-
200
- const children = longPage.children.sort((a, b) => a.position.y - b.position.y);
201
-
202
- let currentPageIndex = 0;
203
124
 
204
- for (let i = 0; i < children.length; i++) {
205
- const { schema, position, height, width } = children[i];
206
- const { y, x } = position;
125
+ // Don't leave header alone on a page without any data rows
126
+ // If only header fits and there are data rows remaining, move everything to next page
127
+ // BUT: if already at page top, don't move (prevents infinite loop when data row is too large)
128
+ const isAtPageTop = currentYInPage <= EPSILON;
129
+ if (
130
+ isSplittable &&
131
+ startRowIndex === 0 &&
132
+ currentRowIndex === 1 &&
133
+ dynamicHeights.length > 1 &&
134
+ !isAtPageTop
135
+ ) {
136
+ currentRowIndex = 0;
137
+ currentPageIndex++;
138
+ currentYInPage = 0;
139
+ continue;
140
+ }
207
141
 
208
- let targetPageIndex = calculateTargetPageIndex(y);
142
+ // Force at least one row to prevent infinite loop
143
+ if (currentRowIndex === startRowIndex) {
144
+ chunkHeight += dynamicHeights[currentRowIndex];
145
+ currentRowIndex++;
146
+ }
209
147
 
210
- if (targetPageIndex < currentPageIndex) {
211
- targetPageIndex = currentPageIndex;
148
+ // Create schema for this chunk
149
+ const newSchema: Schema = {
150
+ ...schema,
151
+ height: chunkHeight,
152
+ position: { ...schema.position, y: currentYInPage + paddingTop },
153
+ };
154
+
155
+ // Set bodyRange for splittable elements
156
+ // dynamicHeights[0] = header row, dynamicHeights[1] = body[0]
157
+ // So subtract 1 to convert to body index
158
+ if (isSplittable) {
159
+ newSchema.__bodyRange = {
160
+ start: startRowIndex === 0 ? 0 : startRowIndex - 1,
161
+ end: currentRowIndex - 1,
162
+ };
163
+ newSchema.__isSplit = startRowIndex > 0;
212
164
  }
213
165
 
214
- let newY = calculateNewY(y, targetPageIndex);
166
+ pages[currentPageIndex].push(newSchema);
215
167
 
216
- while (newY + height > basePdf.height - paddingBottom) {
217
- const currentPageContentHeight = getPageHeight(targetPageIndex + 1);
168
+ // Update position
169
+ currentYInPage += chunkHeight;
218
170
 
219
- if (height > currentPageContentHeight) {
220
- targetPageIndex++;
221
- newY = calculateNewY(y, targetPageIndex);
222
- break;
223
- }
224
-
225
- targetPageIndex++;
226
- newY = calculateNewY(y, targetPageIndex);
171
+ if (currentYInPage >= contentHeight - EPSILON) {
172
+ currentPageIndex++;
173
+ currentYInPage = 0;
227
174
  }
228
175
 
229
- currentPageIndex = targetPageIndex;
230
-
231
- if (!schema) throw new Error('[@pdfme/common] schema is undefined');
232
-
233
- const clonedElement = createNode({ schema, position: { x, y: newY }, width, height });
234
- pages[targetPageIndex].insertChild(clonedElement);
176
+ actualGlobalEndY = currentPageIndex * contentHeight + currentYInPage;
235
177
  }
236
178
 
237
- pages.forEach((page) => resortChildren(page, orderMap));
238
-
239
- return pages;
179
+ return actualGlobalEndY;
240
180
  }
241
181
 
242
- function createNewTemplate(pages: LayoutNode[], basePdf: BlankPdf): Template {
243
- const newTemplate: Template = {
244
- schemas: Array.from({ length: pages.length }, () => [] as Schema[]),
245
- basePdf: basePdf,
246
- };
247
-
248
- const nameToSchemas = new Map<string, LayoutNode[]>();
249
-
250
- cloneDeep(pages).forEach((page, pageIndex) => {
251
- page.children.forEach((child) => {
252
- const { schema } = child;
253
- if (!schema) throw new Error('[@pdfme/common] schema is undefined');
254
-
255
- const name = schema.name;
256
- if (!nameToSchemas.has(name)) {
257
- nameToSchemas.set(name, []);
258
- }
259
- nameToSchemas.get(name)!.push(child);
260
-
261
- const sameNameSchemas = page.children.filter((c) => c.schema?.name === name);
262
- const start = nameToSchemas.get(name)!.length - sameNameSchemas.length;
263
-
264
- if (sameNameSchemas.length > 0) {
265
- if (!sameNameSchemas[0].schema) {
266
- throw new Error('[@pdfme/common] schema is undefined');
267
- }
268
-
269
- // Use the first schema to get the schema and position
270
- const schema = sameNameSchemas[0].schema;
271
- const height = sameNameSchemas.reduce((acc, cur) => acc + cur.height, 0);
272
- const position = sameNameSchemas[0].position;
273
-
274
- // Currently, __bodyRange exists for table schemas, but if we make it more abstract,
275
- // it could be used for other schemas as well to render schemas that have been split by page breaks, starting from the middle.
276
- schema.__bodyRange = {
277
- start: Math.max(start - 1, 0),
278
- end: start + sameNameSchemas.length - 1,
279
- };
280
-
281
- // Currently, this is used to determine whether to display the header when a table is split.
282
- schema.__isSplit = start > 0;
283
-
284
- const newSchema = Object.assign({}, schema, { position, height });
285
- const index = newTemplate.schemas[pageIndex].findIndex((s) => s.name === name);
286
- if (index !== -1) {
287
- newTemplate.schemas[pageIndex][index] = newSchema;
288
- } else {
289
- newTemplate.schemas[pageIndex].push(newSchema);
290
- }
291
- }
292
- });
182
+ /** Sort elements within each page by their original order */
183
+ function sortPagesByOrder(pages: Schema[][], orderMap: Map<string, number>): void {
184
+ pages.forEach((page) => {
185
+ page.sort((a, b) => (orderMap.get(a.name) ?? 0) - (orderMap.get(b.name) ?? 0));
293
186
  });
187
+ }
294
188
 
295
- return newTemplate;
189
+ /** Remove trailing empty pages */
190
+ function removeTrailingEmptyPages(pages: Schema[][]): void {
191
+ while (pages.length > 1 && pages[pages.length - 1].length === 0) {
192
+ pages.pop();
193
+ }
296
194
  }
297
195
 
298
- function flattenSchemasWithOffset(
299
- templateSchemas: Schema[][],
300
- basePdf: BlankPdf,
301
- ): { flatSchemas: Schema[]; orderMap: Map<string, number> } {
302
- const flatSchemas: Schema[] = [];
303
- const orderMap = new Map<string, number>();
304
- let globalOrder = 0;
305
- const [paddingTop, , paddingBottom] = basePdf.padding;
306
- const getPageHeight = (pageIndex: number) =>
307
- basePdf.height - paddingBottom - (pageIndex > 0 ? paddingTop : 0);
308
-
309
- let yOffset = 0;
310
- templateSchemas.forEach((schemaPage, pageIndex) => {
311
- if (pageIndex > 0) yOffset += getPageHeight(pageIndex - 1);
312
-
313
- schemaPage.forEach((schema) => {
314
- const clonedSchema = cloneDeep(schema);
315
- clonedSchema.position = { ...clonedSchema.position, y: clonedSchema.position.y + yOffset };
316
- flatSchemas.push(clonedSchema);
317
-
318
- if (!orderMap.has(schema.name)) {
319
- orderMap.set(schema.name, globalOrder++);
320
- }
321
- });
322
- });
196
+ /**
197
+ * Process a single template page that has dynamic content.
198
+ * Uses the same layout algorithm as the original implementation,
199
+ * but scoped to a single page's schemas.
200
+ */
201
+ function processDynamicPage(
202
+ items: LayoutItem[],
203
+ orderMap: Map<string, number>,
204
+ contentHeight: number,
205
+ paddingTop: number,
206
+ ): Schema[][] {
207
+ const pages: Schema[][] = [];
208
+ let totalYOffset = 0;
209
+
210
+ for (const item of items) {
211
+ const currentGlobalStartY = item.baseY + totalYOffset;
212
+
213
+ const actualGlobalEndY = placeRowsOnPages(
214
+ item.schema,
215
+ item.dynamicHeights,
216
+ currentGlobalStartY,
217
+ contentHeight,
218
+ paddingTop,
219
+ pages,
220
+ );
221
+
222
+ // Update offset: difference between actual and original end position
223
+ const originalGlobalEndY = item.baseY + item.height;
224
+ totalYOffset = actualGlobalEndY - originalGlobalEndY;
225
+ }
226
+
227
+ sortPagesByOrder(pages, orderMap);
228
+ removeTrailingEmptyPages(pages);
323
229
 
324
- return { flatSchemas, orderMap };
230
+ return pages;
325
231
  }
326
232
 
233
+ /**
234
+ * Process a template containing tables with dynamic heights
235
+ * and generate a new template with proper page breaks.
236
+ *
237
+ * Processing is done page-by-page:
238
+ * - Pages with height changes are processed with full layout calculations
239
+ * - Pages without height changes are copied as-is (no offset propagation between pages)
240
+ *
241
+ * This reduces computation cost by:
242
+ * 1. Limiting layout calculations to pages that need them
243
+ * 2. Avoiding cross-page offset propagation for static pages
244
+ */
327
245
  export const getDynamicTemplate = async (
328
246
  arg: ModifyTemplateForDynamicTableArg,
329
247
  ): Promise<Template> => {
330
- const { template, ...rest } = arg;
248
+ const { template, input, options, _cache, getDynamicHeights } = arg;
331
249
  const basePdf = template.basePdf;
332
250
 
333
251
  if (!isBlankPdf(basePdf)) {
334
252
  return template;
335
253
  }
336
254
 
337
- const { flatSchemas, orderMap } = flattenSchemasWithOffset(template.schemas, basePdf);
255
+ const contentHeight = getContentHeight(basePdf);
256
+ const paddingTop = basePdf.padding[0];
257
+ const resultPages: Schema[][] = [];
258
+ const PARALLEL_LIMIT = 10;
259
+
260
+ // Process each template page independently
261
+ for (let pageIndex = 0; pageIndex < template.schemas.length; pageIndex++) {
262
+ const pageSchemas = template.schemas[pageIndex];
263
+
264
+ // Normalize this page's schemas
265
+ const { items, orderMap } = normalizePageSchemas(pageSchemas, paddingTop);
266
+
267
+ // Calculate dynamic heights for this page's schemas with concurrency limit
268
+ for (let i = 0; i < items.length; i += PARALLEL_LIMIT) {
269
+ const chunk = items.slice(i, i + PARALLEL_LIMIT);
270
+ const chunkResults = await Promise.all(
271
+ chunk.map((item) => {
272
+ const value = getSchemaValue(item.schema, input);
273
+ return getDynamicHeights(value, {
274
+ schema: item.schema,
275
+ basePdf,
276
+ options,
277
+ _cache,
278
+ }).then((heights) => (heights.length === 0 ? [0] : heights));
279
+ }),
280
+ );
281
+ // Update items with calculated heights
282
+ for (let j = 0; j < chunkResults.length; j++) {
283
+ items[i + j].dynamicHeights = chunkResults[j];
284
+ }
285
+ }
286
+
287
+ // Process all pages independently (no cross-page offset propagation)
288
+ const processedPages = processDynamicPage(items, orderMap, contentHeight, paddingTop);
289
+ resultPages.push(...processedPages);
290
+ }
338
291
 
339
- const longPage = await createOnePage({
340
- basePdf,
341
- schemaPage: flatSchemas,
342
- orderMap,
343
- ...rest,
344
- });
292
+ removeTrailingEmptyPages(resultPages);
345
293
 
346
- const pages = breakIntoPages({ longPage, basePdf, orderMap });
294
+ // Check if anything changed - return original template if not
295
+ if (resultPages.length === template.schemas.length) {
296
+ let unchanged = true;
297
+ for (let i = 0; i < resultPages.length && unchanged; i++) {
298
+ if (resultPages[i].length !== template.schemas[i].length) {
299
+ unchanged = false;
300
+ break;
301
+ }
302
+ for (let j = 0; j < resultPages[i].length && unchanged; j++) {
303
+ const orig = template.schemas[i][j];
304
+ const result = resultPages[i][j];
305
+ if (
306
+ Math.abs(orig.height - result.height) > EPSILON ||
307
+ Math.abs(orig.position.y - result.position.y) > EPSILON
308
+ ) {
309
+ unchanged = false;
310
+ }
311
+ }
312
+ }
313
+ if (unchanged) {
314
+ return template;
315
+ }
316
+ }
347
317
 
348
- return createNewTemplate(pages, basePdf);
318
+ return { basePdf, schemas: resultPages };
349
319
  };