@polotno/pdf-export 0.1.18 → 0.1.20

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/lib/text.js CHANGED
@@ -1,184 +1,577 @@
1
- const { parseColor, srcToBuffer } = require('./utils');
2
- const getUrls = require('get-urls').default;
3
- const fetch = require('node-fetch').default;
4
-
5
- async function getGoogleFontPath(fontFamily, fontWeight = 'normal') {
6
- const weight = fontWeight === 'bold' ? '700' : '400';
7
- const url = `https://fonts.googleapis.com/css?family=${fontFamily}:${weight}`;
8
- const req = await fetch(url);
9
- if (!req.ok) {
10
- if (weight !== '400') {
11
- return getGoogleFontPath(fontFamily, 'normal');
12
- }
13
- throw new Error(`Failed to fetch Google font: ${fontFamily}`);
14
- }
15
- const text = await req.text();
16
- const urls = getUrls(text);
17
- return urls.values().next().value;
18
- }
19
-
20
- async function loadFontIfNeeded(doc, element, fonts) {
21
- // check if universal font is already defined
22
- if (fonts[element.fontFamily]) {
23
- doc.font(element.fontFamily);
24
- return element.fontFamily;
25
- }
26
- const fontKey = `${element.fontFamily}-${element.fontWeight || 'normal'}`;
27
- if (!fonts[fontKey]) {
28
- const src = await getGoogleFontPath(element.fontFamily, element.fontWeight);
29
- doc.registerFont(fontKey, await srcToBuffer(src));
30
- fonts[fontKey] = true;
31
- }
32
- doc.font(fontKey);
33
- return fontKey;
34
- }
35
-
36
- function renderText(doc, element, attrs = {}) {
37
- doc.fontSize(element.fontSize);
38
- doc.fillColor(parseColor(element.fill).hex, element.opacity);
39
-
40
- // Handle stroked text differently for PDF/X-1a compatibility
41
- const hasStroke = element.strokeWidth > 0;
42
- const isPDFX1a = attrs.pdfx1a;
43
-
44
- if (hasStroke && !isPDFX1a) {
45
- // Standard PDF: use PDFKit's built-in stroke support
46
- doc.lineWidth(element.strokeWidth / 2);
47
- doc.strokeColor(parseColor(element.stroke).hex);
48
- }
49
-
50
- const props = {
51
- align: element.align,
52
- fill: element.fill,
53
- baseline: 'top',
54
- lineGap: 1,
55
- width: element.width,
56
- underline: element.textDecoration.indexOf('underline') >= 0,
57
- characterSpacing: element.letterSpacing
58
- ? element.letterSpacing * element.fontSize
59
- : 0,
60
- lineBreak: false,
61
- stroke: hasStroke && !isPDFX1a, // Only use stroke for non-PDF/X-1a
62
- };
63
-
64
- const currentLineHeight = doc.heightOfString('A', props);
65
- const lineHeight = element.lineHeight * element.fontSize;
66
-
67
- const fontBoundingBoxAscent = (doc._font.ascender / 1000) * element.fontSize;
68
- const fontBoundingBoxDescent =
69
- (doc._font.descender / 1000) * element.fontSize;
70
- const translateY = (fontBoundingBoxAscent - fontBoundingBoxDescent) / 2;
71
-
72
- const diff = currentLineHeight - lineHeight;
73
- props.lineGap = props.lineGap - diff;
74
-
75
- let yOffset = 0;
76
- if ((attrs.textVerticalResizeEnabled || true) && element.verticalAlign) {
77
- const textHeight = doc.heightOfString(element.text, props);
1
+ import { parseColor, srcToBuffer } from './utils.js';
2
+ import getUrls from 'get-urls';
3
+ import fetch from 'node-fetch';
4
+ import { stripHtml } from "string-strip-html";
5
+ /**
6
+ * Check if text contains HTML tags
7
+ */
8
+ function containsHTML(text) {
9
+ const htmlTagRegex = /<\/?(?:strong|b|em|i|u|span)[^>]*>/i;
10
+ return htmlTagRegex.test(text);
11
+ }
12
+ /**
13
+ * Parse HTML text into styled segments
14
+ */
15
+ function parseHTMLToSegments(html, baseElement) {
16
+ const segments = [];
17
+ const tagStack = [];
18
+ // Regex to match tags and text content
19
+ const regex = /<(\/?)(strong|b|em|i|u|span)([^>]*)>|([^<]+)/gi;
20
+ let match;
21
+ while ((match = regex.exec(html)) !== null) {
22
+ if (match[4]) {
23
+ // Text content
24
+ const text = match[4];
25
+ // Calculate current styles from tag stack
26
+ let bold = false;
27
+ let italic = false;
28
+ let underline = false;
29
+ let color = undefined;
30
+ for (const tag of tagStack) {
31
+ if (tag.tag === 'strong' || tag.tag === 'b')
32
+ bold = true;
33
+ if (tag.tag === 'em' || tag.tag === 'i')
34
+ italic = true;
35
+ if (tag.tag === 'u')
36
+ underline = true;
37
+ if (tag.color)
38
+ color = tag.color;
39
+ }
40
+ segments.push({
41
+ text,
42
+ bold,
43
+ italic,
44
+ underline,
45
+ color
46
+ });
47
+ }
48
+ else {
49
+ // Tag
50
+ const isClosing = match[1] === '/';
51
+ const tagName = match[2].toLowerCase();
52
+ const attributes = match[3];
53
+ if (isClosing) {
54
+ // Remove from stack
55
+ const index = tagStack.findIndex(t => t.tag === tagName);
56
+ if (index !== -1) {
57
+ tagStack.splice(index, 1);
58
+ }
59
+ }
60
+ else {
61
+ // Add to stack
62
+ const tagData = { tag: tagName };
63
+ // Parse color from span style attribute
64
+ if (attributes) {
65
+ const colorMatch = /style=["'](?:[^"']*)?color:\s*([^;"']+)/i.exec(attributes);
66
+ if (colorMatch) {
67
+ tagData.color = colorMatch[1].trim();
68
+ }
69
+ }
70
+ tagStack.push(tagData);
71
+ }
72
+ }
73
+ }
74
+ return segments;
75
+ }
76
+ /**
77
+ * Get font weight string based on bold/italic state
78
+ */
79
+ function getFontWeight(bold, italic, baseFontWeight) {
80
+ if (bold) {
81
+ return 'bold';
82
+ }
83
+ return baseFontWeight || 'normal';
84
+ }
85
+ /**
86
+ * Get font key for caching
87
+ */
88
+ function getFontKey(fontFamily, bold, italic, baseFontWeight) {
89
+ const weight = getFontWeight(bold, italic, baseFontWeight);
90
+ const style = italic ? 'italic' : 'normal';
91
+ return `${fontFamily}-${weight}-${style}`;
92
+ }
93
+ export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', italic = false) {
94
+ const weight = fontWeight === 'bold' ? '700' : '400';
95
+ const italicParam = italic ? 'italic' : '';
96
+ const url = `https://fonts.googleapis.com/css?family=${fontFamily}:${italicParam}${weight}`;
97
+ const req = await fetch(url);
98
+ if (!req.ok) {
99
+ if (weight !== '400' || italic) {
100
+ // Fallback: try normal weight without italic
101
+ return getGoogleFontPath(fontFamily, 'normal', false);
102
+ }
103
+ throw new Error(`Failed to fetch Google font: ${fontFamily}`);
104
+ }
105
+ const text = await req.text();
106
+ const urls = getUrls(text);
107
+ return urls.values().next().value;
108
+ }
109
+ export async function loadFontIfNeeded(doc, element, fonts) {
110
+ // check if universal font is already defined
111
+ if (fonts[element.fontFamily]) {
112
+ doc.font(element.fontFamily);
113
+ return element.fontFamily;
114
+ }
115
+ const isItalic = element.fontStyle?.indexOf('italic') >= 0;
116
+ const isBold = element.fontWeight == 'bold';
117
+ const fontKey = getFontKey(element.fontFamily, isBold, isItalic, element.fontWeight);
118
+ if (!fonts[fontKey]) {
119
+ const src = await getGoogleFontPath(element.fontFamily, element.fontWeight, isItalic);
120
+ doc.registerFont(fontKey, await srcToBuffer(src));
121
+ fonts[fontKey] = true;
122
+ }
123
+ doc.font(fontKey);
124
+ return fontKey;
125
+ }
126
+ /**
127
+ * Load font for a rich text segment
128
+ */
129
+ async function loadFontForSegment(doc, segment, element, fonts) {
130
+ const fontFamily = element.fontFamily;
131
+ const bold = segment.bold || element.fontWeight == 'bold' || false;
132
+ const italic = segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false;
133
+ // Check if universal font is already defined
134
+ if (fonts[fontFamily]) {
135
+ doc.font(fontFamily);
136
+ return fontFamily;
137
+ }
138
+ const fontKey = getFontKey(fontFamily, bold, italic, element.fontWeight);
139
+ if (!fonts[fontKey]) {
140
+ const weight = getFontWeight(bold, italic, element.fontWeight);
141
+ const src = await getGoogleFontPath(fontFamily, weight, italic);
142
+ doc.registerFont(fontKey, await srcToBuffer(src));
143
+ fonts[fontKey] = true;
144
+ }
145
+ doc.font(fontKey);
146
+ return fontKey;
147
+ }
148
+ /**
149
+ * Parse HTML into tokens (text and tags)
150
+ */
151
+ function tokenizeHTML(html) {
152
+ const tokens = [];
153
+ const regex = /<(\/?)(strong|b|em|i|u|span)([^>]*)>|([^<]+)/gi;
154
+ let match;
155
+ while ((match = regex.exec(html)) !== null) {
156
+ if (match[4]) {
157
+ // Text content
158
+ tokens.push({
159
+ type: 'text',
160
+ content: match[4]
161
+ });
162
+ }
163
+ else {
164
+ // Tag
165
+ const isClosing = match[1] === '/';
166
+ const tagName = match[2].toLowerCase();
167
+ tokens.push({
168
+ type: 'tag',
169
+ content: match[0],
170
+ tagName: tagName,
171
+ isClosing: isClosing
172
+ });
173
+ }
174
+ }
175
+ return tokens;
176
+ }
177
+ /**
178
+ * Reconstruct HTML from tokens while maintaining proper tag nesting across line breaks
179
+ * @param tokens - Array of parsed HTML tokens
180
+ * @param openTags - Tags that were opened in previous lines and should be carried forward
181
+ * @returns Reconstructed HTML string and the updated list of open tags
182
+ */
183
+ function tokensToHTML(tokens, openTags) {
184
+ let html = '';
185
+ const tagStack = [...openTags]; // Clone the open tags
186
+ // Prepend any open tags
187
+ for (const tag of openTags) {
188
+ html += tag.fullTag;
189
+ }
190
+ // Process tokens
191
+ for (const token of tokens) {
192
+ if (token.type === 'text') {
193
+ html += token.content;
194
+ }
195
+ else if (token.type === 'tag') {
196
+ html += token.content;
197
+ if (token.isClosing) {
198
+ // Remove from stack
199
+ const idx = tagStack.findIndex(t => t.name === token.tagName);
200
+ if (idx !== -1) {
201
+ tagStack.splice(idx, 1);
202
+ }
203
+ }
204
+ else {
205
+ // Add to stack
206
+ tagStack.push({
207
+ name: token.tagName,
208
+ fullTag: token.content
209
+ });
210
+ }
211
+ }
212
+ }
213
+ // Close any remaining open tags for this line
214
+ for (let i = tagStack.length - 1; i >= 0; i--) {
215
+ html += `</${tagStack[i].name}>`;
216
+ }
217
+ return { html, openTags: tagStack };
218
+ }
219
+ /**
220
+ * Split text into lines that fit within the element width while preserving HTML formatting
221
+ * Handles word wrapping and ensures HTML tags are properly opened/closed across line breaks
222
+ */
223
+ function splitTextIntoLines(doc, element, props) {
224
+ const lines = [];
225
+ const paragraphs = element.text.split('\n');
226
+ for (const paragraph of paragraphs) {
227
+ // Tokenize the paragraph
228
+ const tokens = tokenizeHTML(paragraph);
229
+ // Extract plain text for width calculation
230
+ const plainText = tokens
231
+ .filter(t => t.type === 'text')
232
+ .map(t => t.content)
233
+ .join('');
234
+ const paragraphWidth = doc.widthOfString(plainText, props);
235
+ // Justify alignment using native pdfkit instruments
236
+ if (paragraphWidth <= element.width || element.align === 'justify') {
237
+ // Paragraph fits on one line
238
+ lines.push({ text: paragraph, width: paragraphWidth });
239
+ }
240
+ else {
241
+ // Need to split paragraph into multiple lines
242
+ let currentLine = '';
243
+ let currentWidth = 0;
244
+ let currentTokens = [];
245
+ let openTags = [];
246
+ for (const token of tokens) {
247
+ if (token.type === 'tag') {
248
+ currentTokens.push(token);
249
+ continue;
250
+ }
251
+ // Text token - split by words
252
+ const textWords = token.content.split(' ');
253
+ for (let i = 0; i < textWords.length; i++) {
254
+ const word = textWords[i];
255
+ const testLine = currentLine ? `${currentLine}${i > 0 ? ' ' : ''}${word}` : word;
256
+ const testWidth = doc.widthOfString(testLine, props);
257
+ if (testWidth <= element.width) {
258
+ currentLine = testLine;
259
+ currentWidth = testWidth;
260
+ // Add text token (with space if not first word in token)
261
+ if (i > 0 || currentTokens.length > 0) {
262
+ let content = (i > 0 ? ' ' : '') + word;
263
+ currentTokens.push({
264
+ type: 'text',
265
+ content: content
266
+ });
267
+ }
268
+ else {
269
+ currentTokens.push({
270
+ type: 'text',
271
+ content: word
272
+ });
273
+ }
274
+ }
275
+ else {
276
+ // Line is too long, save current line and start new one
277
+ if (currentLine) {
278
+ const result = tokensToHTML(currentTokens, openTags);
279
+ lines.push({ text: result.html, width: currentWidth });
280
+ openTags = result.openTags;
281
+ currentTokens = [];
282
+ }
283
+ currentLine = word;
284
+ currentWidth = doc.widthOfString(word, props);
285
+ currentTokens.push({
286
+ type: 'text',
287
+ content: word
288
+ });
289
+ }
290
+ }
291
+ }
292
+ // Add the last line
293
+ if (currentLine) {
294
+ const result = tokensToHTML(currentTokens, openTags);
295
+ lines.push({ text: result.html, width: currentWidth });
296
+ }
297
+ }
298
+ }
299
+ return lines;
300
+ }
301
+ /**
302
+ * Calculate horizontal offset for a line of text based on alignment
303
+ * @param element - Text element with alignment settings
304
+ * @param lineWidth - Width of the current line
305
+ * @returns X offset for positioning the line
306
+ */
307
+ function calculateLineXOffset(element, lineWidth) {
308
+ const align = element.align;
309
+ if (align === 'right') {
310
+ return element.width - lineWidth;
311
+ }
312
+ else if (align === 'center') {
313
+ return (element.width - lineWidth) / 2;
314
+ }
315
+ else if (align === 'justify') {
316
+ // Justify alignment is handled by PDFKit's align property
317
+ return 0;
318
+ }
319
+ // Default: left alignment
320
+ return 0;
321
+ }
322
+ /**
323
+ * Calculate text rendering metrics including line height and baseline offset
324
+ */
325
+ function calculateTextMetrics(doc, element) {
326
+ const textOptions = {
327
+ align: element.align === 'justify' ? 'justify' : 'left',
328
+ baseline: 'alphabetic',
329
+ lineGap: 1,
330
+ width: element.width,
331
+ underline: element.textDecoration.indexOf('underline') >= 0,
332
+ characterSpacing: element.letterSpacing
333
+ ? element.letterSpacing * element.fontSize
334
+ : 0,
335
+ lineBreak: false,
336
+ stroke: false,
337
+ fill: false
338
+ };
339
+ const currentLineHeight = doc.heightOfString('A', textOptions);
340
+ const lineHeight = element.lineHeight * element.fontSize;
341
+ const fontBoundingBoxAscent = (doc._font.ascender / 1000) * element.fontSize;
342
+ const fontBoundingBoxDescent = (doc._font.descender / 1000) * element.fontSize;
343
+ // Calculate baseline offset based on font metrics (similar to Konva rendering)
344
+ const baselineOffset = (fontBoundingBoxAscent - Math.abs(fontBoundingBoxDescent)) / 2 + lineHeight / 2;
345
+ // Adjust line gap to match desired line height
346
+ const lineHeightDiff = currentLineHeight - lineHeight;
347
+ textOptions.lineGap = textOptions.lineGap - lineHeightDiff;
348
+ const textLines = splitTextIntoLines(doc, element, textOptions);
349
+ return {
350
+ textOptions,
351
+ lineHeightPx: lineHeight,
352
+ baselineOffset,
353
+ textLines
354
+ };
355
+ }
356
+ /**
357
+ * Calculate vertical alignment offset for text
358
+ */
359
+ function calculateVerticalAlignment(doc, element, textOptions) {
360
+ if (!element.verticalAlign || element.verticalAlign === 'top') {
361
+ return 0;
362
+ }
363
+ const strippedContent = stripHtml(element.text).result;
364
+ const textHeight = doc.heightOfString(strippedContent, textOptions);
78
365
  if (element.verticalAlign === 'middle') {
79
- yOffset = (element.height - textHeight) / 2;
80
- } else if (element.verticalAlign === 'bottom') {
81
- yOffset = element.height - textHeight;
82
- }
83
- }
84
-
85
- for (var size = element.fontSize; size > 0; size -= 1) {
86
- doc.fontSize(size);
87
- const height = doc.heightOfString(element.text, {
88
- ...props,
89
- });
90
- if (height <= element.height) {
91
- break;
92
- }
93
- }
94
-
95
- const halfLineHeight = ((element.lineHeight - 1) / 2) * element.fontSize;
96
-
97
- if (element.backgroundEnabled) {
98
- const backPadding =
99
- element.backgroundPadding * (element.fontSize * element.lineHeight);
100
- const cornerRadius =
101
- element.backgroundCornerRadius *
102
- (element.fontSize * element.lineHeight * 0.5);
103
-
104
- const textWidth = doc.widthOfString(element.text, {
105
- ...props,
106
- width: element.width,
366
+ return (element.height - textHeight) / 2;
367
+ }
368
+ else if (element.verticalAlign === 'bottom') {
369
+ return element.height - textHeight;
370
+ }
371
+ return 0;
372
+ }
373
+ /**
374
+ * Reduce font size to fit text within element height
375
+ */
376
+ function fitTextToHeight(doc, element, textOptions) {
377
+ const strippedContent = stripHtml(element.text).result;
378
+ for (let size = element.fontSize; size > 0; size -= 1) {
379
+ doc.fontSize(size);
380
+ const height = doc.heightOfString(strippedContent, textOptions);
381
+ if (height <= element.height) {
382
+ break;
383
+ }
384
+ }
385
+ }
386
+ /**
387
+ * Render text background box
388
+ */
389
+ function renderTextBackground(doc, element, verticalAlignmentOffset, textOptions) {
390
+ if (!element.backgroundEnabled) {
391
+ return;
392
+ }
393
+ const strippedContent = stripHtml(element.text).result;
394
+ const padding = element.backgroundPadding * (element.fontSize * element.lineHeight);
395
+ const cornerRadius = element.backgroundCornerRadius * (element.fontSize * element.lineHeight * 0.5);
396
+ const textWidth = doc.widthOfString(strippedContent, {
397
+ ...textOptions,
398
+ width: element.width,
107
399
  });
108
- const textHeight = doc.heightOfString(element.text, {
109
- ...props,
110
- width: element.width,
400
+ const textHeight = doc.heightOfString(strippedContent, {
401
+ ...textOptions,
402
+ width: element.width,
111
403
  });
112
-
113
- let bgX = -backPadding / 2;
114
- let bgY = -backPadding / 2;
115
- let bgWidth = textWidth + backPadding;
116
- let bgHeight = textHeight + backPadding;
117
-
404
+ let bgX = -padding / 2;
405
+ let bgY = verticalAlignmentOffset - padding / 2;
406
+ const bgWidth = textWidth + padding;
407
+ const bgHeight = textHeight + padding;
408
+ // Adjust horizontal position based on text alignment
118
409
  if (element.align === 'center') {
119
- bgX = (element.width - textWidth) / 2 - backPadding / 2;
120
- } else if (element.align === 'right') {
121
- bgX = element.width - textWidth - backPadding / 2;
410
+ bgX = (element.width - textWidth) / 2 - padding / 2;
122
411
  }
123
-
124
- if (element.verticalAlign === 'middle') {
125
- bgY = (element.height - textHeight) / 2 - backPadding / 2;
126
- } else if (element.verticalAlign === 'bottom') {
127
- bgY = element.height - textHeight - backPadding / 2;
412
+ else if (element.align === 'right') {
413
+ bgX = element.width - textWidth - padding / 2;
128
414
  }
129
-
130
415
  doc.roundedRect(bgX, bgY, bgWidth, bgHeight, cornerRadius);
131
416
  doc.fillColor(parseColor(element.backgroundColor).hex);
132
417
  doc.fill();
133
418
  doc.fillColor(parseColor(element.fill).hex, element.opacity);
134
- }
135
-
136
- // Render text with PDF/X-1a compatible stroke simulation
137
- if (hasStroke && isPDFX1a) {
138
- // For PDF/X-1a: simulate stroke by drawing text multiple times
419
+ }
420
+ /**
421
+ * Render text stroke using PDF/X-1a compatible method (multiple offset fills)
422
+ */
423
+ function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
139
424
  const strokeColor = parseColor(element.stroke).hex;
140
425
  const strokeWidth = element.strokeWidth;
141
-
142
- // Draw stroke by rendering text multiple times with offsets
426
+ const isJustify = element.align === 'justify';
427
+ // Generate stroke offsets in a circle pattern
143
428
  const offsets = [];
144
429
  for (let angle = 0; angle < 360; angle += 45) {
145
- const radian = (angle * Math.PI) / 180;
146
- offsets.push({
147
- x: Math.cos(radian) * strokeWidth,
148
- y: Math.sin(radian) * strokeWidth,
149
- });
150
- }
151
-
152
- // Draw stroke layers
430
+ const radian = (angle * Math.PI) / 180;
431
+ offsets.push({
432
+ x: Math.cos(radian) * strokeWidth,
433
+ y: Math.sin(radian) * strokeWidth,
434
+ });
435
+ }
436
+ // Render stroke layer by drawing text multiple times with offsets
153
437
  doc.save();
154
438
  doc.fillColor(strokeColor, element.opacity);
155
- for (const offset of offsets) {
156
- doc.text(element.text, offset.x, yOffset + halfLineHeight + offset.y, {
157
- ...props,
158
- stroke: false, // Force no stroke for compatibility
159
- height: element.height + element.fontSize,
160
- });
439
+ for (let i = 0; i < textLines.length; i++) {
440
+ const line = textLines[i];
441
+ const lineXOffset = calculateLineXOffset(element, line.width);
442
+ const lineYOffset = yOffset + (i * lineHeightPx);
443
+ for (const offset of offsets) {
444
+ doc.text(line.text, lineXOffset + offset.x, lineYOffset + offset.y, {
445
+ ...textOptions,
446
+ width: isJustify ? element.width : undefined,
447
+ stroke: false,
448
+ });
449
+ }
161
450
  }
162
451
  doc.restore();
163
-
164
- // Draw fill text on top
452
+ // Render fill layer on top
165
453
  doc.fillColor(parseColor(element.fill).hex, element.opacity);
166
- doc.text(element.text, 0, yOffset + halfLineHeight, {
167
- ...props,
168
- stroke: false, // Force no stroke for compatibility
169
- height: element.height + element.fontSize,
170
- });
171
- } else {
172
- // Standard rendering
173
- doc.text(element.text, 0, yOffset + halfLineHeight, {
174
- ...props,
175
- height: element.height + element.fontSize,
176
- });
177
- }
178
- }
179
-
180
- module.exports = {
181
- getGoogleFontPath,
182
- renderText,
183
- loadFontIfNeeded,
184
- };
454
+ for (let i = 0; i < textLines.length; i++) {
455
+ const line = textLines[i];
456
+ const lineXOffset = calculateLineXOffset(element, line.width);
457
+ const lineYOffset = yOffset + (i * lineHeightPx);
458
+ doc.text(line.text, lineXOffset, lineYOffset, {
459
+ ...textOptions,
460
+ width: isJustify ? element.width : undefined,
461
+ stroke: false,
462
+ });
463
+ }
464
+ }
465
+ /**
466
+ * Render text stroke using standard PDF stroke
467
+ */
468
+ function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
469
+ const isJustify = element.align === 'justify';
470
+ doc.save();
471
+ doc.lineWidth(element.strokeWidth);
472
+ doc.lineCap('round').lineJoin('round');
473
+ doc.strokeColor(parseColor(element.stroke).hex, element.opacity);
474
+ let cumulativeYOffset = 0;
475
+ for (let i = 0; i < textLines.length; i++) {
476
+ const line = textLines[i];
477
+ const lineXOffset = calculateLineXOffset(element, line.width);
478
+ const lineYOffset = yOffset + cumulativeYOffset;
479
+ const strippedLineText = stripHtml(line.text).result;
480
+ const heightOfLine = line.text === ''
481
+ ? lineHeightPx
482
+ : doc.heightOfString(strippedLineText, textOptions);
483
+ cumulativeYOffset += heightOfLine;
484
+ doc.text(line.text, lineXOffset, lineYOffset, {
485
+ ...textOptions,
486
+ width: isJustify ? element.width : undefined,
487
+ height: heightOfLine,
488
+ stroke: true,
489
+ fill: false
490
+ });
491
+ }
492
+ doc.restore();
493
+ }
494
+ /**
495
+ * Render text fill with rich text support (HTML segments)
496
+ */
497
+ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
498
+ if (!element.fill) {
499
+ return;
500
+ }
501
+ const baseParsedColor = parseColor(element.fill);
502
+ const baseOpacity = Math.min(baseParsedColor.rgba[3] ?? 1, element.opacity, 1);
503
+ doc.fillColor(baseParsedColor.hex, baseOpacity);
504
+ const isJustify = element.align === 'justify';
505
+ let cumulativeYOffset = 0;
506
+ for (let i = 0; i < textLines.length; i++) {
507
+ const line = textLines[i];
508
+ const lineXOffset = calculateLineXOffset(element, line.width);
509
+ const lineYOffset = yOffset + cumulativeYOffset;
510
+ const strippedLineText = stripHtml(line.text).result;
511
+ const heightOfLine = line.text === ''
512
+ ? lineHeightPx
513
+ : doc.heightOfString(strippedLineText, textOptions);
514
+ cumulativeYOffset += heightOfLine;
515
+ // Position cursor at line start
516
+ doc.text('', lineXOffset, lineYOffset, { height: 0, width: 0 });
517
+ // Parse line into styled segments
518
+ const segments = parseHTMLToSegments(line.text, element);
519
+ // Render each segment with its own styling
520
+ for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
521
+ const segment = segments[segmentIndex];
522
+ const isLastSegment = segmentIndex === segments.length - 1;
523
+ // Load appropriate font for this segment
524
+ await loadFontForSegment(doc, segment, element, fonts);
525
+ doc.fontSize(element.fontSize);
526
+ // Apply segment color
527
+ const segmentColor = segment.color
528
+ ? parseColor(segment.color).hex
529
+ : parseColor(element.fill).hex;
530
+ const segmentParsedColor = segment.color
531
+ ? parseColor(segment.color)
532
+ : parseColor(element.fill);
533
+ const segmentOpacity = Math.min(segmentParsedColor.rgba[3] ?? 1, element.opacity, 1);
534
+ doc.fillColor(segmentColor, segmentOpacity);
535
+ // Render segment text
536
+ doc.text(segment.text, {
537
+ ...textOptions,
538
+ width: isJustify ? element.width : undefined,
539
+ height: heightOfLine,
540
+ continued: !isLastSegment,
541
+ underline: segment.underline || textOptions.underline || false,
542
+ lineBreak: !!segment.underline, // Workaround for pdfkit bug
543
+ stroke: false,
544
+ fill: true
545
+ });
546
+ }
547
+ }
548
+ }
549
+ /**
550
+ * Main text rendering function
551
+ */
552
+ export async function renderText(doc, element, fonts, attrs = {}) {
553
+ doc.fontSize(element.fontSize);
554
+ const hasStroke = element.strokeWidth > 0;
555
+ const isPDFX1a = attrs.pdfx1a;
556
+ // Calculate text metrics and line positioning
557
+ const metrics = calculateTextMetrics(doc, element);
558
+ const verticalAlignmentOffset = calculateVerticalAlignment(doc, element, metrics.textOptions);
559
+ // Fit text to element height if needed
560
+ fitTextToHeight(doc, element, metrics.textOptions);
561
+ // Calculate final vertical offset
562
+ const finalYOffset = verticalAlignmentOffset + metrics.baselineOffset;
563
+ // Render background if enabled
564
+ renderTextBackground(doc, element, verticalAlignmentOffset, metrics.textOptions);
565
+ // Render text based on stroke and PDF/X-1a requirements
566
+ if (hasStroke && isPDFX1a) {
567
+ // PDF/X-1a mode: simulate stroke with offset fills
568
+ renderPDFX1aStroke(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
569
+ }
570
+ else {
571
+ // Standard rendering: stroke first, then fill
572
+ if (hasStroke) {
573
+ renderStandardStroke(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
574
+ }
575
+ await renderTextFill(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
576
+ }
577
+ }