@polotno/pdf-export 0.1.36 → 0.1.38

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,221 +1,13 @@
1
- import { parseColor, srcToBuffer } from './utils.js';
1
+ import { parseColor, srcToBuffer, fetchWithTimeout } from './utils.js';
2
2
  import getUrls from 'get-urls';
3
- import fetch from 'node-fetch';
4
- import { stripHtml } from 'string-strip-html';
5
- import { decode as decodeEntities } from 'html-entities';
6
- /**
7
- * Expand tabs to spaces based on character positions (every 8 characters by default).
8
- * This is a lightweight character-based approximation used for line-breaking calculations.
9
- *
10
- * NOTE: This is NOT used for actual rendering! The rendering functions use visual width
11
- * measurements and manual cursor positioning for accurate tab alignment in proportional fonts.
12
- * This function is only used in splitTextIntoLines() to approximate text widths for wrapping.
13
- *
14
- * @param text - Text containing tabs to expand
15
- * @param tabSize - Size of tab stops in characters (default 8)
16
- * @param startPosition - Starting character position for tab stop calculation (default 0)
17
- * @returns Text with tabs expanded to spaces (character-based approximation)
18
- */
19
- function expandTabsToTabStops(text, tabSize = 8, startPosition = 0) {
20
- if (!text) {
21
- return text;
22
- }
23
- let result = '';
24
- let position = startPosition; // Current character position
25
- for (let i = 0; i < text.length; i++) {
26
- const char = text[i];
27
- if (char === '\t') {
28
- // Calculate how many spaces needed to reach next tab stop
29
- const spacesNeeded = tabSize - (position % tabSize);
30
- result += ' '.repeat(spacesNeeded);
31
- position += spacesNeeded;
32
- }
33
- else if (char === '\n') {
34
- // Reset position on newline (tab stops reset at line start)
35
- result += char;
36
- position = 0;
37
- }
38
- else {
39
- result += char;
40
- position++;
41
- }
42
- }
43
- return result;
44
- }
45
- function isTabDebuggingEnabled() {
46
- return typeof process !== 'undefined' && process.env?.DEBUG_TABS === '1';
47
- }
48
- function getEffectiveTabSize(tabSizeInSpaces) {
49
- if (typeof process === 'undefined') {
50
- return tabSizeInSpaces;
51
- }
52
- const envValue = process.env?.POLOTNO_TAB_SIZE;
53
- if (!envValue) {
54
- return tabSizeInSpaces;
55
- }
56
- const parsed = parseInt(envValue, 10);
57
- return Number.isFinite(parsed) && parsed > 0 ? parsed : tabSizeInSpaces;
58
- }
59
- function formatTabDebugLabel(context) {
60
- if (!context) {
61
- return 'unknown';
62
- }
63
- if (context.elementId) {
64
- return context.elementId;
65
- }
66
- if (context.elementName) {
67
- return context.elementName;
68
- }
69
- return 'unknown';
70
- }
71
- function formatSegmentPreview(text) {
72
- if (!text) {
73
- return '';
74
- }
75
- return text.replace(/\s+/g, ' ').trim().slice(0, 80);
76
- }
77
- function getTabDebugContext(element, segment) {
78
- if (!isTabDebuggingEnabled()) {
79
- return undefined;
80
- }
81
- const elementWithMeta = element;
82
- return {
83
- elementId: elementWithMeta.id,
84
- elementName: elementWithMeta.name,
85
- segmentPreview: segment.text,
86
- };
87
- }
88
- /**
89
- * Expand tabs in text with word spacing adjustment for accurate visual alignment.
90
- * This splits text at tab boundaries and calculates the wordSpacing needed to make
91
- * the replacement spaces render at exactly the right width to reach tab stops.
92
- *
93
- * @param text - Text containing tabs to expand
94
- * @param doc - PDFKit document for measuring text width
95
- * @param textOptions - PDFKit text options (font, size, etc.)
96
- * @param tabSizeInSpaces - Number of spaces per tab stop (default 8)
97
- * @param currentWidth - Current text width in points (default 0)
98
- * @returns Array of segments with expanded text and wordSpacing adjustments
99
- */
100
- function expandTabsWithWordSpacing(text, doc, textOptions, tabSizeInSpaces = 8, currentWidth = 0, debugContext) {
101
- if (!text || !text.includes('\t')) {
102
- // No tabs, return as-is
103
- const width = currentWidth + doc.widthOfString(text, textOptions);
104
- return {
105
- segments: [{ type: 'text', text, wordSpacing: 0 }],
106
- finalWidth: width,
107
- };
108
- }
109
- // Measure the width of one space character (for rendering tab spaces)
110
- const spaceWidth = doc.widthOfString(' ', textOptions);
111
- const effectiveTabSize = getEffectiveTabSize(tabSizeInSpaces);
112
- const tabStopWidth = Math.max(spaceWidth * effectiveTabSize, spaceWidth || 1);
113
- const shouldLog = isTabDebuggingEnabled() && !!debugContext;
114
- const segments = [];
115
- let width = currentWidth;
116
- let currentSegment = '';
117
- for (let i = 0; i < text.length; i++) {
118
- const char = text[i];
119
- if (char === '\t') {
120
- // Flush current segment if any
121
- if (currentSegment) {
122
- const segmentWidth = doc.widthOfString(currentSegment, textOptions);
123
- segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
124
- width += segmentWidth;
125
- currentSegment = '';
126
- }
127
- // Calculate the exact distance to next tab stop
128
- const currentTabPosition = width % tabStopWidth;
129
- const targetWidth = tabStopWidth - currentTabPosition;
130
- const widthBeforeTab = width;
131
- // Use a reasonable number of spaces (at least 1, usually 2-8)
132
- const spacesNeeded = Math.max(1, Math.round(targetWidth / spaceWidth));
133
- const spaces = ' '.repeat(spacesNeeded);
134
- // Calculate the natural width of those spaces
135
- const naturalWidth = doc.widthOfString(spaces, textOptions);
136
- // Calculate wordSpacing adjustment to make spaces fill exact target width
137
- // wordSpacing is added between words, and spaces count as word separators
138
- // For N spaces, we have N-1 word boundaries, but PDFKit applies wordSpacing
139
- // to each space character in the context of word separation
140
- const wordSpacingAdjustment = spacesNeeded > 1
141
- ? (targetWidth - naturalWidth) / (spacesNeeded - 1)
142
- : 0;
143
- if (shouldLog) {
144
- console.log(`[polotno-tabs] element=${formatTabDebugLabel(debugContext)} text="${formatSegmentPreview(debugContext?.segmentPreview)}" startWidth=${widthBeforeTab.toFixed(2)} targetStop=${(widthBeforeTab + targetWidth).toFixed(2)} spaces=${spacesNeeded} naturalWidth=${naturalWidth.toFixed(2)} wordSpacing=${wordSpacingAdjustment.toFixed(4)}`);
145
- }
146
- // Emit a tab instruction so the renderer can manually reposition the cursor.
147
- segments.push({
148
- type: 'tab',
149
- advanceWidth: widthBeforeTab + targetWidth,
150
- });
151
- width += targetWidth;
152
- }
153
- else if (char === '\n') {
154
- // Flush current segment and add newline
155
- if (currentSegment) {
156
- const segmentWidth = doc.widthOfString(currentSegment, textOptions);
157
- segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
158
- width += segmentWidth;
159
- currentSegment = '';
160
- }
161
- segments.push({ type: 'text', text: '\n', wordSpacing: 0 });
162
- width = 0; // Reset width on newline
163
- }
164
- else {
165
- currentSegment += char;
166
- }
167
- }
168
- // Flush remaining segment
169
- if (currentSegment) {
170
- const segmentWidth = doc.widthOfString(currentSegment, textOptions);
171
- segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
172
- width += segmentWidth;
173
- }
174
- return { segments, finalWidth: width };
175
- }
176
- function decodeHtmlEntities(text) {
177
- if (!text) {
178
- return text;
179
- }
180
- const decoded = decodeEntities(text);
181
- // Don't replace tabs here - we'll handle them with expandTabsToTabStops
182
- return decoded;
183
- }
3
+ import { stripHtml } from "string-strip-html";
184
4
  /**
185
5
  * Check if text contains HTML tags
186
6
  */
