@softwear/latestcollectioncore 1.0.175 → 1.0.177

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/src/reports.ts ADDED
@@ -0,0 +1,859 @@
1
+ import { jsPDF } from 'jspdf'
2
+ import { format } from 'date-fns'
3
+ import deepCopy from './deepCopy'
4
+ import { PaperSize, Layout, LayoutObject, ContainerLayoutObject } from './pdf'
5
+
6
+ type RenderMode = 'measurement' | 'rendering'
7
+
8
+ export interface GenPdfAlert {
9
+ header: string
10
+ body?: string
11
+ type?: 'warning' | 'error' | 'success' | 'info'
12
+ timeout?: number
13
+ }
14
+
15
+ export interface PdfFontVariant {
16
+ style: 'normal' | 'bold' | 'italic' | 'bolditalic'
17
+ url: string
18
+ }
19
+
20
+ export interface PdfFontDefinition {
21
+ family: string
22
+ variants: PdfFontVariant[]
23
+ }
24
+
25
+ export interface PdfImageAsset {
26
+ dataUrl: string
27
+ format: 'PNG' | 'JPEG'
28
+ width?: number
29
+ height?: number
30
+ }
31
+
32
+ export interface GenPdfOptions {
33
+ onAlert?: (alert: GenPdfAlert) => void
34
+ translate?: (key: string) => string
35
+ resolveLogoUrl?: () => string | undefined
36
+ isCustomPdfFont?: (fontFamily: string) => boolean
37
+ getPdfFontDefinition?: (fontFamily: string) => PdfFontDefinition | undefined
38
+ loadImage?: (url: string) => Promise<PdfImageAsset | undefined>
39
+ }
40
+
41
+ interface RenderContext {
42
+ mode: RenderMode
43
+ pageCount?: number
44
+ currentPageCount?: number // For measurement mode
45
+ measureOnly?: boolean // When true, skip drawing to measure bounds (for fillContainer rectangles)
46
+ imageAssets?: Map<string, PdfImageAsset>
47
+ /** Layout default font used when object.fontFamily is undefined */
48
+ defaultFontFamily?: string
49
+ }
50
+
51
+ // We have to declare some functions that will be called by other functions but also have to call those other functions
52
+ // We overwrite the function definition as soon as those other functions have been declared
53
+ /* eslint-disable @typescript-eslint/no-unused-vars */
54
+ let drawStaticPartOfPage = function (doc: jsPDF, layout: Layout, printBuffer: any, paperSize: PaperSize, options: GenPdfOptions, context: RenderContext): void {
55
+ // to be assigned later
56
+ }
57
+
58
+ // Forward declaration - parameters intentionally unused, function is overwritten below
59
+ /* eslint-disable @typescript-eslint/no-unused-vars */
60
+ let addObjectToPDF = function (
61
+ _originX: number,
62
+ _originY: number,
63
+ _doc: jsPDF,
64
+ _object: LayoutObject,
65
+ _printBuffer: any,
66
+ _paperSize: PaperSize,
67
+ _layout: Layout,
68
+ _options: GenPdfOptions,
69
+ _rootPrintBuffer: any,
70
+ _containerChain: { object: ContainerLayoutObject; printBuffer: any }[],
71
+ _context: RenderContext
72
+ ): { x: number; y: number } {
73
+ // to be assigned later
74
+ return { x: 0, y: 0 }
75
+ }
76
+ /* eslint-enable @typescript-eslint/no-unused-vars */
77
+
78
+ const formatDate = function (date: number | string): string {
79
+ return format(new Date(date), 'MM-dd-yy HH:mm:ss')
80
+ }
81
+
82
+ function getProperty(propertyName: string, object: any): any {
83
+ const parts = propertyName.split('.')
84
+ const length = parts.length
85
+ let property = object
86
+
87
+ for (let i = 0; i < length; i++) {
88
+ if (property) {
89
+ property = property[parts[i]]
90
+ }
91
+ }
92
+ return property
93
+ }
94
+
95
+ /** Repeat once over the current buffer when the container is only a visual group (no `source` binding). */
96
+ function containerLoopSource(container: ContainerLayoutObject, printBuffer: any): any {
97
+ if (!container.source || container.source === '') return [printBuffer]
98
+ return getProperty(container.source, printBuffer)
99
+ }
100
+
101
+ /** jsPDF font style strings. fontStyle bitmask: 0=normal, 1=bold, 2=italic, 3=bold+italic */
102
+ const FONT_STYLES = ['normal', 'bold', 'italic', 'bolditalic'] as const
103
+
104
+ /** Recursively collect used custom fonts from layout objects (text/field with fontFamily) and layout default. */
105
+ function collectUsedFontsFromLayout(layout: Layout, options: GenPdfOptions): Set<string> {
106
+ const used = new Set<string>()
107
+ const isCustomPdfFont = options.isCustomPdfFont || (() => false)
108
+ function walk(obj: LayoutObject) {
109
+ if ((obj.type === 'text' || obj.type === 'field') && obj.fontFamily && isCustomPdfFont(obj.fontFamily)) {
110
+ used.add(obj.fontFamily)
111
+ }
112
+ if (obj.type === 'container' && obj.children) {
113
+ obj.children.forEach(walk)
114
+ }
115
+ }
116
+ layout.objects.forEach(walk)
117
+ if (layout.defaultFontFamily && isCustomPdfFont(layout.defaultFontFamily)) {
118
+ used.add(layout.defaultFontFamily)
119
+ }
120
+ return used
121
+ }
122
+
123
+ function binaryToBase64(binary: string): string {
124
+ if (typeof btoa === 'function') return btoa(binary)
125
+
126
+ const buffer = (globalThis as any).Buffer
127
+ if (buffer) return buffer.from(binary, 'binary').toString('base64')
128
+
129
+ throw new Error('No base64 encoder available')
130
+ }
131
+
132
+ function bytesToBase64(bytes: Uint8Array): string {
133
+ const buffer = (globalThis as any).Buffer
134
+ if (buffer) return buffer.from(bytes).toString('base64')
135
+
136
+ const chunkSize = 8192
137
+ let binary = ''
138
+ for (let i = 0; i < bytes.length; i += chunkSize) {
139
+ const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length))
140
+ binary += String.fromCharCode.apply(null, Array.from(chunk))
141
+ }
142
+ return binaryToBase64(binary)
143
+ }
144
+
145
+ function readUint16BE(bytes: Uint8Array, offset: number): number {
146
+ return (bytes[offset] << 8) | bytes[offset + 1]
147
+ }
148
+
149
+ function readUint32BE(bytes: Uint8Array, offset: number): number {
150
+ return bytes[offset] * 0x1000000 + ((bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3])
151
+ }
152
+
153
+ function isPng(bytes: Uint8Array): boolean {
154
+ return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
155
+ }
156
+
157
+ function isJpeg(bytes: Uint8Array): boolean {
158
+ return bytes[0] === 0xff && bytes[1] === 0xd8
159
+ }
160
+
161
+ function getImageFormat(url: string, contentType: string | null, bytes: Uint8Array): 'PNG' | 'JPEG' {
162
+ const lowerContentType = contentType?.toLowerCase() || ''
163
+ const lowerUrl = url.toLowerCase()
164
+ if (lowerContentType.includes('png') || isPng(bytes) || lowerUrl.endsWith('.png')) return 'PNG'
165
+ return 'JPEG'
166
+ }
167
+
168
+ function getImageMimeType(format: 'PNG' | 'JPEG'): string {
169
+ return format === 'PNG' ? 'image/png' : 'image/jpeg'
170
+ }
171
+
172
+ function getImageDimensions(bytes: Uint8Array, format: 'PNG' | 'JPEG'): { width?: number; height?: number } {
173
+ if (format === 'PNG' && bytes.length >= 24 && isPng(bytes)) {
174
+ return { width: readUint32BE(bytes, 16), height: readUint32BE(bytes, 20) }
175
+ }
176
+
177
+ if (format === 'JPEG' && isJpeg(bytes)) {
178
+ let offset = 2
179
+ while (offset + 9 < bytes.length) {
180
+ if (bytes[offset] !== 0xff) {
181
+ offset++
182
+ continue
183
+ }
184
+
185
+ const marker = bytes[offset + 1]
186
+ const length = readUint16BE(bytes, offset + 2)
187
+ const isStartOfFrameMarker = (marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)
188
+ if (isStartOfFrameMarker) {
189
+ return { height: readUint16BE(bytes, offset + 5), width: readUint16BE(bytes, offset + 7) }
190
+ }
191
+
192
+ if (length < 2) break
193
+ offset += 2 + length
194
+ }
195
+ }
196
+
197
+ return {}
198
+ }
199
+
200
+ async function loadImageForPdf(url: string): Promise<PdfImageAsset> {
201
+ const res = await fetch(url, { mode: 'cors' })
202
+ if (!res.ok) throw new Error(`Image fetch failed: ${url} ${res.status}`)
203
+
204
+ const arrayBuffer = await res.arrayBuffer()
205
+ const bytes = new Uint8Array(arrayBuffer)
206
+ const format = getImageFormat(url, res.headers.get('content-type'), bytes)
207
+ const dimensions = getImageDimensions(bytes, format)
208
+ const dataUrl = `data:${getImageMimeType(format)};base64,${bytesToBase64(bytes)}`
209
+ return { dataUrl, format, ...dimensions }
210
+ }
211
+
212
+ function resolveImageUrl(object: LayoutObject, printBuffer: any): string | undefined {
213
+ if (object.type !== 'image') return undefined
214
+ const imgObj = object as any
215
+ return imgObj.source && printBuffer ? getProperty(imgObj.source, printBuffer) : imgObj.url
216
+ }
217
+
218
+ function collectImageUrlsFromLayout(layout: Layout, printBuffer: any): string[] {
219
+ const urls = new Set<string>()
220
+
221
+ function walk(objects: LayoutObject[], currentPrintBuffer: any) {
222
+ objects.forEach((object) => {
223
+ if (object.type === 'image') {
224
+ const imageUrl = resolveImageUrl(object, currentPrintBuffer)
225
+ if (imageUrl) urls.add(String(imageUrl))
226
+ return
227
+ }
228
+
229
+ if (object.type !== 'container') return
230
+
231
+ const source = containerLoopSource(object as ContainerLayoutObject, currentPrintBuffer)
232
+ if (!Array.isArray(source)) return
233
+ source.forEach((containerPrintBuffer) => walk(object.children, containerPrintBuffer))
234
+ })
235
+ }
236
+
237
+ walk(layout.objects, printBuffer)
238
+ return [...urls]
239
+ }
240
+
241
+ /** Fetch TTF from URL and return as base64. */
242
+ async function fetchFontAsBase64(url: string): Promise<string> {
243
+ const res = await fetch(url, { mode: 'cors' })
244
+ if (!res.ok) throw new Error(`Font fetch failed: ${url} ${res.status}`)
245
+ const arrayBuffer = await res.arrayBuffer()
246
+ const bytes = new Uint8Array(arrayBuffer)
247
+ const chunkSize = 8192
248
+ let binary = ''
249
+ for (let i = 0; i < bytes.length; i += chunkSize) {
250
+ const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length))
251
+ binary += String.fromCharCode.apply(null, Array.from(chunk))
252
+ }
253
+ const base64 = binaryToBase64(binary)
254
+ return base64
255
+ }
256
+
257
+ /** Embed custom fonts into jsPDF doc. Only embeds fonts that are actually used. */
258
+ async function embedFontsInDoc(doc: jsPDF, usedFontFamilies: Set<string>, options: GenPdfOptions): Promise<void> {
259
+ for (const family of usedFontFamilies) {
260
+ const def = options.getPdfFontDefinition?.(family)
261
+ if (!def) {
262
+ continue
263
+ }
264
+ if (def.variants.length === 0) {
265
+ continue
266
+ }
267
+ for (const variant of def.variants) {
268
+ const vfsName = `${family}-${variant.style}.ttf`
269
+ const base64 = await fetchFontAsBase64(variant.url)
270
+ doc.addFileToVFS(vfsName, base64)
271
+ doc.addFont(vfsName, family, variant.style, 'Identity-H')
272
+ }
273
+ }
274
+ }
275
+
276
+ function truncateTextToFitWidth(doc: jsPDF, text: string, maxWidth: number): string {
277
+ if (doc.getTextWidth(text) <= maxWidth) return text
278
+ const ELLIPSIS = '...'
279
+
280
+ let truncatedText = ''
281
+ for (let i = 0; i < text.length; i++) {
282
+ const tempText = truncatedText + text[i]
283
+ if (doc.getTextWidth(tempText + ELLIPSIS) > maxWidth) break
284
+ truncatedText = tempText
285
+ }
286
+ return truncatedText + ELLIPSIS
287
+ }
288
+
289
+ function drawSimpleObject(
290
+ x: number,
291
+ y: number,
292
+ doc: jsPDF,
293
+ object: LayoutObject,
294
+ printBuffer: any,
295
+ width: number,
296
+ height: number,
297
+ options: GenPdfOptions,
298
+ context: RenderContext
299
+ ): void {
300
+ let text = '' // Will hold text to display for either 'text' or 'field'
301
+
302
+ // Get text from object for text objects
303
+ if (object.type == 'text') {
304
+ text = options.translate ? options.translate(object.text) : object.text
305
+ }
306
+ // Get text from printBuffer for field objects
307
+ if (object.type == 'field') {
308
+ if (object.source === 'pageNumber') {
309
+ text = String(doc.getNumberOfPages())
310
+ } else if (object.source === 'pageCount') {
311
+ text = String(context.pageCount || 0)
312
+ } else if (object.source === 'page') {
313
+ text = String(doc.getNumberOfPages()) + ' / ' + String(context.pageCount || 0)
314
+ } else if (object.source && printBuffer) {
315
+ text = getProperty(object.source, printBuffer)
316
+ } else {
317
+ text = printBuffer
318
+ }
319
+ if (typeof text == 'number') text = '' + text
320
+ if (object.format == 'date') text = formatDate(parseInt(text))
321
+ if (text === undefined || typeof text != 'string') {
322
+ options.onAlert?.({ header: 'fieldnotfound', body: object.source, type: 'warning', timeout: 10000 })
323
+ text = ''
324
+ }
325
+ if (object.format == 'currency') text = String.fromCharCode(128) + ' ' + parseFloat(text).toFixed(2)
326
+ }
327
+ // Manipulate case as specified in object
328
+ if ((object.type === 'text' || object.type === 'field') && object.case === 1) text = text.toUpperCase()
329
+ if ((object.type === 'text' || object.type === 'field') && object.case === 2) text = text.toLowerCase()
330
+
331
+ // Skip actual drawing in measurement mode or when measuring for fillContainer
332
+ if (context.mode === 'measurement' || context.measureOnly) {
333
+ return
334
+ }
335
+
336
+ // Draw rectangle for rectangle objects (supports backgroundColor, borderRadius, strokeWidth, strokeColor)
337
+ // fillContainer rectangles are drawn separately as background - skip here to avoid duplicate 1px strip
338
+ if (object.type == 'rectangle') {
339
+ const rectObj = object as any
340
+ if (rectObj.fillContainer) return
341
+ const hasFill = rectObj.backgroundColor
342
+ const radius = (rectObj.borderRadius || 0) / 10 // layout units to mm
343
+ const hasCustomStroke = rectObj.strokeColor != null || rectObj.strokeWidth != null
344
+ if (hasCustomStroke) {
345
+ if (rectObj.strokeColor) {
346
+ let hex = String(rectObj.strokeColor).replace(/^#/, '')
347
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
348
+ doc.setDrawColor(parseInt(hex.slice(0, 2), 16) || 0, parseInt(hex.slice(2, 4), 16) || 0, parseInt(hex.slice(4, 6), 16) || 0)
349
+ }
350
+ if (rectObj.strokeWidth != null) doc.setLineWidth(rectObj.strokeWidth)
351
+ }
352
+ if (radius > 0 || hasFill) {
353
+ if (hasFill) {
354
+ let hex = String(rectObj.backgroundColor).replace(/^#/, '')
355
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
356
+ doc.setFillColor(parseInt(hex.slice(0, 2), 16) || 0, parseInt(hex.slice(2, 4), 16) || 0, parseInt(hex.slice(4, 6), 16) || 0)
357
+ }
358
+ if (typeof doc.roundedRect === 'function') {
359
+ const mode = hasFill ? 'FD' : 'S' // FD = fill + stroke, S = stroke only
360
+ doc.roundedRect(x, y, width, height, radius, radius, mode)
361
+ } else {
362
+ if (hasFill) doc.rect(x, y, width, height, 'F')
363
+ else doc.rect(x, y, width, height)
364
+ }
365
+ } else {
366
+ doc.rect(x, y, width, height)
367
+ }
368
+ if (hasCustomStroke) {
369
+ doc.setDrawColor(0, 0, 0)
370
+ doc.setLineWidth(0.35)
371
+ }
372
+ }
373
+
374
+ // Draw image for image objects (static url or dynamic source)
375
+ if (object.type == 'image') {
376
+ const imgObj = object as any
377
+ const imgUrl = resolveImageUrl(object, printBuffer)
378
+ if (imgUrl) {
379
+ const urlLower = String(imgUrl).toLowerCase()
380
+ const imageAsset = context.imageAssets?.get(String(imgUrl))
381
+ const format = imageAsset?.format || (urlLower.endsWith('.png') ? 'PNG' : urlLower.endsWith('.jpg') || urlLower.endsWith('.jpeg') ? 'JPEG' : 'JPEG')
382
+ let drawX = x
383
+ let drawY = y
384
+ let drawWidth = width
385
+ let drawHeight = height
386
+ // Preserve aspect ratio when objectFit is 'contain' and we have dimensions
387
+ if (imgObj.objectFit === 'contain' && imageAsset?.width && imageAsset?.height) {
388
+ const scale = Math.min(width / imageAsset.width, height / imageAsset.height)
389
+ drawWidth = imageAsset.width * scale
390
+ drawHeight = imageAsset.height * scale
391
+ drawX = x + (width - drawWidth) / 2
392
+ drawY = y + (height - drawHeight) / 2
393
+ }
394
+ try {
395
+ doc.addImage(imageAsset?.dataUrl || imgUrl, format, drawX, drawY, drawWidth, drawHeight)
396
+ } catch (_e) {
397
+ // Skip if image fails to load (e.g. CORS, invalid URL)
398
+ }
399
+ }
400
+ }
401
+
402
+ // Draw text for text and field objets
403
+ if ((object.type == 'text' || object.type == 'field') && text) {
404
+ let textAlign: 'left' | 'center' | 'right' | undefined
405
+ if (object.textAlign == 1) textAlign = 'left'
406
+ if (object.textAlign == 2) {
407
+ textAlign = 'center'
408
+ x = x + width / 2
409
+ }
410
+ if (object.textAlign == 3) {
411
+ textAlign = 'right'
412
+ x = x + width
413
+ }
414
+
415
+ if (object.name == 'qty' && text == '0') return
416
+ const fontStyleStr = FONT_STYLES[object.fontStyle & 3] || 'normal'
417
+ // Use object font if it's a custom font; otherwise layout default overrides standard fonts (Helvetica etc.)
418
+ const fontFamily = object.fontFamily && options.isCustomPdfFont?.(object.fontFamily) ? object.fontFamily : context.defaultFontFamily || object.fontFamily || 'Helvetica'
419
+ doc.setFont(fontFamily, fontStyleStr)
420
+ doc.setFontSize(object.fontSize / 4)
421
+ doc.text(truncateTextToFitWidth(doc, text, width), x, y, { align: textAlign, baseline: 'hanging' })
422
+ }
423
+ }
424
+
425
+ function addPage(
426
+ originX: number,
427
+ originY: number,
428
+ doc: jsPDF,
429
+ layout: Layout,
430
+ rootPrintBuffer: any,
431
+ paperSize: PaperSize,
432
+ options: GenPdfOptions,
433
+ containerChain: { object: ContainerLayoutObject; printBuffer: any }[],
434
+ context: RenderContext
435
+ ): { originX: number; originY: number; x: number; y: number } {
436
+ if (context.mode === 'measurement') {
437
+ // Track page count in measurement mode
438
+ if (context.currentPageCount === undefined) {
439
+ context.currentPageCount = 1
440
+ }
441
+ context.currentPageCount++
442
+ } else if (!context.measureOnly) {
443
+ // Actually add page in rendering mode (skip when measuring for fillContainer)
444
+ doc.addPage()
445
+ }
446
+ if (!context.measureOnly) {
447
+ drawStaticPartOfPage(doc, layout, rootPrintBuffer, paperSize, options, context)
448
+ }
449
+ const absoluteLowerRightHandSide = { x: originX / 10, y: originY / 10 }
450
+ // Now draw all the parent containers of the current container
451
+ if (containerChain.length > 0) {
452
+ // Let origin start at (x,y) of the outermost container
453
+ originX = containerChain[0].object.x
454
+ originY = containerChain[0].object.y
455
+ absoluteLowerRightHandSide.x = originX / 10
456
+ absoluteLowerRightHandSide.y = originY / 10
457
+ containerChain.forEach((link) => {
458
+ link.object.children.forEach((child) => {
459
+ if (!child.snapToBottom && (child.type != 'container' || child.repeatOnOverflow)) {
460
+ const childLowerRightHandSide = addObjectToPDF(originX, originY, doc, child, link.printBuffer, paperSize, layout, options, rootPrintBuffer, [], context)
461
+ absoluteLowerRightHandSide.x = Math.max(absoluteLowerRightHandSide.x, childLowerRightHandSide.x)
462
+ absoluteLowerRightHandSide.y = Math.max(absoluteLowerRightHandSide.y, childLowerRightHandSide.y)
463
+ }
464
+ })
465
+ if (link.object.repeatContainer == 'horizontal') originX = absoluteLowerRightHandSide.x * 10
466
+ if (link.object.repeatContainer == 'vertical') originY = absoluteLowerRightHandSide.y * 10
467
+ })
468
+ }
469
+ return { originX: originX, originY: originY, x: absoluteLowerRightHandSide.x, y: absoluteLowerRightHandSide.y }
470
+ }
471
+
472
+ function drawBottomDwellers(
473
+ object: ContainerLayoutObject,
474
+ originX: number,
475
+ originY: number,
476
+ absoluteLowerRightHandSide: { x: number; y: number },
477
+ bottomChildY: number,
478
+ paperSize: PaperSize,
479
+ doc: jsPDF,
480
+ layout: Layout,
481
+ rootPrintBuffer: any,
482
+ options: GenPdfOptions,
483
+ newContainerChain: { object: ContainerLayoutObject; printBuffer: any }[],
484
+ container: any,
485
+ context: RenderContext
486
+ ): { originX: number; originY: number; x: number; y: number } {
487
+ let childRelativeToBottomDrawn = false
488
+ let firstBottomRelativeChildY = Math.min(...object.children.filter((child) => child.snapToBottom).map((child) => child.y))
489
+ object.children.forEach(function (child) {
490
+ if (child.snapToBottom) {
491
+ // Objects relative to bottom/right
492
+ if (!childRelativeToBottomDrawn) originY = absoluteLowerRightHandSide.y * 10
493
+ childRelativeToBottomDrawn = true
494
+ // child.text = '======'+originX+':'+originY+'======'
495
+ if (object.pageBreak || originY + object.height - bottomChildY > paperSize.height - paperSize.footerHeight) {
496
+ // We ran out of paper
497
+ const newOrigin = addPage(originX, originY, doc, layout, rootPrintBuffer, paperSize, options, newContainerChain, context)
498
+ originX = newOrigin.originX
499
+ originY = newOrigin.originY
500
+ absoluteLowerRightHandSide = { x: newOrigin.x, y: newOrigin.y }
501
+ firstBottomRelativeChildY = 0
502
+ }
503
+ // originY = absoluteLowerRightHandSide.y * 10
504
+ const childLowerRightHandSide = addObjectToPDF(originX, originY - firstBottomRelativeChildY, doc, child, container, paperSize, layout, options, rootPrintBuffer, [], context)
505
+ absoluteLowerRightHandSide.x = Math.max(absoluteLowerRightHandSide.x, childLowerRightHandSide.x)
506
+ absoluteLowerRightHandSide.y = Math.max(absoluteLowerRightHandSide.y, childLowerRightHandSide.y)
507
+ absoluteLowerRightHandSide.y = Math.max(absoluteLowerRightHandSide.y, (originY + object.height - firstBottomRelativeChildY) / 10)
508
+ }
509
+ })
510
+ if (childRelativeToBottomDrawn) originY = absoluteLowerRightHandSide.y * 10
511
+ return { originX: originX, originY: originY, x: absoluteLowerRightHandSide.x, y: absoluteLowerRightHandSide.y }
512
+ }
513
+
514
+ /**
515
+ * Add an object to the PDF document.
516
+ *
517
+ * Use originX,originY as origin to displace the object's own x,y coordinates
518
+ */
519
+ addObjectToPDF = function (
520
+ originX: number,
521
+ originY: number,
522
+ doc: jsPDF,
523
+ object: LayoutObject,
524
+ printBuffer: any,
525
+ paperSize: PaperSize,
526
+ layout: Layout,
527
+ options: GenPdfOptions,
528
+ rootPrintBuffer: any,
529
+ containerChain: { object: ContainerLayoutObject; printBuffer: any }[],
530
+ context: RenderContext
531
+ ): { x: number; y: number } {
532
+ const x = (originX + object.x) / 10
533
+ const y = (originY + object.y) / 10
534
+ const width = object.width / 10
535
+ const height = object.height / 10
536
+ // Keep track of the lower righthandside of all objects in this container
537
+ // We need to return this object so whatever calls addObjectToPDF knows how big our drawing was
538
+ let absoluteLowerRightHandSide = { x: x + width, y: y + height }
539
+
540
+ if (!object.active) return absoluteLowerRightHandSide
541
+ if (object.type != 'container') drawSimpleObject(x, y, doc, object, printBuffer, width, height, options, context)
542
+
543
+ // Recursively draw all child objects from a container object
544
+ if (object.type == 'container') {
545
+ let originX = x * 10,
546
+ originY = y * 10
547
+
548
+ const source = containerLoopSource(object as ContainerLayoutObject, printBuffer)
549
+ if (source === undefined) {
550
+ options.onAlert?.({ header: 'containernotfound', body: object.source, type: 'warning', timeout: 10000 })
551
+ return absoluteLowerRightHandSide
552
+ }
553
+ const nrContainers = source.length
554
+ for (let containerIndex = 0; containerIndex < nrContainers; containerIndex++) {
555
+ const newContainerChain = containerChain.filter(function () {
556
+ return true
557
+ })
558
+ const container = source[containerIndex]
559
+ absoluteLowerRightHandSide = { x: originX / 10, y: originY / 10 }
560
+ const minHeightMm = (object as any).minHeightBeforeBreak
561
+ const useOrphanCheck = minHeightMm != null && minHeightMm > 0
562
+ if (useOrphanCheck && (object.pageBreak || originY + minHeightMm > paperSize.height - paperSize.footerHeight)) {
563
+ // We ran out of paper
564
+ originX = x * 10
565
+ originY = y * 10
566
+ const newOrigin = addPage(originX, originY, doc, layout, rootPrintBuffer, paperSize, options, newContainerChain, context)
567
+ originX = newOrigin.originX
568
+ originY = newOrigin.originY
569
+ absoluteLowerRightHandSide = { x: newOrigin.x, y: newOrigin.y }
570
+ }
571
+ // If container has fillContainer rectangles, measure bounds first then draw them as background
572
+ const fillContainerRects = object.children.filter((c: any) => c.type === 'rectangle' && c.fillContainer)
573
+ if (fillContainerRects.length > 0 && !context.measureOnly) {
574
+ const measureContext = { ...context, measureOnly: true }
575
+ const measureOriginX = originX
576
+ const measureOriginY = originY
577
+ const measureBounds = { x: originX / 10, y: originY / 10 }
578
+ let measureBottomChildY = 0
579
+ object.children.forEach(function (child: any) {
580
+ if (child.type != 'container' && !child.snapToBottom) {
581
+ measureBottomChildY = Math.max(measureBottomChildY, child.y + child.height)
582
+ const r = addObjectToPDF(measureOriginX, measureOriginY, doc, child, container, paperSize, layout, options, rootPrintBuffer, [], measureContext)
583
+ measureBounds.x = Math.max(measureBounds.x, r.x)
584
+ measureBounds.y = Math.max(measureBounds.y, r.y)
585
+ }
586
+ })
587
+ const measureChain = [...newContainerChain, { object: object as ContainerLayoutObject, printBuffer: container }]
588
+ object.children.forEach(function (child: any) {
589
+ if (child.type == 'container' && !child.snapToBottom) {
590
+ measureBottomChildY = Math.max(measureBottomChildY, child.y + child.height)
591
+ const r = addObjectToPDF(measureOriginX, measureOriginY, doc, child, container, paperSize, layout, options, rootPrintBuffer, measureChain, measureContext)
592
+ measureBounds.x = Math.max(measureBounds.x, r.x)
593
+ measureBounds.y = Math.max(measureBounds.y, r.y)
594
+ }
595
+ })
596
+ const measureBottom = drawBottomDwellers(
597
+ object as ContainerLayoutObject,
598
+ measureOriginX,
599
+ measureOriginY,
600
+ { x: measureBounds.x, y: measureBounds.y },
601
+ measureBottomChildY,
602
+ paperSize,
603
+ doc,
604
+ layout,
605
+ rootPrintBuffer,
606
+ options,
607
+ measureChain,
608
+ container,
609
+ measureContext
610
+ )
611
+ measureBounds.y = Math.max(measureBounds.y, measureBottom.y)
612
+ // Draw fillContainer rectangles as background
613
+ fillContainerRects.forEach(function (rect: any) {
614
+ const rx = (measureOriginX + rect.x) / 10
615
+ const ry = (measureOriginY + rect.y) / 10
616
+ const rw = rect.width / 10
617
+ const rh = measureBounds.y - ry
618
+ if (rh > 0) {
619
+ const rectObj = rect as any
620
+ const hasFill = rectObj.backgroundColor
621
+ const radius = (rectObj.borderRadius || 0) / 10
622
+ const hasCustomStroke = rectObj.strokeColor != null || rectObj.strokeWidth != null
623
+ if (hasCustomStroke) {
624
+ if (rectObj.strokeColor) {
625
+ let hex = String(rectObj.strokeColor).replace(/^#/, '')
626
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
627
+ doc.setDrawColor(parseInt(hex.slice(0, 2), 16) || 0, parseInt(hex.slice(2, 4), 16) || 0, parseInt(hex.slice(4, 6), 16) || 0)
628
+ }
629
+ if (rectObj.strokeWidth != null) doc.setLineWidth(rectObj.strokeWidth)
630
+ }
631
+ if (hasFill) {
632
+ let hex = String(rectObj.backgroundColor).replace(/^#/, '')
633
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
634
+ doc.setFillColor(parseInt(hex.slice(0, 2), 16) || 0, parseInt(hex.slice(2, 4), 16) || 0, parseInt(hex.slice(4, 6), 16) || 0)
635
+ }
636
+ if (typeof doc.roundedRect === 'function') {
637
+ doc.roundedRect(rx, ry, rw, rh, radius, radius, hasFill ? 'FD' : 'S')
638
+ } else {
639
+ if (hasFill) doc.rect(rx, ry, rw, rh, 'F')
640
+ else doc.rect(rx, ry, rw, rh)
641
+ }
642
+ if (hasCustomStroke) {
643
+ doc.setDrawColor(0, 0, 0)
644
+ doc.setLineWidth(0.35)
645
+ }
646
+ }
647
+ })
648
+ }
649
+ // normal objects first, then containers
650
+ let bottomChildY = 0
651
+ object.children.forEach(function (child) {
652
+ if (child.type != 'container' && !child.snapToBottom) {
653
+ // Objects relative to top/left
654
+ bottomChildY = Math.max(bottomChildY, child.y + child.height)
655
+ const childLowerRightHandSide = addObjectToPDF(originX, originY, doc, child, container, paperSize, layout, options, rootPrintBuffer, [], context)
656
+ absoluteLowerRightHandSide.x = Math.max(absoluteLowerRightHandSide.x, childLowerRightHandSide.x)
657
+ absoluteLowerRightHandSide.y = Math.max(absoluteLowerRightHandSide.y, childLowerRightHandSide.y)
658
+ }
659
+ })
660
+ newContainerChain.push({ object: object as ContainerLayoutObject, printBuffer: container })
661
+ object.children.forEach(function (child) {
662
+ if (child.type == 'container' && !child.snapToBottom) {
663
+ bottomChildY = Math.max(bottomChildY, child.y + child.height)
664
+ const childLowerRightHandSide = addObjectToPDF(originX, originY, doc, child, container, paperSize, layout, options, rootPrintBuffer, newContainerChain, context)
665
+ absoluteLowerRightHandSide.x = Math.max(absoluteLowerRightHandSide.x, childLowerRightHandSide.x)
666
+ absoluteLowerRightHandSide.y = Math.max(absoluteLowerRightHandSide.y, childLowerRightHandSide.y)
667
+ }
668
+ })
669
+ if (containerIndex < nrContainers - 1) {
670
+ if (object.repeatContainer == 'horizontal') originX = absoluteLowerRightHandSide.x * 10
671
+ if (object.repeatContainer == 'vertical') originY = absoluteLowerRightHandSide.y * 10
672
+ if (object.pageBreak || originY + object.height - bottomChildY > paperSize.height - paperSize.footerHeight) {
673
+ // We ran out of paper
674
+ const newOrigin = addPage(originX, originY, doc, layout, rootPrintBuffer, paperSize, options, newContainerChain, context)
675
+ originX = newOrigin.originX
676
+ originY = newOrigin.originY
677
+ absoluteLowerRightHandSide = { x: newOrigin.x, y: newOrigin.y }
678
+ const newestOrigin = drawBottomDwellers(
679
+ object as ContainerLayoutObject,
680
+ originX,
681
+ originY,
682
+ absoluteLowerRightHandSide,
683
+ bottomChildY,
684
+ paperSize,
685
+ doc,
686
+ layout,
687
+ rootPrintBuffer,
688
+ options,
689
+ newContainerChain,
690
+ container,
691
+ context
692
+ )
693
+ originX = newestOrigin.originX
694
+ originY = newestOrigin.originY
695
+ absoluteLowerRightHandSide = { x: newestOrigin.x, y: newestOrigin.y }
696
+ continue
697
+ }
698
+ }
699
+ const newestOrigin = drawBottomDwellers(
700
+ object as ContainerLayoutObject,
701
+ originX,
702
+ originY,
703
+ absoluteLowerRightHandSide,
704
+ bottomChildY,
705
+ paperSize,
706
+ doc,
707
+ layout,
708
+ rootPrintBuffer,
709
+ options,
710
+ newContainerChain,
711
+ container,
712
+ context
713
+ )
714
+ originX = newestOrigin.originX
715
+ originY = newestOrigin.originY
716
+ absoluteLowerRightHandSide = { x: newestOrigin.x, y: newestOrigin.y }
717
+ }
718
+ }
719
+ return absoluteLowerRightHandSide
720
+ }
721
+
722
+ /**
723
+ * Draw all non-container objects on the page
724
+ */
725
+ drawStaticPartOfPage = function (doc: jsPDF, layout: Layout, printBuffer: any, paperSize: PaperSize, options: GenPdfOptions, context: RenderContext): void {
726
+ layout.objects.forEach(function (object) {
727
+ if (object.type != 'container') {
728
+ addObjectToPDF(0, 0, doc, object, printBuffer, paperSize, layout, options, printBuffer, [], context)
729
+ }
730
+ })
731
+ }
732
+
733
+ function sortVertical(objects: LayoutObject[]): void {
734
+ objects.sort((a, b) => a.y - b.y)
735
+ objects.forEach((element) => {
736
+ if ((element as ContainerLayoutObject).children) sortVertical((element as ContainerLayoutObject).children)
737
+ })
738
+ }
739
+
740
+ export async function genPDF(layout: Layout, printBuffer: any, options: GenPdfOptions = {}): Promise<jsPDF> {
741
+ // Make a deep copy of the layout so we can add temporary properties
742
+ layout = deepCopy(layout)
743
+ sortVertical(layout.objects)
744
+ // Load images for static images
745
+ layout.objects.forEach((object) => {
746
+ if (object.type != 'image') return
747
+ if (object.name == 'Logo') {
748
+ const logoUrl = options.resolveLogoUrl?.()
749
+ if (logoUrl) object.url = logoUrl
750
+ }
751
+ })
752
+ // Preload image bytes for Node.js and dimensions for objectFit:contain
753
+ const imageAssets = new Map<string, PdfImageAsset>()
754
+ const loadImage = options.loadImage || loadImageForPdf
755
+ const urls = collectImageUrlsFromLayout(layout, printBuffer)
756
+ await Promise.all(
757
+ urls.map(async (url) => {
758
+ try {
759
+ const asset = await loadImage(url)
760
+ if (asset) imageAssets.set(url, asset)
761
+ } catch {
762
+ // Ignore load failures; jsPDF will still get the original URL as fallback
763
+ }
764
+ })
765
+ )
766
+ const paperSize = layout.paperSize
767
+ const paperSizeCopy = { ...paperSize }
768
+ paperSize.width *= 10
769
+ paperSize.height *= 10
770
+ paperSize.footerHeight *= 10
771
+
772
+ // First pass: Measurement mode - count pages without rendering
773
+ const measurementContext: RenderContext = {
774
+ mode: 'measurement',
775
+ currentPageCount: 1, // Start with page 1
776
+ }
777
+ // Create a minimal jsPDF instance for measurement (needed for some calculations)
778
+ const measurementDoc = new jsPDF({
779
+ orientation: paperSizeCopy.width > paperSizeCopy.height ? 'landscape' : 'portrait',
780
+ format: [paperSizeCopy.width, paperSizeCopy.height],
781
+ })
782
+ const usedFonts = collectUsedFontsFromLayout(layout, options)
783
+ if (usedFonts.size > 0) {
784
+ await embedFontsInDoc(measurementDoc, usedFonts, options)
785
+ }
786
+ drawStaticPartOfPage(measurementDoc, layout, printBuffer, paperSize, options, measurementContext)
787
+
788
+ // Draw containers in measurement mode (same order as render)
789
+ layout.objects
790
+ .filter((object) => object.type == 'container' && (object as ContainerLayoutObject).printOnlyAtStart)
791
+ .forEach((object) => {
792
+ addObjectToPDF(0, 0, measurementDoc, object, printBuffer, paperSize, layout, options, printBuffer, [], measurementContext)
793
+ })
794
+ layout.objects
795
+ .filter(
796
+ (object) =>
797
+ object.type == 'container' &&
798
+ !(object as ContainerLayoutObject).printOnlyAtEnd &&
799
+ !(object as ContainerLayoutObject).printOnlyAtStart
800
+ )
801
+ .forEach((object) => {
802
+ addObjectToPDF(0, 0, measurementDoc, object, printBuffer, paperSize, layout, options, printBuffer, [], measurementContext)
803
+ })
804
+ layout.objects
805
+ .filter((object) => object.type == 'container' && (object as ContainerLayoutObject).printOnlyAtEnd)
806
+ .forEach((object) => {
807
+ addObjectToPDF(0, 0, measurementDoc, object, printBuffer, paperSize, layout, options, printBuffer, [], measurementContext)
808
+ })
809
+
810
+ // Get total page count from measurement pass
811
+ const totalPageCount = measurementContext.currentPageCount || 1
812
+
813
+ // Second pass: Rendering mode - actual PDF generation with pageCount available
814
+ const doc = new jsPDF({
815
+ orientation: paperSizeCopy.width > paperSizeCopy.height ? 'landscape' : 'portrait',
816
+ format: [paperSizeCopy.width, paperSizeCopy.height],
817
+ })
818
+ if (usedFonts.size > 0) {
819
+ await embedFontsInDoc(doc, usedFonts, options)
820
+ }
821
+ const renderContext: RenderContext = {
822
+ mode: 'rendering',
823
+ pageCount: totalPageCount,
824
+ imageAssets,
825
+ defaultFontFamily: layout.defaultFontFamily,
826
+ }
827
+ drawStaticPartOfPage(doc, layout, printBuffer, paperSize, options, renderContext)
828
+
829
+ layout.objects
830
+ .filter((object) => object.type == 'container' && (object as ContainerLayoutObject).printOnlyAtStart)
831
+ .forEach((object) => {
832
+ addObjectToPDF(0, 0, doc, object, printBuffer, paperSize, layout, options, printBuffer, [], renderContext)
833
+ })
834
+
835
+ // Draw containers in rendering mode (body containers after printOnlyAtStart)
836
+ layout.objects
837
+ .filter(
838
+ (object) =>
839
+ object.type == 'container' &&
840
+ !(object as ContainerLayoutObject).printOnlyAtEnd &&
841
+ !(object as ContainerLayoutObject).printOnlyAtStart
842
+ )
843
+ .forEach((object) => {
844
+ addObjectToPDF(0, 0, doc, object, printBuffer, paperSize, layout, options, printBuffer, [], renderContext)
845
+ })
846
+
847
+ // Draw printOnlyAtEnd containers once at the end (at their layout coordinates on current page)
848
+ const printOnlyAtEndContainers = layout.objects.filter(
849
+ (object) => object.type == 'container' && (object as ContainerLayoutObject).printOnlyAtEnd
850
+ )
851
+ printOnlyAtEndContainers.forEach((object) => {
852
+ addObjectToPDF(0, 0, doc, object, printBuffer, paperSize, layout, options, printBuffer, [], renderContext)
853
+ })
854
+ return doc
855
+ }
856
+
857
+ export default {
858
+ genPDF,
859
+ }