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.
- package/LICENSE +201 -0
- package/README.md +409 -0
- package/cli.js +135 -0
- package/core/font-registry.js +47 -0
- package/core/pdf-core.js +853 -0
- package/core/plugin-chart.js +109 -0
- package/core/plugin-registry.js +46 -0
- package/core/render-document.js +931 -0
- package/core/shapes.js +354 -0
- package/core/units.js +39 -0
- package/core/validate.js +151 -0
- package/fonts/crimson-text.js +13 -0
- package/fonts/fira-code.js +9 -0
- package/fonts/ibm-plex-sans.js +13 -0
- package/fonts/inter.js +9 -0
- package/fonts/jetbrains-mono.js +13 -0
- package/fonts/lato.js +13 -0
- package/fonts/libre-baskerville.js +11 -0
- package/fonts/lora.js +13 -0
- package/fonts/merriweather.js +13 -0
- package/fonts/montserrat.js +13 -0
- package/fonts/nunito.js +13 -0
- package/fonts/open-sans.js +13 -0
- package/fonts/oswald.js +9 -0
- package/fonts/playfair-display.js +13 -0
- package/fonts/pt-sans.js +13 -0
- package/fonts/raleway.js +13 -0
- package/fonts/roboto.js +13 -0
- package/fonts/source-code-pro.js +13 -0
- package/fonts/source-serif-4.js +13 -0
- package/fonts/work-sans.js +13 -0
- package/index.js +24 -0
- package/package.json +62 -0
package/core/pdf-core.js
ADDED
|
@@ -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
|
+
};
|