@softwear/latestcollectioncore 1.0.174 → 1.0.176

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