h17-sspdf 0.1.0

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.
@@ -0,0 +1,853 @@
1
+ const fs = require("fs");
2
+ const { jsPDF } = require("jspdf");
3
+ const { pxToMm, ptToMm, resolveLineHeightMm } = require("./units");
4
+
5
+ // Style math helpers — shared between core rendering and height estimation.
6
+
7
+ function getMarginTopMm(style) {
8
+ if (style.marginTopMm !== undefined) {
9
+ return Number(style.marginTopMm) || 0;
10
+ }
11
+ if (style.marginTopPx !== undefined) {
12
+ return pxToMm(style.marginTopPx);
13
+ }
14
+ return 0;
15
+ }
16
+
17
+ function getMarginBottomMm(style) {
18
+ if (style.marginBottomMm !== undefined) {
19
+ return Number(style.marginBottomMm) || 0;
20
+ }
21
+ if (style.marginBottomPx !== undefined) {
22
+ return pxToMm(style.marginBottomPx);
23
+ }
24
+ return 0;
25
+ }
26
+
27
+ function getStyleMarginsMm(style) {
28
+ return {
29
+ top: getMarginTopMm(style),
30
+ bottom: getMarginBottomMm(style),
31
+ };
32
+ }
33
+
34
+ function resolvePaddingValue(mm, px, fallback) {
35
+ if (mm !== undefined) {
36
+ return Number(mm) || 0;
37
+ }
38
+ if (px !== undefined) {
39
+ return pxToMm(px);
40
+ }
41
+ return fallback;
42
+ }
43
+
44
+ function getTextPaddingMm(style) {
45
+ const allMm = style.paddingMm !== undefined ? (Number(style.paddingMm) || 0) : null;
46
+ const allPx = style.paddingPx !== undefined ? pxToMm(style.paddingPx) : null;
47
+ const fallback = allMm !== null ? allMm : (allPx !== null ? allPx : 0);
48
+ return {
49
+ top: resolvePaddingValue(style.paddingTopMm, style.paddingTopPx, fallback),
50
+ right: resolvePaddingValue(style.paddingRightMm, style.paddingRightPx, fallback),
51
+ bottom: resolvePaddingValue(style.paddingBottomMm, style.paddingBottomPx, fallback),
52
+ left: resolvePaddingValue(style.paddingLeftMm, style.paddingLeftPx, fallback),
53
+ };
54
+ }
55
+
56
+ function applyTextTransform(text, transform) {
57
+ if (transform === "upper") {
58
+ return text.toUpperCase();
59
+ }
60
+ if (transform === "lower") {
61
+ return text.toLowerCase();
62
+ }
63
+ return text;
64
+ }
65
+
66
+ /**
67
+ * jsPDF abstraction layer:
68
+ * - page/background lifecycle
69
+ * - cursor and pagination
70
+ * - style application
71
+ * - text, row, bullet, divider primitives
72
+ */
73
+ class PDFCore {
74
+ /**
75
+ * @param {object} theme
76
+ * @param {object} [theme.page]
77
+ * @param {number} [theme.page.margin]
78
+ * @param {string} [theme.page.format]
79
+ * @param {string} [theme.page.orientation]
80
+ * @param {string} [theme.page.unit]
81
+ * @param {boolean} [theme.page.compress]
82
+ * @param {number[]} [theme.page.backgroundColor]
83
+ */
84
+ constructor(theme = {}) {
85
+ this.theme = theme;
86
+ this.page = theme.page || {};
87
+ this.layout = theme.layout || {};
88
+ const baseMargin = Number(this.page.margin) || 15;
89
+ this.marginLeftMm = this.page.marginLeftMm !== undefined
90
+ ? (Number(this.page.marginLeftMm) || 0)
91
+ : baseMargin;
92
+ this.marginRightMm = this.page.marginRightMm !== undefined
93
+ ? (Number(this.page.marginRightMm) || 0)
94
+ : baseMargin;
95
+ this.marginTopMm = this.page.marginTopMm !== undefined
96
+ ? (Number(this.page.marginTopMm) || 0)
97
+ : baseMargin;
98
+ this.marginBottomMm = this.page.marginBottomMm !== undefined
99
+ ? (Number(this.page.marginBottomMm) || 0)
100
+ : baseMargin;
101
+
102
+ this.margin = this.marginLeftMm;
103
+ this.headerHeightMm = Number(this.page.headerHeightMm) || 0;
104
+ this.footerHeightMm = Number(this.page.footerHeightMm) || 0;
105
+ this.format = String(this.page.format || "a4").toLowerCase();
106
+
107
+ if (this.format !== "a4") {
108
+ throw new Error(`Unsupported page format "${this.page.format}". Only A4 is supported.`);
109
+ }
110
+
111
+ this.doc = new jsPDF({
112
+ orientation: this.page.orientation || "portrait",
113
+ unit: this.page.unit || "mm",
114
+ format: this.format,
115
+ compress: this.page.compress !== false,
116
+ });
117
+
118
+ this.pageWidth = this.doc.internal.pageSize.getWidth();
119
+ this.pageHeight = this.doc.internal.pageSize.getHeight();
120
+ this.backgroundColor = this._resolveColor(this.page.backgroundColor);
121
+ this.lastDrawnBounds = null;
122
+ this.defaultRenderState = this._buildDefaultRenderState(this.page);
123
+ this.contentTopY = this.marginTopMm + this.headerHeightMm;
124
+ this.contentBottomY = this.pageHeight - this.marginBottomMm - this.footerHeightMm;
125
+ this.cursorY = this.contentTopY;
126
+
127
+ const meta = this.page.metadata || {};
128
+ this.doc.setProperties({
129
+ title: meta.title || theme.name || "",
130
+ subject: meta.subject || "",
131
+ author: meta.author || "Hugo Palma",
132
+ keywords: meta.keywords || "",
133
+ creator: "SuperSimplePDF (github.com/hugopalma17/sspdf)",
134
+ });
135
+
136
+ this.paintBackground();
137
+ this.applyDefaultRenderState();
138
+ }
139
+
140
+ /**
141
+ * Register a single font face into jsPDF VFS.
142
+ * @param {object} fontFace
143
+ * @param {string} fontFace.family
144
+ * @param {string} fontFace.style
145
+ * @param {string} fontFace.fileName
146
+ * @param {string} fontFace.data Base64 TTF content
147
+ */
148
+ registerFont(fontFace) {
149
+ if (!fontFace || !fontFace.family || !fontFace.fileName || !fontFace.data) {
150
+ throw new Error("Invalid font face registration payload");
151
+ }
152
+ const style = fontFace.style || "normal";
153
+ this.doc.addFileToVFS(fontFace.fileName, fontFace.data);
154
+ this.doc.addFont(fontFace.fileName, fontFace.family, style);
155
+ }
156
+
157
+ /**
158
+ * Register multiple font faces.
159
+ * @param {Array<object>} fontFaces
160
+ */
161
+ registerFonts(fontFaces = []) {
162
+ fontFaces.forEach((face) => this.registerFont(face));
163
+ }
164
+
165
+ /**
166
+ * Draw current page background.
167
+ */
168
+ paintBackground() {
169
+ this.doc.setFillColor(...this.backgroundColor);
170
+ this.doc.rect(0, 0, this.pageWidth, this.pageHeight, "F");
171
+ }
172
+
173
+ /**
174
+ * Force a new page and reset cursor to top margin.
175
+ */
176
+ addPage() {
177
+ this.doc.addPage();
178
+ this.paintBackground();
179
+ this.applyDefaultRenderState();
180
+ this.cursorY = this.contentTopY;
181
+ }
182
+
183
+ /**
184
+ * Run a renderer operation in an isolated jsPDF state.
185
+ * This prevents style leakage between labels/operations.
186
+ * @param {Function} fn
187
+ */
188
+ withDocumentState(fn) {
189
+ if (typeof fn !== "function") {
190
+ throw new Error("withDocumentState requires a callback");
191
+ }
192
+
193
+ const canSaveGraphicsState = typeof this.doc.saveGraphicsState === "function"
194
+ && typeof this.doc.restoreGraphicsState === "function";
195
+ if (canSaveGraphicsState) {
196
+ this.doc.saveGraphicsState();
197
+ }
198
+
199
+ try {
200
+ fn();
201
+ } finally {
202
+ if (canSaveGraphicsState) {
203
+ this.doc.restoreGraphicsState();
204
+ }
205
+ this.applyDefaultRenderState();
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Reapply baseline rendering defaults so the next operation starts clean.
211
+ */
212
+ applyDefaultRenderState() {
213
+ const state = this.defaultRenderState;
214
+ this.doc.setFont(state.text.fontFamily, state.text.fontStyle);
215
+ this.doc.setFontSize(state.text.fontSize);
216
+ this.doc.setTextColor(...state.text.color);
217
+ this.doc.setLineHeightFactor(state.text.lineHeight);
218
+ this.doc.setDrawColor(...state.stroke.color);
219
+ this.doc.setFillColor(...state.fillColor);
220
+ this.doc.setLineWidth(state.stroke.lineWidth);
221
+ this.doc.setLineDashPattern([], 0);
222
+
223
+ if (typeof this.doc.setLineCap === "function") {
224
+ this.doc.setLineCap(state.stroke.lineCap);
225
+ }
226
+ if (typeof this.doc.setLineJoin === "function") {
227
+ this.doc.setLineJoin(state.stroke.lineJoin);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Ensure there is enough vertical space, otherwise create a new page.
233
+ * @param {number} requiredHeightMm
234
+ */
235
+ ensureSpace(requiredHeightMm) {
236
+ const required = Number(requiredHeightMm) || 0;
237
+ if (this.cursorY + required > this.contentBottomY) {
238
+ this.addPage();
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Move cursor down by fixed millimeters.
244
+ * @param {number} mm
245
+ */
246
+ moveDown(mm) {
247
+ this.cursorY += Number(mm) || 0;
248
+ }
249
+
250
+ /**
251
+ * Get current vertical cursor.
252
+ * @returns {number}
253
+ */
254
+ getCursorY() {
255
+ return this.cursorY;
256
+ }
257
+
258
+ /**
259
+ * Set current vertical cursor.
260
+ * @param {number} y
261
+ */
262
+ setCursorY(y) {
263
+ this.cursorY = Number(y) || this.cursorY;
264
+ }
265
+
266
+ /**
267
+ * Apply a text style to jsPDF.
268
+ * @param {object} style
269
+ * @param {string} [style.fontFamily]
270
+ * @param {string} [style.fontStyle]
271
+ * @param {number} [style.fontSize]
272
+ * @param {number[]} [style.color]
273
+ */
274
+ applyTextStyle(style = {}) {
275
+ const fontFamily = style.fontFamily;
276
+ const fontStyle = style.fontStyle;
277
+ const fontSize = Number(style.fontSize);
278
+ const color = this._resolveColor(style.color);
279
+
280
+ this.doc.setFont(fontFamily, fontStyle);
281
+ this.doc.setFontSize(fontSize);
282
+ this.doc.setTextColor(...color);
283
+ }
284
+
285
+ /**
286
+ * Draw wrapped text with style/pagination/cursor handling.
287
+ * @param {object} payload
288
+ * @param {string} payload.text
289
+ * @param {object} payload.style
290
+ * @param {number} [payload.x]
291
+ * @param {number} [payload.y]
292
+ * @param {number} [payload.maxWidth]
293
+ * @param {string} [payload.align]
294
+ * @param {boolean} [payload.wrap]
295
+ * @param {boolean} [payload.advance]
296
+ * @param {boolean} [payload.allowPageBreak]
297
+ * @returns {{ y: number, endY: number, lineCount: number, lineHeightMm: number }}
298
+ */
299
+ drawText(payload) {
300
+ const text = payload && payload.text !== undefined ? String(payload.text) : "";
301
+ const style = (payload && payload.style) || {};
302
+ const x = payload && payload.x !== undefined ? payload.x : this.marginLeftMm;
303
+ const maxWidth = payload && payload.maxWidth !== undefined
304
+ ? payload.maxWidth
305
+ : this.pageWidth - this.marginRightMm - x;
306
+ const align = (payload && payload.align) || style.align || "left";
307
+ const wrap = payload && payload.wrap === false ? false : true;
308
+ const advance = payload && payload.advance === false ? false : true;
309
+ const allowPageBreak = payload && payload.allowPageBreak === false ? false : true;
310
+
311
+ const marginTopMm = getMarginTopMm(style);
312
+ const marginBottomMm = getMarginBottomMm(style);
313
+ const transformedText = applyTextTransform(text, style.textTransform);
314
+ const fontSize = Number(style.fontSize);
315
+ const lineHeightMm = style.lineHeightMm || resolveLineHeightMm(fontSize, style.lineHeight);
316
+ const padding = getTextPaddingMm(style);
317
+ const innerWidth = maxWidth - padding.left - padding.right;
318
+ if (innerWidth <= 0) {
319
+ throw new Error("Text operation has non-positive inner width after padding");
320
+ }
321
+ const lines = wrap
322
+ ? this.measureWrappedLines(transformedText, innerWidth, style)
323
+ : [transformedText];
324
+ const lineCount = Math.max(lines.length, 1);
325
+ const textHeight = lineCount * lineHeightMm;
326
+ const blockHeight = padding.top + textHeight + padding.bottom;
327
+
328
+ let drawY;
329
+ if (payload && payload.y !== undefined) {
330
+ drawY = Number(payload.y) + marginTopMm + padding.top;
331
+ } else {
332
+ if (allowPageBreak) {
333
+ this.ensureSpace(marginTopMm + blockHeight + marginBottomMm);
334
+ }
335
+ drawY = this.cursorY + marginTopMm + padding.top;
336
+ }
337
+
338
+ this._drawTextContainer({
339
+ style,
340
+ x,
341
+ y: drawY - padding.top,
342
+ width: maxWidth,
343
+ height: blockHeight,
344
+ });
345
+ this._drawTextLeftBorder({
346
+ style,
347
+ x,
348
+ y: drawY,
349
+ lineHeightMm,
350
+ blockHeight: textHeight,
351
+ });
352
+ this.applyTextStyle(style);
353
+ const baselineOffsetMm = this._getBaselineOffsetMm(fontSize);
354
+ this.doc.setLineHeightFactor(Number(style.lineHeight));
355
+ this._drawTextLines(lines, {
356
+ x: x + padding.left,
357
+ y: drawY + baselineOffsetMm,
358
+ maxWidth: innerWidth,
359
+ align,
360
+ lineHeightMm,
361
+ });
362
+
363
+ this.applyDefaultRenderState();
364
+
365
+ const endY = drawY + textHeight + padding.bottom;
366
+ this.lastDrawnBounds = {
367
+ topY: drawY - padding.top,
368
+ bottomY: endY,
369
+ leftX: x,
370
+ rightX: x + maxWidth,
371
+ };
372
+ if (advance) {
373
+ this.cursorY = endY + marginBottomMm;
374
+ }
375
+
376
+ return {
377
+ y: drawY,
378
+ endY,
379
+ lineCount,
380
+ lineHeightMm,
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Draw a single-line left/right row (e.g., role title + period).
386
+ * @param {object} payload
387
+ * @param {string} payload.leftText
388
+ * @param {string} payload.rightText
389
+ * @param {object} payload.leftStyle
390
+ * @param {object} payload.rightStyle
391
+ * @param {number} [payload.xLeft]
392
+ * @param {number} [payload.xRight]
393
+ * @param {boolean} [payload.allowPageBreak]
394
+ * @returns {{ y: number, endY: number }}
395
+ */
396
+ drawRow(payload) {
397
+ const leftStyle = payload.leftStyle || {};
398
+ const rightStyle = payload.rightStyle || {};
399
+ const xLeft = payload.xLeft !== undefined ? payload.xLeft : this.marginLeftMm;
400
+ const xRight = payload.xRight !== undefined ? payload.xRight : this.pageWidth - this.marginRightMm;
401
+ const allowPageBreak = payload.allowPageBreak === false ? false : true;
402
+
403
+ const topMm = Math.max(getMarginTopMm(leftStyle), getMarginTopMm(rightStyle));
404
+ const bottomMm = Math.max(getMarginBottomMm(leftStyle), getMarginBottomMm(rightStyle));
405
+
406
+ const leftLineHeight = resolveLineHeightMm(Number(leftStyle.fontSize), leftStyle.lineHeight);
407
+ const rightLineHeight = resolveLineHeightMm(Number(rightStyle.fontSize), rightStyle.lineHeight);
408
+ const rowHeight = Math.max(leftLineHeight, rightLineHeight);
409
+
410
+ if (allowPageBreak) {
411
+ this.ensureSpace(topMm + rowHeight + bottomMm);
412
+ }
413
+ const y = this.cursorY + topMm;
414
+ const leftFontSize = Number(leftStyle.fontSize);
415
+ const rightFontSize = Number(rightStyle.fontSize);
416
+ const baselineOffsetMm = Math.max(
417
+ this._getBaselineOffsetMm(leftFontSize),
418
+ this._getBaselineOffsetMm(rightFontSize)
419
+ );
420
+ const baseline = y + baselineOffsetMm;
421
+
422
+ if (payload.leftText) {
423
+ this.applyTextStyle(leftStyle);
424
+ this.doc.text(applyTextTransform(String(payload.leftText), leftStyle.textTransform), xLeft, baseline);
425
+ }
426
+
427
+ if (payload.rightText) {
428
+ this.applyTextStyle(rightStyle);
429
+ this.doc.text(applyTextTransform(String(payload.rightText), rightStyle.textTransform), xRight, baseline, { align: "right" });
430
+ }
431
+
432
+ this.applyDefaultRenderState();
433
+
434
+ const endY = y + rowHeight;
435
+ this.lastDrawnBounds = {
436
+ topY: y,
437
+ bottomY: endY,
438
+ leftX: xLeft,
439
+ rightX: xRight,
440
+ };
441
+ this.cursorY = endY + bottomMm;
442
+
443
+ return { y, endY };
444
+ }
445
+
446
+ /**
447
+ * Draw bullet marker + wrapped bullet text.
448
+ * @param {object} payload
449
+ * @param {string} payload.text
450
+ * @param {object} payload.textStyle
451
+ * @param {object} payload.markerStyle
452
+ * @param {string} [payload.marker]
453
+ * @param {number} [payload.x]
454
+ * @param {number} [payload.textIndentMm]
455
+ * @param {number} [payload.maxWidth]
456
+ * @param {boolean} [payload.allowPageBreak]
457
+ * @returns {{ y: number, endY: number, lineCount: number }}
458
+ */
459
+ drawBullet(payload) {
460
+ const textStyle = payload.textStyle || {};
461
+ const markerStyle = payload.markerStyle || {};
462
+ const marker = payload.marker || markerStyle.marker;
463
+ const x = payload.x !== undefined ? payload.x : this.marginLeftMm;
464
+ const textIndentMm = payload.textIndentMm !== undefined ? payload.textIndentMm : 4;
465
+ const textX = x + textIndentMm;
466
+ const maxWidth = payload.maxWidth !== undefined
467
+ ? payload.maxWidth
468
+ : this.pageWidth - this.marginRightMm - textX;
469
+ const text = payload.text !== undefined ? String(payload.text) : "";
470
+ const allowPageBreak = payload.allowPageBreak === false ? false : true;
471
+
472
+ const topMm = getMarginTopMm(textStyle);
473
+ const bottomMm = getMarginBottomMm(textStyle);
474
+ const lineHeightMm = resolveLineHeightMm(Number(textStyle.fontSize), textStyle.lineHeight);
475
+
476
+ const lines = this.measureWrappedLines(
477
+ applyTextTransform(text, textStyle.textTransform),
478
+ maxWidth,
479
+ textStyle
480
+ );
481
+ const lineCount = Math.max(lines.length, 1);
482
+ const blockHeight = lineCount * lineHeightMm;
483
+
484
+ if (allowPageBreak) {
485
+ this.ensureSpace(topMm + blockHeight + bottomMm);
486
+ }
487
+ const y = this.cursorY + topMm;
488
+ const textFontSize = Number(textStyle.fontSize);
489
+ const markerFontSize = Number(markerStyle.fontSize);
490
+ const baselineOffsetMm = Math.max(
491
+ this._getBaselineOffsetMm(textFontSize),
492
+ this._getBaselineOffsetMm(markerFontSize)
493
+ );
494
+ const baseline = y + baselineOffsetMm;
495
+
496
+ this.applyTextStyle(markerStyle);
497
+ this.doc.text(String(marker), x, baseline);
498
+
499
+ this.applyTextStyle(textStyle);
500
+ this.doc.setLineHeightFactor(Number(textStyle.lineHeight));
501
+ this.doc.text(lines, textX, baseline);
502
+
503
+ this.applyDefaultRenderState();
504
+
505
+ const endY = y + blockHeight;
506
+ this.lastDrawnBounds = {
507
+ topY: y,
508
+ bottomY: endY,
509
+ leftX: x,
510
+ rightX: textX + maxWidth,
511
+ };
512
+ this.cursorY = endY + bottomMm;
513
+
514
+ return { y, endY, lineCount };
515
+ }
516
+
517
+ /**
518
+ * Draw horizontal divider line.
519
+ * @param {object} payload
520
+ * @param {object} payload.style
521
+ * @param {number} [payload.x1]
522
+ * @param {number} [payload.x2]
523
+ * @param {boolean} [payload.allowPageBreak]
524
+ * @returns {{ y: number }}
525
+ */
526
+ drawDivider(payload) {
527
+ const style = payload.style || {};
528
+ const x1 = payload.x1 !== undefined ? payload.x1 : this.marginLeftMm;
529
+ const x2 = payload.x2 !== undefined ? payload.x2 : this.pageWidth - this.marginRightMm;
530
+
531
+ const topMm = getMarginTopMm(style);
532
+ const bottomMm = getMarginBottomMm(style);
533
+ const lineWidth = Number(style.lineWidth);
534
+ const allowPageBreak = payload.allowPageBreak === false ? false : true;
535
+
536
+ if (allowPageBreak) {
537
+ this.ensureSpace(topMm + lineWidth + bottomMm);
538
+ }
539
+ const y = this.cursorY + topMm;
540
+
541
+ if (style.opacity && style.opacity < 1 && this.doc.GState) {
542
+ this.doc.saveGraphicsState();
543
+ this.doc.setGState(this.doc.GState({ "stroke-opacity": style.opacity }));
544
+ }
545
+
546
+ this.doc.setDrawColor(...this._resolveColor(style.color));
547
+ this.doc.setLineWidth(lineWidth);
548
+
549
+ if (Array.isArray(style.dashPattern) && style.dashPattern.length > 0) {
550
+ this.doc.setLineDashPattern(style.dashPattern, 0);
551
+ } else {
552
+ this.doc.setLineDashPattern([], 0);
553
+ }
554
+
555
+ this.doc.line(x1, y, x2, y);
556
+ this.doc.setLineDashPattern([], 0);
557
+
558
+ if (style.opacity && style.opacity < 1 && this.doc.GState) {
559
+ this.doc.restoreGraphicsState();
560
+ }
561
+
562
+ this.applyDefaultRenderState();
563
+
564
+ this.lastDrawnBounds = {
565
+ topY: y,
566
+ bottomY: y + lineWidth,
567
+ leftX: x1,
568
+ rightX: x2,
569
+ };
570
+ this.cursorY = y + lineWidth + bottomMm;
571
+ return { y };
572
+ }
573
+
574
+ /**
575
+ * Draw an image (PNG, JPEG) at the current cursor or specified position.
576
+ * @param {object} payload
577
+ * @param {string|ArrayBuffer|Uint8Array} payload.data Image data (base64 string, ArrayBuffer, or data URL)
578
+ * @param {string} [payload.format] "PNG", "JPEG", etc.
579
+ * @param {number} [payload.x] X position in mm
580
+ * @param {number} [payload.y] Y position in mm (defaults to cursorY)
581
+ * @param {number} payload.widthMm Display width in mm
582
+ * @param {number} payload.heightMm Display height in mm
583
+ * @param {object} [payload.style] Style with margins
584
+ * @param {boolean} [payload.advance] Whether to advance cursor (default true)
585
+ * @param {boolean} [payload.allowPageBreak] Whether to break page (default true)
586
+ * @returns {{ y: number, endY: number }}
587
+ */
588
+ drawImage(payload) {
589
+ const style = (payload && payload.style) || {};
590
+ const x = payload.x !== undefined ? payload.x : this.marginLeftMm;
591
+ const widthMm = Number(payload.widthMm);
592
+ const heightMm = Number(payload.heightMm);
593
+ const advance = payload.advance !== false;
594
+ const allowPageBreak = payload.allowPageBreak !== false;
595
+
596
+ const marginTopMm = getMarginTopMm(style);
597
+ const marginBottomMm = getMarginBottomMm(style);
598
+
599
+ if (allowPageBreak) {
600
+ this.ensureSpace(marginTopMm + heightMm + marginBottomMm);
601
+ }
602
+
603
+ const drawY = payload.y !== undefined
604
+ ? Number(payload.y) + marginTopMm
605
+ : this.cursorY + marginTopMm;
606
+
607
+ this.doc.addImage(
608
+ payload.data,
609
+ payload.format || "PNG",
610
+ x,
611
+ drawY,
612
+ widthMm,
613
+ heightMm
614
+ );
615
+
616
+ const endY = drawY + heightMm;
617
+ this.lastDrawnBounds = {
618
+ topY: drawY,
619
+ bottomY: endY,
620
+ leftX: x,
621
+ rightX: x + widthMm,
622
+ };
623
+
624
+ if (advance) {
625
+ this.cursorY = endY + marginBottomMm;
626
+ }
627
+
628
+ return { y: drawY, endY };
629
+ }
630
+
631
+ /**
632
+ * Draw visually hidden text (ATS tags, keywords) without moving cursor.
633
+ * @param {object} payload
634
+ * @param {string} payload.text
635
+ * @param {object} payload.style
636
+ * @param {number} [payload.x]
637
+ */
638
+ drawHiddenText(payload) {
639
+ const style = Object.assign({}, payload.style || {}, {
640
+ color: payload.style && payload.style.color
641
+ ? payload.style.color
642
+ : this.backgroundColor,
643
+ });
644
+ this.drawText({
645
+ text: payload.text,
646
+ style,
647
+ x: payload.x !== undefined ? payload.x : this.marginLeftMm,
648
+ y: this.cursorY,
649
+ wrap: true,
650
+ advance: false,
651
+ });
652
+ }
653
+
654
+ /**
655
+ * Split text to size using the provided style for accurate width measurement.
656
+ * This avoids wrap drift when previous operations changed font/size.
657
+ * @param {string} text
658
+ * @param {number} maxWidth
659
+ * @param {object} style
660
+ * @returns {string[]}
661
+ */
662
+ measureWrappedLines(text, maxWidth, style = {}) {
663
+ const previousFont = this.doc.getFont();
664
+ const previousFontSize = this.doc.getFontSize();
665
+
666
+ this.doc.setFont(style.fontFamily, style.fontStyle);
667
+ this.doc.setFontSize(Number(style.fontSize));
668
+ const lines = this.doc.splitTextToSize(String(text), maxWidth);
669
+
670
+ this.doc.setFont(previousFont.fontName, previousFont.fontStyle);
671
+ this.doc.setFontSize(previousFontSize);
672
+ return lines;
673
+ }
674
+
675
+ /**
676
+ * Return document as Buffer.
677
+ * @returns {Buffer}
678
+ */
679
+ toBuffer() {
680
+ return Buffer.from(this.doc.output("arraybuffer"));
681
+ }
682
+
683
+ /**
684
+ * Save rendered PDF to disk.
685
+ * @param {string} outputPath
686
+ */
687
+ saveToFile(outputPath) {
688
+ fs.writeFileSync(outputPath, this.toBuffer());
689
+ }
690
+
691
+ _resolveColor(color, fallback) {
692
+ if (Array.isArray(color) && color.length === 3) {
693
+ return color;
694
+ }
695
+ return fallback;
696
+ }
697
+
698
+ _resolveTextAnchorX(x, maxWidth, align) {
699
+ if (align === "center") {
700
+ return x + (maxWidth / 2);
701
+ }
702
+ if (align === "right") {
703
+ return x + maxWidth;
704
+ }
705
+ return x;
706
+ }
707
+
708
+ _drawTextLeftBorder(input) {
709
+ const style = input.style || {};
710
+ const border = style.leftBorder;
711
+ if (!border || typeof border !== "object") {
712
+ return;
713
+ }
714
+
715
+ const widthMm = border.widthMm !== undefined ? Number(border.widthMm) || 0 : 0;
716
+ if (widthMm <= 0) {
717
+ return;
718
+ }
719
+
720
+ const gapMm = border.gapMm !== undefined ? Number(border.gapMm) || 0 : 0;
721
+ const heightMm = border.heightMm !== undefined
722
+ ? Number(border.heightMm) || 0
723
+ : input.blockHeight;
724
+ if (heightMm <= 0) {
725
+ return;
726
+ }
727
+
728
+ const topOffsetMm = border.topOffsetMm !== undefined
729
+ ? Number(border.topOffsetMm) || 0
730
+ : 0;
731
+ const color = this._resolveColor(border.color);
732
+
733
+ this.doc.setFillColor(...color);
734
+ this.doc.rect(
735
+ input.x - gapMm - widthMm,
736
+ input.y + topOffsetMm,
737
+ widthMm,
738
+ heightMm,
739
+ "F"
740
+ );
741
+ }
742
+
743
+ _drawTextContainer(input) {
744
+ const style = input.style || {};
745
+ const hasBackground = Array.isArray(style.backgroundColor) && style.backgroundColor.length === 3;
746
+ const borderWidth = style.borderWidthMm !== undefined
747
+ ? (Number(style.borderWidthMm) || 0)
748
+ : (style.borderWidth !== undefined ? (Number(style.borderWidth) || 0) : 0);
749
+ const hasBorder = borderWidth > 0;
750
+
751
+ if (!hasBackground && !hasBorder) {
752
+ return;
753
+ }
754
+
755
+ const x = input.x;
756
+ const y = input.y;
757
+ const width = input.width;
758
+ const height = input.height;
759
+ if (width <= 0 || height <= 0) {
760
+ return;
761
+ }
762
+
763
+ const radiusMm = style.borderRadiusMm !== undefined
764
+ ? (Number(style.borderRadiusMm) || 0)
765
+ : 0;
766
+ const fillColor = this._resolveColor(style.backgroundColor);
767
+ const borderColor = this._resolveColor(style.borderColor);
768
+ let mode = "S";
769
+ if (hasBackground && hasBorder) {
770
+ mode = "FD";
771
+ } else if (hasBackground) {
772
+ mode = "F";
773
+ }
774
+
775
+ if (hasBackground) {
776
+ this.doc.setFillColor(...fillColor);
777
+ }
778
+ if (hasBorder) {
779
+ this.doc.setDrawColor(...borderColor);
780
+ this.doc.setLineWidth(borderWidth);
781
+ }
782
+
783
+ if (radiusMm > 0 && typeof this.doc.roundedRect === "function") {
784
+ this.doc.roundedRect(x, y, width, height, radiusMm, radiusMm, mode);
785
+ return;
786
+ }
787
+ this.doc.rect(x, y, width, height, mode);
788
+ }
789
+
790
+ _drawTextLines(linesInput, options) {
791
+ const lines = Array.isArray(linesInput) ? linesInput : [String(linesInput)];
792
+ const x = options.x;
793
+ const y = options.y;
794
+ const maxWidth = options.maxWidth;
795
+ const align = options.align || "left";
796
+ const lineHeightMm = options.lineHeightMm || 0;
797
+
798
+ // jsPDF justify stretches all lines in array mode (including paragraph-last lines).
799
+ // Draw line-by-line so only non-final lines are justified and width is explicit.
800
+ if (align === "justify") {
801
+ if (lines.length === 1) {
802
+ this.doc.text(lines[0], x, y, { align: "left" });
803
+ return;
804
+ }
805
+ lines.forEach((line, i) => {
806
+ const lineY = y + (i * lineHeightMm);
807
+ const isLast = i === lines.length - 1;
808
+ if (isLast) {
809
+ this.doc.text(line, x, lineY, { align: "left" });
810
+ return;
811
+ }
812
+ this.doc.text(line, x, lineY, { align: "justify", maxWidth });
813
+ });
814
+ return;
815
+ }
816
+
817
+ this.doc.text(lines, this._resolveTextAnchorX(x, maxWidth, align), y, { align });
818
+ }
819
+
820
+ _getBaselineOffsetMm(fontSizePt) {
821
+ return ptToMm(Number(fontSizePt) * 0.75);
822
+ }
823
+
824
+ _buildDefaultRenderState(page = {}) {
825
+ const text = page.defaultText || {};
826
+ const stroke = page.defaultStroke || {};
827
+ return {
828
+ text: {
829
+ fontFamily: text.fontFamily,
830
+ fontStyle: text.fontStyle,
831
+ fontSize: Number(text.fontSize),
832
+ color: this._resolveColor(text.color),
833
+ lineHeight: Number(text.lineHeight),
834
+ },
835
+ stroke: {
836
+ color: this._resolveColor(stroke.color),
837
+ lineWidth: Number(stroke.lineWidth),
838
+ lineCap: stroke.lineCap,
839
+ lineJoin: stroke.lineJoin,
840
+ },
841
+ fillColor: this._resolveColor(page.defaultFillColor),
842
+ };
843
+ }
844
+ }
845
+
846
+ module.exports = {
847
+ PDFCore,
848
+ getMarginTopMm,
849
+ getMarginBottomMm,
850
+ getStyleMarginsMm,
851
+ getTextPaddingMm,
852
+ applyTextTransform,
853
+ };