187
7
  function containsHTML(text) {
188
- const htmlTagRegex = /<\/?(?:strong|b|em|i|u|span|p|br)[^>]*>/i;
8
+ const htmlTagRegex = /<\/?(?:strong|b|em|i|u|span)[^>]*>/i;
189
9
  return htmlTagRegex.test(text);
190
10
  }
191
- /**
192
- * Normalize rich text HTML by converting block-level line breaks into newline characters
193
- * while preserving inline formatting tags.
194
- */
195
- function normalizeRichText(text) {
196
- if (!text) {
197
- return text;
198
- }
199
- let normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
200
- // Convert explicit HTML break tags into newline characters
201
- normalized = normalized.replace(/<br\s*\/?>/gi, '\n');
202
- // Treat paragraph boundaries as newlines and drop opening tags
203
- normalized = normalized.replace(/<\/p\s*>/gi, '\n');
204
- normalized = normalized.replace(/<p[^>]*>/gi, '');
205
- // Collapse excessive consecutive newlines produced by HTML cleanup
206
- normalized = normalized.replace(/\n{3,}/g, '\n\n');
207
- // Trim stray leading/trailing newlines introduced by paragraph conversion
208
- normalized = normalized.replace(/^\n+/, '').replace(/\n+$/, '');
209
- // NOTE: We do NOT expand tabs here anymore!
210
- // Tab expansion is now handled in the rendering functions (renderTextFill, renderPDFX1aStroke, renderStandardStroke)
211
- // using visual width measurements and wordSpacing adjustments for accurate alignment in proportional fonts.
212
- // The old character-based expandTabsToTabStops() was incorrect for proportional fonts.
213
- // Decode common HTML non-breaking space entities into their unicode counterpart
214
- normalized = normalized.replace(/&(nbsp|#160|#xA0);/gi, '\u00A0');
215
- // Strip zero-width characters that can create missing-glyph boxes in PDF output
216
- normalized = normalized.replace(/[\u200B\u200C\u200D\uFEFF\u2060]/g, '');
217
- return normalized;
218
- }
219
11
  /**
220
12
  * Parse HTML text into styled segments
221
13
  */
@@ -228,7 +20,7 @@ function parseHTMLToSegments(html, baseElement) {
228
20
  while ((match = regex.exec(html)) !== null) {
229
21
  if (match[4]) {
230
22
  // Text content
231
- const text = decodeHtmlEntities(match[4]);
23
+ const text = match[4];
232
24
  // Calculate current styles from tag stack
233
25
  let bold = false;
234
26
  let italic = false;
@@ -249,7 +41,7 @@ function parseHTMLToSegments(html, baseElement) {
249
41
  bold,
250
42
  italic,
251
43
  underline,
252
- color,
44
+ color
253
45
  });
254
46
  }
255
47
  else {
@@ -259,7 +51,7 @@ function parseHTMLToSegments(html, baseElement) {
259
51
  const attributes = match[3];
260
52
  if (isClosing) {
261
53
  // Remove from stack
262
- const index = tagStack.findIndex((t) => t.tag === tagName);
54
+ const index = tagStack.findIndex(t => t.tag === tagName);
263
55
  if (index !== -1) {
264
56
  tagStack.splice(index, 1);
265
57
  }
@@ -301,7 +93,7 @@ export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', itali
301
93
  const weight = fontWeight === 'bold' ? '700' : '400';
302
94
  const italicParam = italic ? 'italic' : '';
303
95
  const url = `https://fonts.googleapis.com/css?family=${fontFamily}:${italicParam}${weight}`;
304
- const req = await fetch(url);
96
+ const req = await fetchWithTimeout(url);
305
97
  if (!req.ok) {
306
98
  if (weight !== '400' || italic) {
307
99
  // Fallback: try normal weight without italic
@@ -313,18 +105,30 @@ export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', itali
313
105
  const urls = getUrls(text);
314
106
  return urls.values().next().value;
315
107
  }
108
+ export async function loadFontIfNeeded(doc, element, fonts) {
109
+ // check if universal font is already defined
110
+ if (fonts[element.fontFamily]) {
111
+ doc.font(element.fontFamily);
112
+ return element.fontFamily;
113
+ }
114
+ const isItalic = element.fontStyle?.indexOf('italic') >= 0;
115
+ const isBold = element.fontWeight == 'bold';
116
+ const fontKey = getFontKey(element.fontFamily, isBold, isItalic, element.fontWeight);
117
+ if (!fonts[fontKey]) {
118
+ const src = await getGoogleFontPath(element.fontFamily, element.fontWeight, isItalic);
119
+ doc.registerFont(fontKey, await srcToBuffer(src));
120
+ fonts[fontKey] = true;
121
+ }
122
+ doc.font(fontKey);
123
+ return fontKey;
124
+ }
316
125
  /**
317
- * Load font for a text element or segment
126
+ * Load font for a rich text segment
318
127
  */
319
128
  async function loadFontForSegment(doc, segment, element, fonts) {
320
129
  const fontFamily = element.fontFamily;
321
- // Determine bold/italic from segment or element
322
- const bold = segment
323
- ? segment.bold || element.fontWeight == 'bold' || false
324
- : element.fontWeight == 'bold';
325
- const italic = segment
326
- ? segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false
327
- : element.fontStyle?.indexOf('italic') >= 0 || false;
130
+ const bold = segment.bold || element.fontWeight == 'bold' || false;
131
+ const italic = segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false;
328
132
  // Check if universal font is already defined
329
133
  if (fonts[fontFamily]) {
330
134
  doc.font(fontFamily);
@@ -340,55 +144,6 @@ async function loadFontForSegment(doc, segment, element, fonts) {
340
144
  doc.font(fontKey);
341
145
  return fontKey;
342
146
  }
343
- // Alias for backward compatibility
344
- export async function loadFontIfNeeded(doc, element, fonts) {
345
- return loadFontForSegment(doc, null, element, fonts);
346
- }
347
- async function buildRenderSegmentsForLine(doc, element, lineText, textOptions, fonts) {
348
- const parsedSegments = parseHTMLToSegments(lineText, element);
349
- let currentLineWidth = 0;
350
- const renderSegments = [];
351
- for (const segment of parsedSegments) {
352
- const fontKey = await loadFontForSegment(doc, segment, element, fonts);
353
- doc.font(fontKey);
354
- doc.fontSize(element.fontSize);
355
- if (segment.text.includes('\t')) {
356
- const expanded = expandTabsWithWordSpacing(segment.text, doc, textOptions, 8, currentLineWidth, getTabDebugContext(element, segment));
357
- currentLineWidth = expanded.finalWidth;
358
- for (const tabSegment of expanded.segments) {
359
- if (tabSegment.type === 'tab') {
360
- renderSegments.push({
361
- segment,
362
- fontKey,
363
- type: 'tab',
364
- advanceWidth: tabSegment.advanceWidth,
365
- });
366
- }
367
- else {
368
- renderSegments.push({
369
- segment,
370
- fontKey,
371
- type: 'text',
372
- text: tabSegment.text,
373
- wordSpacing: tabSegment.wordSpacing,
374
- });
375
- }
376
- }
377
- }
378
- else {
379
- const segmentWidth = doc.widthOfString(segment.text, textOptions);
380
- currentLineWidth += segmentWidth;
381
- renderSegments.push({
382
- segment,
383
- fontKey,
384
- type: 'text',
385
- text: segment.text,
386
- wordSpacing: 0,
387
- });
388
- }
389
- }
390
- return renderSegments;
391
- }
392
147
  /**
393
148
  * Parse HTML into tokens (text and tags)
394
149
  */
@@ -399,11 +154,9 @@ function tokenizeHTML(html) {
399
154
  while ((match = regex.exec(html)) !== null) {
400
155
  if (match[4]) {
401
156
  // Text content
402
- const decodedContent = decodeHtmlEntities(match[4]);
403
157
  tokens.push({
404
158
  type: 'text',
405
- content: match[4],
406
- decodedContent,
159
+ content: match[4]
407
160
  });
408
161
  }
409
162
  else {
@@ -414,257 +167,12 @@ function tokenizeHTML(html) {
414
167
  type: 'tag',
415
168
  content: match[0],
416
169
  tagName: tagName,
417
- isClosing: isClosing,
170
+ isClosing: isClosing
418
171
  });
419
172
  }
420
173
  }
421
174
  return tokens;
422
175
  }
423
- const VOID_ELEMENTS = new Set(['br']);
424
- function parseAttributes(raw) {
425
- const attributes = {};
426
- const attrRegex = /([^\s=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
427
- let attrMatch;
428
- while ((attrMatch = attrRegex.exec(raw)) !== null) {
429
- const name = attrMatch[1].toLowerCase();
430
- const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
431
- attributes[name] = value;
432
- }
433
- return attributes;
434
- }
435
- function parseSimpleHTML(html) {
436
- const root = {
437
- type: 'element',
438
- tagName: 'root',
439
- attributes: {},
440
- children: [],
441
- };
442
- const stack = [root];
443
- const tagRegex = /<\/?([a-zA-Z0-9:-]+)([^>]*)>/g;
444
- let lastIndex = 0;
445
- let match;
446
- while ((match = tagRegex.exec(html)) !== null) {
447
- if (match.index > lastIndex) {
448
- const textContent = html.slice(lastIndex, match.index);
449
- if (textContent) {
450
- stack[stack.length - 1].children.push({
451
- type: 'text',
452
- content: textContent,
453
- });
454
- }
455
- }
456
- const fullMatch = match[0];
457
- const isClosing = fullMatch.startsWith('</');
458
- const tagName = match[1].toLowerCase();
459
- const attrChunk = match[2] || '';
460
- if (isClosing) {
461
- for (let i = stack.length - 1; i >= 0; i--) {
462
- if (stack[i].tagName === tagName) {
463
- stack.length = i;
464
- break;
465
- }
466
- }
467
- }
468
- else {
469
- const attributes = parseAttributes(attrChunk);
470
- const elementNode = {
471
- type: 'element',
472
- tagName,
473
- attributes,
474
- children: [],
475
- };
476
- const parent = stack[stack.length - 1];
477
- parent.children.push(elementNode);
478
- const selfClosing = fullMatch.endsWith('/>') || VOID_ELEMENTS.has(tagName);
479
- if (!selfClosing) {
480
- stack.push(elementNode);
481
- }
482
- }
483
- lastIndex = tagRegex.lastIndex;
484
- }
485
- if (lastIndex < html.length) {
486
- const remaining = html.slice(lastIndex);
487
- if (remaining) {
488
- stack[stack.length - 1].children.push({
489
- type: 'text',
490
- content: remaining,
491
- });
492
- }
493
- }
494
- return root;
495
- }
496
- function serializeAttributes(attributes) {
497
- const entries = Object.entries(attributes);
498
- if (!entries.length) {
499
- return '';
500
- }
501
- return (' ' +
502
- entries
503
- .map(([key, value]) => value === '' ? key : `${key}="${value.replace(/"/g, '&quot;')}"`)
504
- .join(' '));
505
- }
506
- function shouldSkipNode(node) {
507
- if (node.type === 'element' && node.tagName === 'span') {
508
- const classAttr = node.attributes['class'] || node.attributes['className'] || '';
509
- if (/\bql-cursor\b/.test(classAttr)) {
510
- return true;
511
- }
512
- }
513
- return false;
514
- }
515
- function serializeNodes(nodes) {
516
- return nodes
517
- .map((node) => {
518
- if (shouldSkipNode(node)) {
519
- return '';
520
- }
521
- if (node.type === 'text') {
522
- return node.content.replace(/\uFEFF/g, '');
523
- }
524
- const attrs = serializeAttributes(node.attributes);
525
- if (VOID_ELEMENTS.has(node.tagName)) {
526
- return `<${node.tagName}${attrs}>`;
527
- }
528
- return `<${node.tagName}${attrs}>${serializeNodes(node.children)}</${node.tagName}>`;
529
- })
530
- .join('');
531
- }
532
- function getIndentLevelFromAttributes(attributes) {
533
- const classAttr = attributes['class'] || attributes['className'] || '';
534
- const match = classAttr.match(/ql-indent-(\d+)/);
535
- return match ? parseInt(match[1], 10) : 0;
536
- }
537
- function detectIndentLevel(node) {
538
- const directIndent = getIndentLevelFromAttributes(node.attributes);
539
- if (directIndent > 0) {
540
- return directIndent;
541
- }
542
- for (const child of node.children) {
543
- if (child.type === 'element' &&
544
- child.tagName !== 'ul' &&
545
- child.tagName !== 'ol') {
546
- const childIndent = detectIndentLevel(child);
547
- if (childIndent > 0) {
548
- return childIndent;
549
- }
550
- }
551
- }
552
- return 0;
553
- }
554
- function nodesToParagraphs(nodes, baseIndentLevel = 0) {
555
- const paragraphs = [];
556
- let pendingInline = [];
557
- const flushInline = () => {
558
- if (pendingInline.length === 0) {
559
- return;
560
- }
561
- const html = serializeNodes(pendingInline);
562
- paragraphs.push({ html });
563
- pendingInline = [];
564
- };
565
- for (const node of nodes) {
566
- if (shouldSkipNode(node)) {
567
- continue;
568
- }
569
- if (node.type === 'text') {
570
- const newlineRegex = /\n+/g;
571
- let lastIndex = 0;
572
- let match;
573
- while ((match = newlineRegex.exec(node.content)) !== null) {
574
- const chunk = node.content.slice(lastIndex, match.index);
575
- if (chunk.length > 0) {
576
- pendingInline.push({
577
- type: 'text',
578
- content: chunk,
579
- });
580
- }
581
- flushInline();
582
- const extraBreaks = match[0].length - 1;
583
- for (let extra = 0; extra < extraBreaks; extra++) {
584
- paragraphs.push({ html: '' });
585
- }
586
- lastIndex = newlineRegex.lastIndex;
587
- }
588
- const rest = node.content.slice(lastIndex);
589
- if (rest.length > 0) {
590
- pendingInline.push({
591
- type: 'text',
592
- content: rest,
593
- });
594
- }
595
- continue;
596
- }
597
- if (node.tagName === 'p') {
598
- flushInline();
599
- const html = serializeNodes(node.children);
600
- paragraphs.push({
601
- html,
602
- });
603
- continue;
604
- }
605
- if (node.tagName === 'br') {
606
- flushInline();
607
- paragraphs.push({ html: '' });
608
- continue;
609
- }
610
- if (node.tagName === 'ul' || node.tagName === 'ol') {
611
- flushInline();
612
- paragraphs.push(...collectListParagraphs(node, baseIndentLevel));
613
- continue;
614
- }
615
- pendingInline.push(node);
616
- }
617
- flushInline();
618
- return paragraphs;
619
- }
620
- function collectListParagraphs(listNode, baseIndentLevel) {
621
- const paragraphs = [];
622
- const listIndent = baseIndentLevel + getIndentLevelFromAttributes(listNode.attributes);
623
- let counter = 1;
624
- for (const child of listNode.children) {
625
- if (child.type !== 'element' || child.tagName !== 'li') {
626
- continue;
627
- }
628
- const liIndentFromAttribute = getIndentLevelFromAttributes(child.attributes);
629
- const detectedIndent = detectIndentLevel(child);
630
- const indentLevel = liIndentFromAttribute > 0
631
- ? listIndent + liIndentFromAttribute
632
- : listIndent + detectedIndent;
633
- const itemParagraphs = nodesToParagraphs(child.children, indentLevel);
634
- let markerAssigned = false;
635
- for (const paragraph of itemParagraphs) {
636
- if (!paragraph.listMeta) {
637
- paragraph.listMeta = {
638
- type: listNode.tagName,
639
- index: counter,
640
- indentLevel,
641
- displayMarker: !markerAssigned,
642
- };
643
- markerAssigned = true;
644
- }
645
- paragraphs.push(paragraph);
646
- }
647
- if (!markerAssigned) {
648
- paragraphs.push({
649
- html: '',
650
- listMeta: {
651
- type: listNode.tagName,
652
- index: counter,
653
- indentLevel,
654
- displayMarker: true,
655
- },
656
- });
657
- }
658
- if (listNode.tagName === 'ol') {
659
- counter += 1;
660
- }
661
- }
662
- return paragraphs;
663
- }
664
- function parseHtmlToParagraphs(html) {
665
- const root = parseSimpleHTML(html);
666
- return nodesToParagraphs(root.children);
667
- }
668
176
  /**
669
177
  * Reconstruct HTML from tokens while maintaining proper tag nesting across line breaks
670
178
  * @param tokens - Array of parsed HTML tokens
@@ -687,7 +195,7 @@ function tokensToHTML(tokens, openTags) {
687
195
  html += token.content;
688
196
  if (token.isClosing) {
689
197
  // Remove from stack
690
- const idx = tagStack.findIndex((t) => t.name === token.tagName);
198
+ const idx = tagStack.findIndex(t => t.name === token.tagName);
691
199
  if (idx !== -1) {
692
200
  tagStack.splice(idx, 1);
693
201
  }
@@ -696,7 +204,7 @@ function tokensToHTML(tokens, openTags) {
696
204
  // Add to stack
697
205
  tagStack.push({
698
206
  name: token.tagName,
699
- fullTag: token.content,
207
+ fullTag: token.content
700
208
  });
701
209
  }
702
210
  }
@@ -711,84 +219,26 @@ function tokensToHTML(tokens, openTags) {
711
219
  * Split text into lines that fit within the element width while preserving HTML formatting
712
220
  * Handles word wrapping and ensures HTML tags are properly opened/closed across line breaks
713
221
  */
714
- function cloneListMetaForLine(meta, showMarker) {
715
- if (!meta) {
716
- return undefined;
717
- }
718
- return {
719
- ...meta,
720
- showMarker,
721
- };
722
- }
723
- function createListLineMeta(doc, element, props, paragraphMeta) {
724
- const indentPx = paragraphMeta.indentLevel * element.fontSize * 0.5;
725
- const markerText = paragraphMeta.type === 'ul' ? '•' : `${paragraphMeta.index.toString()}.`;
726
- const previousFontSize = doc._fontSize !== undefined
727
- ? doc._fontSize
728
- : element.fontSize;
729
- const markerFontSize = paragraphMeta.type === 'ul' ? element.fontSize * 1.2 : element.fontSize;
730
- doc.fontSize(markerFontSize);
731
- const markerLabelWidth = doc.widthOfString(markerText, {
732
- ...props,
733
- width: undefined,
734
- });
735
- doc.fontSize(previousFontSize);
736
- const markerGapPx = element.fontSize * (paragraphMeta.type === 'ul' ? 1.5 : 0.8);
737
- const markerBoxMinPx = element.fontSize * (paragraphMeta.type === 'ul' ? 2.5 : 2.8);
738
- const markerBoxWidth = Math.max(markerLabelWidth + markerGapPx, markerBoxMinPx);
739
- const textStartPx = indentPx + markerBoxWidth;
740
- return {
741
- type: paragraphMeta.type,
742
- markerText,
743
- indentPx,
744
- markerBoxWidth,
745
- markerFontSize,
746
- markerAlignment: paragraphMeta.type === 'ul' ? 'center' : 'right',
747
- showMarker: paragraphMeta.displayMarker,
748
- textStartPx,
749
- };
750
- }
751
222
  function splitTextIntoLines(doc, element, props) {
752
223
  const lines = [];
753
- const rawText = typeof element.text === 'string'
754
- ? element.text
755
- : String(element.text ?? '');
756
- const paragraphs = parseHtmlToParagraphs(rawText);
757
- if (paragraphs.length === 0) {
758
- paragraphs.push({ html: '' });
759
- }
224
+ const paragraphs = element.text.split('\n');
760
225
  for (const paragraph of paragraphs) {
761
226
  // Tokenize the paragraph
762
- const tokens = tokenizeHTML(paragraph.html);
227
+ const tokens = tokenizeHTML(paragraph);
763
228
  // Extract plain text for width calculation
764
- // Expand tabs to tab stops for accurate width measurement
765
- const plainText = expandTabsToTabStops(tokens
766
- .filter((t) => t.type === 'text')
767
- .map((t) => t.decodedContent ?? decodeHtmlEntities(t.content))
768
- .join(''), 8);
769
- const baseMeta = paragraph.listMeta
770
- ? createListLineMeta(doc, element, props, paragraph.listMeta)
771
- : undefined;
772
- const availableWidthRaw = element.width - (baseMeta ? baseMeta.textStartPx : 0);
773
- const availableWidth = element.align === 'justify'
774
- ? Math.max(availableWidthRaw, 1)
775
- : Math.max(availableWidthRaw, element.width * 0.1, 1);
229
+ const plainText = tokens
230
+ .filter(t => t.type === 'text')
231
+ .map(t => t.content)
232
+ .join('');
776
233
  const paragraphWidth = doc.widthOfString(plainText, props);
777
- let showMarkerForLine = baseMeta?.showMarker ?? false;
778
234
  // Justify alignment using native pdfkit instruments
779
- if (paragraphWidth <= availableWidth || element.align === 'justify') {
235
+ if (paragraphWidth <= element.width || element.align === 'justify') {
780
236
  // Paragraph fits on one line
781
- const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
782
- lines.push({
783
- text: paragraph.html,
784
- width: paragraphWidth,
785
- fullWidth: paragraphWidth + (listMeta ? listMeta.textStartPx : 0),
786
- listMeta,
787
- });
237
+ lines.push({ text: paragraph, width: paragraphWidth });
788
238
  }
789
239
  else {
790
240
  // Need to split paragraph into multiple lines
791
- let currentLineDecoded = '';
241
+ let currentLine = '';
792
242
  let currentWidth = 0;
793
243
  let currentTokens = [];
794
244
  let openTags = [];
@@ -798,215 +248,75 @@ function splitTextIntoLines(doc, element, props) {
798
248
  continue;
799
249
  }
800
250
  // Text token - split by words
801
- // Don't expand tabs here - we need to preserve tabs for proper alignment
802
- const rawWords = token.content.split(' ');
803
- const decodedText = token.decodedContent ?? decodeHtmlEntities(token.content);
804
- const decodedWords = decodedText.split(' ');
805
- for (let i = 0; i < rawWords.length; i++) {
806
- const rawWord = rawWords[i];
807
- const decodedWord = decodedWords[i] ?? decodeHtmlEntities(rawWord);
808
- const separator = i > 0 ? ' ' : '';
809
- const hasCurrentLine = currentLineDecoded.length > 0;
810
- const testLineDecoded = hasCurrentLine
811
- ? `${currentLineDecoded}${separator}${decodedWord}`
812
- : decodedWord;
813
- // Expand tabs in test line for accurate width measurement
814
- // Tabs are expanded based on the full line position, maintaining tab stop alignment
815
- const testLineExpanded = expandTabsToTabStops(testLineDecoded, 8);
816
- const testWidth = doc.widthOfString(testLineExpanded, props);
817
- if (testWidth <= availableWidth) {
818
- currentLineDecoded = testLineDecoded;
251
+ const textWords = token.content.split(' ');
252
+ for (let i = 0; i < textWords.length; i++) {
253
+ const word = textWords[i];
254
+ const testLine = currentLine ? `${currentLine}${i > 0 ? ' ' : ''}${word}` : word;
255
+ const testWidth = doc.widthOfString(testLine, props);
256
+ if (testWidth <= element.width) {
257
+ currentLine = testLine;
819
258
  currentWidth = testWidth;
820
259
  // Add text token (with space if not first word in token)
821
- const rawContent = separator.length > 0 ? `${separator}${rawWord}` : rawWord;
822
- const decodedContent = separator.length > 0 ? `${separator}${decodedWord}` : decodedWord;
823
- currentTokens.push({
824
- type: 'text',
825
- content: rawContent,
826
- decodedContent,
827
- });
260
+ if (i > 0 || currentTokens.length > 0) {
261
+ let content = (i > 0 ? ' ' : '') + word;
262
+ currentTokens.push({
263
+ type: 'text',
264
+ content: content
265
+ });
266
+ }
267
+ else {
268
+ currentTokens.push({
269
+ type: 'text',
270
+ content: word
271
+ });
272
+ }
828
273
  }
829
274
  else {
830
275
  // Line is too long, save current line and start new one
831
- if (currentLineDecoded.length > 0) {
276
+ if (currentLine) {
832
277
  const result = tokensToHTML(currentTokens, openTags);
833
- const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
834
- lines.push({
835
- text: result.html,
836
- width: currentWidth,
837
- fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
838
- listMeta,
839
- });
278
+ lines.push({ text: result.html, width: currentWidth });
840
279
  openTags = result.openTags;
841
280
  currentTokens = [];
842
- showMarkerForLine = false;
843
281
  }
844
- currentLineDecoded = decodedWord;
845
- // Expand tabs for accurate width measurement
846
- const decodedWordExpanded = expandTabsToTabStops(decodedWord, 8);
847
- currentWidth = doc.widthOfString(decodedWordExpanded, props);
282
+ currentLine = word;
283
+ currentWidth = doc.widthOfString(word, props);
848
284
  currentTokens.push({
849
285
  type: 'text',
850
- content: rawWord,
851
- decodedContent: decodedWord,
286
+ content: word
852
287
  });
853
288
  }
854
289
  }
855
290
  }
856
291
  // Add the last line
857
- if (currentLineDecoded.length > 0) {
292
+ if (currentLine) {
858
293
  const result = tokensToHTML(currentTokens, openTags);
859
- const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
860
- lines.push({
861
- text: result.html,
862
- width: currentWidth,
863
- fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
864
- listMeta,
865
- });
866
- }
867
- else if (currentTokens.length === 0) {
868
- // Handle case when paragraph becomes empty after wrapping logic
869
- const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
870
- lines.push({
871
- text: '',
872
- width: 0,
873
- fullWidth: listMeta ? listMeta.textStartPx : 0,
874
- listMeta,
875
- });
294
+ lines.push({ text: result.html, width: currentWidth });
876
295
  }
877
296
  }
878
297
  }
879
298
  return lines;
880
299
  }
881
300
  /**
882
- * Calculate X offset for list markers (not used for text content positioning)
301
+ * Calculate horizontal offset for a line of text based on alignment
302
+ * @param element - Text element with alignment settings
303
+ * @param lineWidth - Width of the current line
304
+ * @returns X offset for positioning the line
883
305
  */
884
- function calculateLineXOffset(element, line) {
885
- // Markers are always at the left edge, regardless of text alignment
886
- if (line.listMeta) {
887
- return 0;
888
- }
889
- // For non-list lines, markers follow text alignment
306
+ function calculateLineXOffset(element, lineWidth) {
890
307
  const align = element.align;
891
- const targetWidth = line.width;
892
308
  if (align === 'right') {
893
- return element.width - targetWidth;
309
+ return element.width - lineWidth;
894
310
  }
895
311
  else if (align === 'center') {
896
- return (element.width - targetWidth) / 2;
897
- }
898
- // left or justify: markers at position 0
899
- return 0;
900
- }
901
- function calculateTextContentXOffset(element, line) {
902
- const align = element.align;
903
- const textWidth = line.width;
904
- const baseStart = line.listMeta?.textStartPx ?? 0;
905
- const availableWidth = Math.max(element.width - baseStart, 0);
906
- if (align === 'right') {
907
- return baseStart + Math.max(availableWidth - textWidth, 0);
908
- }
909
- else if (align === 'center') {
910
- return baseStart + Math.max((availableWidth - textWidth) / 2, 0);
911
- }
912
- return baseStart;
913
- }
914
- /**
915
- * Calculate effective width for text rendering, considering justify and underline constraints
916
- */
917
- function calculateEffectiveWidth(element, line, widthOption, hasUnderline) {
918
- if (widthOption !== undefined) {
919
- return widthOption;
920
- }
921
- if (hasUnderline) {
922
- return element.width;
312
+ return (element.width - lineWidth) / 2;
923
313
  }
924
- return undefined;
925
- }
926
- /**
927
- * Prepare rendering context for a line (calculates positions, widths, handles markers)
928
- */
929
- async function prepareLineForRendering(doc, element, line, lineIndex, yOffset, lineHeightPx, cumulativeYOffset, textOptions, fonts, markerColor, markerOpacity, markerMode) {
930
- const markerXOffset = calculateLineXOffset(element, line);
931
- const lineYOffset = yOffset + cumulativeYOffset;
932
- const strippedLineText = stripHtml(line.text).result;
933
- const heightOfLine = line.text === ''
934
- ? lineHeightPx
935
- : doc.heightOfString(strippedLineText, textOptions);
936
- const contentStartX = calculateTextContentXOffset(element, line);
937
- const isJustify = element.align === 'justify';
938
- // Disable justification if line contains tabs - tabs position content precisely,
939
- // and justification would interfere by spreading spaces
940
- const hasTabs = line.text.includes('\t');
941
- const widthOption = isJustify && !hasTabs
942
- ? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
943
- : undefined;
944
- // Handle list markers if needed
945
- if (line.listMeta?.showMarker) {
946
- await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, markerColor, markerOpacity, markerMode, textOptions);
947
- // Restore color after drawing marker (marker may have changed the color state)
948
- if (markerMode === 'fill') {
949
- doc.fillColor(markerColor, markerOpacity);
950
- }
951
- else {
952
- doc.strokeColor(markerColor, markerOpacity);
953
- }
954
- }
955
- return {
956
- markerXOffset,
957
- lineYOffset,
958
- contentStartX,
959
- widthOption,
960
- heightOfLine,
961
- };
962
- }
963
- /**
964
- * Render segments for a line with flexible options for different rendering modes
965
- */
966
- async function renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, options) {
967
- const { mode, color, opacity = element.opacity, heightOfLine, offsetX = 0, offsetY = 0, applySegmentColor, } = options;
968
- const totalTextSegments = renderSegments.filter((seg) => seg.type === 'text').length;
969
- let processedTextSegments = 0;
970
- // Position cursor at line start (with optional offset)
971
- doc.x = context.contentStartX + offsetX;
972
- doc.y = context.lineYOffset + offsetY;
973
- for (const renderSegment of renderSegments) {
974
- if (renderSegment.type === 'tab') {
975
- const advanceWidth = renderSegment.advanceWidth ?? 0;
976
- doc.x = context.contentStartX + advanceWidth + offsetX;
977
- continue;
978
- }
979
- const { segment, text = '', wordSpacing = 0, fontKey } = renderSegment;
980
- processedTextSegments += 1;
981
- const isLastSegment = processedTextSegments === totalTextSegments;
982
- doc.font(fontKey);
983
- doc.fontSize(element.fontSize);
984
- // Apply color (either segment-specific or global)
985
- if (applySegmentColor) {
986
- applySegmentColor(segment);
987
- }
988
- else if (color) {
989
- if (mode === 'fill') {
990
- doc.fillColor(color, opacity);
991
- }
992
- else {
993
- doc.strokeColor(color, opacity);
994
- }
995
- }
996
- const hasUnderline = segment.underline || textOptions.underline || false;
997
- const effectiveWidth = calculateEffectiveWidth(element, line, context.widthOption, hasUnderline);
998
- doc.text(text, {
999
- ...textOptions,
1000
- width: effectiveWidth,
1001
- height: heightOfLine,
1002
- continued: !isLastSegment,
1003
- underline: hasUnderline,
1004
- lineBreak: hasUnderline,
1005
- stroke: mode === 'stroke',
1006
- fill: mode === 'fill',
1007
- wordSpacing,
1008
- });
314
+ else if (align === 'justify') {
315
+ // Justify alignment is handled by PDFKit's align property
316
+ return 0;
1009
317
  }
318
+ // Default: left alignment
319
+ return 0;
1010
320
  }
1011
321
  /**
1012
322
  * Calculate text rendering metrics including line height and baseline offset
@@ -1023,15 +333,14 @@ function calculateTextMetrics(doc, element) {
1023
333
  : 0,
1024
334
  lineBreak: false,
1025
335
  stroke: false,
1026
- fill: false,
336
+ fill: false
1027
337
  };
1028
338
  const currentLineHeight = doc.heightOfString('A', textOptions);
1029
339
  const lineHeight = element.lineHeight * element.fontSize;
1030
340
  const fontBoundingBoxAscent = (doc._font.ascender / 1000) * element.fontSize;
1031
341
  const fontBoundingBoxDescent = (doc._font.descender / 1000) * element.fontSize;
1032
342
  // Calculate baseline offset based on font metrics (similar to Konva rendering)
1033
- const baselineOffset = (fontBoundingBoxAscent - Math.abs(fontBoundingBoxDescent)) / 2 +
1034
- lineHeight / 2;
343
+ const baselineOffset = (fontBoundingBoxAscent - Math.abs(fontBoundingBoxDescent)) / 2 + lineHeight / 2;
1035
344
  // Adjust line gap to match desired line height
1036
345
  const lineHeightDiff = currentLineHeight - lineHeight;
1037
346
  textOptions.lineGap = textOptions.lineGap - lineHeightDiff;
@@ -1040,7 +349,7 @@ function calculateTextMetrics(doc, element) {
1040
349
  textOptions,
1041
350
  lineHeightPx: lineHeight,
1042
351
  baselineOffset,
1043
- textLines,
352
+ textLines
1044
353
  };
1045
354
  }
1046
355
  /**
@@ -1082,8 +391,7 @@ function renderTextBackground(doc, element, verticalAlignmentOffset, textOptions
1082
391
  }
1083
392
  const strippedContent = stripHtml(element.text).result;
1084
393
  const padding = element.backgroundPadding * (element.fontSize * element.lineHeight);
1085
- const cornerRadius = element.backgroundCornerRadius *
1086
- (element.fontSize * element.lineHeight * 0.5);
394
+ const cornerRadius = element.backgroundCornerRadius * (element.fontSize * element.lineHeight * 0.5);
1087
395
  const textWidth = doc.widthOfString(strippedContent, {
1088
396
  ...textOptions,
1089
397
  width: element.width,
@@ -1108,45 +416,14 @@ function renderTextBackground(doc, element, verticalAlignmentOffset, textOptions
1108
416
  doc.fill();
1109
417
  doc.fillColor(parseColor(element.fill).hex, element.opacity);
1110
418
  }
1111
- async function drawListMarker(doc, element, line, lineXOffset, lineYOffset, fonts, color, opacity, mode, textOptions) {
1112
- if (!line.listMeta || !line.listMeta.showMarker) {
1113
- return;
1114
- }
1115
- const markerSegment = {
1116
- text: line.listMeta.markerText,
1117
- };
1118
- const fontKey = await loadFontForSegment(doc, markerSegment, element, fonts);
1119
- const previousFontSize = doc._fontSize !== undefined
1120
- ? doc._fontSize
1121
- : element.fontSize;
1122
- doc.font(fontKey);
1123
- doc.fontSize(line.listMeta.markerFontSize);
1124
- if (mode === 'fill') {
1125
- doc.fillColor(color, opacity);
1126
- }
1127
- else {
1128
- doc.strokeColor(color, opacity);
1129
- }
1130
- doc.text(line.listMeta.markerText, lineXOffset + line.listMeta.indentPx, lineYOffset, {
1131
- ...textOptions,
1132
- width: line.listMeta.markerBoxWidth,
1133
- align: line.listMeta.markerAlignment,
1134
- continued: false,
1135
- lineBreak: false,
1136
- underline: false,
1137
- characterSpacing: 0,
1138
- stroke: mode === 'stroke',
1139
- fill: mode === 'fill',
1140
- });
1141
- doc.fontSize(previousFontSize);
1142
- }
1143
419
  /**
1144
420
  * Render text stroke using PDF/X-1a compatible method (multiple offset fills)
1145
421
  */
1146
- async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
422
+ function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
1147
423
  const strokeColor = parseColor(element.stroke).hex;
1148
424
  const strokeWidth = element.strokeWidth;
1149
- // Generate stroke offsets in a circle pattern (8 directions)
425
+ const isJustify = element.align === 'justify';
426
+ // Generate stroke offsets in a circle pattern
1150
427
  const offsets = [];
1151
428
  for (let angle = 0; angle < 360; angle += 45) {
1152
429
  const radian = (angle * Math.PI) / 180;
@@ -1160,46 +437,55 @@ async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx
1160
437
  doc.fillColor(strokeColor, element.opacity);
1161
438
  for (let i = 0; i < textLines.length; i++) {
1162
439
  const line = textLines[i];
1163
- const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, i * lineHeightPx, textOptions, fonts, strokeColor, element.opacity, 'fill');
1164
- const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
1165
- // Render with each offset to create stroke effect
440
+ const lineXOffset = calculateLineXOffset(element, line.width);
441
+ const lineYOffset = yOffset + (i * lineHeightPx);
1166
442
  for (const offset of offsets) {
1167
- doc.text('', context.contentStartX + offset.x, context.lineYOffset + offset.y, {
1168
- height: 0,
1169
- width: 0,
1170
- });
1171
- await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
1172
- mode: 'fill',
1173
- color: strokeColor,
1174
- opacity: element.opacity,
1175
- offsetX: offset.x,
1176
- offsetY: offset.y,
443
+ doc.text(line.text, lineXOffset + offset.x, lineYOffset + offset.y, {
444
+ ...textOptions,
445
+ width: isJustify ? element.width : undefined,
446
+ stroke: false,
1177
447
  });
1178
448
  }
1179
449
  }
1180
450
  doc.restore();
1181
- await renderTextFill(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts);
451
+ // Render fill layer on top
452
+ doc.fillColor(parseColor(element.fill).hex, element.opacity);
453
+ for (let i = 0; i < textLines.length; i++) {
454
+ const line = textLines[i];
455
+ const lineXOffset = calculateLineXOffset(element, line.width);
456
+ const lineYOffset = yOffset + (i * lineHeightPx);
457
+ doc.text(line.text, lineXOffset, lineYOffset, {
458
+ ...textOptions,
459
+ width: isJustify ? element.width : undefined,
460
+ stroke: false,
461
+ });
462
+ }
1182
463
  }
1183
464
  /**
1184
465
  * Render text stroke using standard PDF stroke
1185
466
  */
1186
- async function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
1187
- const strokeParsedColor = parseColor(element.stroke);
467
+ function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
468
+ const isJustify = element.align === 'justify';
1188
469
  doc.save();
1189
470
  doc.lineWidth(element.strokeWidth);
1190
471
  doc.lineCap('round').lineJoin('round');
1191
- doc.strokeColor(strokeParsedColor.hex, element.opacity);
472
+ doc.strokeColor(parseColor(element.stroke).hex, element.opacity);
1192
473
  let cumulativeYOffset = 0;
1193
474
  for (let i = 0; i < textLines.length; i++) {
1194
475
  const line = textLines[i];
1195
- const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, cumulativeYOffset, textOptions, fonts, strokeParsedColor.hex, element.opacity, 'stroke');
1196
- cumulativeYOffset += context.heightOfLine;
1197
- const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
1198
- await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
1199
- mode: 'stroke',
1200
- color: strokeParsedColor.hex,
1201
- opacity: element.opacity,
1202
- heightOfLine: context.heightOfLine,
476
+ const lineXOffset = calculateLineXOffset(element, line.width);
477
+ const lineYOffset = yOffset + cumulativeYOffset;
478
+ const strippedLineText = stripHtml(line.text).result;
479
+ const heightOfLine = line.text === ''
480
+ ? lineHeightPx
481
+ : doc.heightOfString(strippedLineText, textOptions);
482
+ cumulativeYOffset += heightOfLine;
483
+ doc.text(line.text, lineXOffset, lineYOffset, {
484
+ ...textOptions,
485
+ width: isJustify ? element.width : undefined,
486
+ height: heightOfLine,
487
+ stroke: true,
488
+ fill: false
1203
489
  });
1204
490
  }
1205
491
  doc.restore();
@@ -1214,14 +500,29 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
1214
500
  const baseParsedColor = parseColor(element.fill);
1215
501
  const baseOpacity = Math.min(baseParsedColor.rgba[3] ?? 1, element.opacity, 1);
1216
502
  doc.fillColor(baseParsedColor.hex, baseOpacity);
503
+ const isJustify = element.align === 'justify';
1217
504
  let cumulativeYOffset = 0;
1218
505
  for (let i = 0; i < textLines.length; i++) {
1219
506
  const line = textLines[i];
1220
- const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, cumulativeYOffset, textOptions, fonts, baseParsedColor.hex, baseOpacity, 'fill');
1221
- cumulativeYOffset += context.heightOfLine;
1222
- const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
1223
- // Apply segment-specific colors for rich text
1224
- const applySegmentColor = (segment) => {
507
+ const lineXOffset = calculateLineXOffset(element, line.width);
508
+ const lineYOffset = yOffset + cumulativeYOffset;
509
+ const strippedLineText = stripHtml(line.text).result;
510
+ const heightOfLine = line.text === ''
511
+ ? lineHeightPx
512
+ : doc.heightOfString(strippedLineText, textOptions);
513
+ cumulativeYOffset += heightOfLine;
514
+ // Position cursor at line start
515
+ doc.text('', lineXOffset, lineYOffset, { height: 0, width: 0 });
516
+ // Parse line into styled segments
517
+ const segments = parseHTMLToSegments(line.text, element);
518
+ // Render each segment with its own styling
519
+ for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
520
+ const segment = segments[segmentIndex];
521
+ const isLastSegment = segmentIndex === segments.length - 1;
522
+ // Load appropriate font for this segment
523
+ await loadFontForSegment(doc, segment, element, fonts);
524
+ doc.fontSize(element.fontSize);
525
+ // Apply segment color
1225
526
  const segmentColor = segment.color
1226
527
  ? parseColor(segment.color).hex
1227
528
  : parseColor(element.fill).hex;
@@ -1230,48 +531,46 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
1230
531
  : parseColor(element.fill);
1231
532
  const segmentOpacity = Math.min(segmentParsedColor.rgba[3] ?? 1, element.opacity, 1);
1232
533
  doc.fillColor(segmentColor, segmentOpacity);
1233
- };
1234
- await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
1235
- mode: 'fill',
1236
- heightOfLine: context.heightOfLine,
1237
- applySegmentColor,
1238
- });
534
+ // Render segment text
535
+ doc.text(segment.text, {
536
+ ...textOptions,
537
+ width: isJustify ? element.width : undefined,
538
+ height: heightOfLine,
539
+ continued: !isLastSegment,
540
+ underline: segment.underline || textOptions.underline || false,
541
+ lineBreak: !!segment.underline, // Workaround for pdfkit bug
542
+ stroke: false,
543
+ fill: true
544
+ });
545
+ }
1239
546
  }
1240
547
  }
1241
548
  /**
1242
549
  * Main text rendering function
1243
550
  */
1244
551
  export async function renderText(doc, element, fonts, attrs = {}) {
1245
- const normalizedText = typeof element.text === 'string'
1246
- ? normalizeRichText(element.text)
1247
- : element.text;
1248
- const elementToRender = typeof element.text === 'string' && normalizedText !== element.text
1249
- ? { ...element, text: normalizedText }
1250
- : element;
1251
- doc.fontSize(elementToRender.fontSize);
1252
- const hasStroke = elementToRender.strokeWidth > 0;
552
+ doc.fontSize(element.fontSize);
553
+ const hasStroke = element.strokeWidth > 0;
1253
554
  const isPDFX1a = attrs.pdfx1a;
1254
555
  // Calculate text metrics and line positioning
1255
- const metrics = calculateTextMetrics(doc, elementToRender);
1256
- const verticalAlignmentOffset = calculateVerticalAlignment(doc, elementToRender, metrics.textOptions);
556
+ const metrics = calculateTextMetrics(doc, element);
557
+ const verticalAlignmentOffset = calculateVerticalAlignment(doc, element, metrics.textOptions);
1257
558
  // Fit text to element height if needed
1258
- fitTextToHeight(doc, elementToRender, metrics.textOptions);
559
+ fitTextToHeight(doc, element, metrics.textOptions);
1259
560
  // Calculate final vertical offset
1260
561
  const finalYOffset = verticalAlignmentOffset + metrics.baselineOffset;
1261
562
  // Render background if enabled
1262
- renderTextBackground(doc, elementToRender, verticalAlignmentOffset, metrics.textOptions);
563
+ renderTextBackground(doc, element, verticalAlignmentOffset, metrics.textOptions);
1263
564
  // Render text based on stroke and PDF/X-1a requirements
1264
565
  if (hasStroke && isPDFX1a) {
1265
566
  // PDF/X-1a mode: simulate stroke with offset fills
1266
- await renderPDFX1aStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
567
+ renderPDFX1aStroke(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
1267
568
  }
1268
569
  else {
1269
570
  // Standard rendering: stroke first, then fill
1270
571
  if (hasStroke) {
1271
- await renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
572
+ renderStandardStroke(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
1272
573
  }
1273
- await renderTextFill(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
574
+ await renderTextFill(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
1274
575
  }
1275
576
  }
1276
- // Internal exports for testing
1277
- export { normalizeRichText as __normalizeRichTextForTests, parseHTMLToSegments as __parseHTMLToSegmentsForTests, };