@polotno/pdf-export 0.1.32 → 0.1.34
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 +310 -295
- package/package.json +1 -1
package/lib/text.js
CHANGED
|
@@ -4,49 +4,15 @@ import fetch from 'node-fetch';
|
|
|
4
4
|
import { stripHtml } from 'string-strip-html';
|
|
5
5
|
import { decode as decodeEntities } from 'html-entities';
|
|
6
6
|
/**
|
|
7
|
-
* Expand tabs to spaces based on
|
|
8
|
-
* This
|
|
9
|
-
* the position of text after tabs.
|
|
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.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - Counts characters: "01\t" → "01 " (6 spaces to reach position 8)
|
|
15
|
-
* - Problem: In proportional fonts, "01" visually takes ~15px but we treat it as 2 chars
|
|
16
|
-
* - Result: Tabs misalign because visual width ≠ character count
|
|
17
|
-
*
|
|
18
|
-
* ACTUAL CHROME BEHAVIOR (visual/pixel-based):
|
|
19
|
-
* - Measures visual width: "01" = 15px, single space = 5px
|
|
20
|
-
* - Tab stop at: 8 spaces × 5px = 40px
|
|
21
|
-
* - "01\t" should advance from 15px → 40px (add 25px, or ~5 spaces)
|
|
22
|
-
* - "\t" should advance from 0px → 40px (add 40px, or 8 spaces)
|
|
23
|
-
* - Both end at same VISUAL position (40px), not same character position
|
|
24
|
-
*
|
|
25
|
-
* HOW TO FIX (future work):
|
|
26
|
-
* 1. Create `expandTabsWithVisualWidth(text, doc, textOptions)` that:
|
|
27
|
-
* - Measures actual text width character-by-character using doc.widthOfString()
|
|
28
|
-
* - Calculates tab stops as multiples of (spaceWidth × 8)
|
|
29
|
-
* - For each tab, determines visual advance needed to reach next tab stop
|
|
30
|
-
* 2. In rendering (renderTextFill, renderStandardStroke, renderPDFX1aStroke):
|
|
31
|
-
* - Split segments at tab characters
|
|
32
|
-
* - Replace each tab with N spaces
|
|
33
|
-
* - Use PDFKit's wordSpacing option to stretch/shrink those spaces to exact width
|
|
34
|
-
* - Example: Need 25px advance → use 5 spaces + wordSpacing adjustment
|
|
35
|
-
* 3. In line breaking (splitTextIntoLines):
|
|
36
|
-
* - Use visual width measurement for all width calculations
|
|
37
|
-
* - Ensure wrapped lines maintain accurate widths
|
|
38
|
-
*
|
|
39
|
-
* CHALLENGES:
|
|
40
|
-
* - Must measure with correct font for each styled segment (bold/italic affects width)
|
|
41
|
-
* - wordSpacing interacts with justify alignment - need careful handling
|
|
42
|
-
* - Line breaking must use same width calculations as rendering
|
|
43
|
-
* - Performance: width measurement is expensive, may need caching
|
|
44
|
-
*
|
|
45
|
-
* For now, we use character-based expansion which approximately matches monospace fonts
|
|
46
|
-
* but misaligns in proportional fonts like Roboto/Arial. This is a known issue.
|
|
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.
|
|
47
13
|
*
|
|
48
14
|
* @param text - Text containing tabs to expand
|
|
49
|
-
* @param tabSize - Size of tab stops (default 8
|
|
15
|
+
* @param tabSize - Size of tab stops in characters (default 8)
|
|
50
16
|
* @param startPosition - Starting character position for tab stop calculation (default 0)
|
|
51
17
|
* @returns Text with tabs expanded to spaces (character-based approximation)
|
|
52
18
|
*/
|
|
@@ -76,48 +42,136 @@ function expandTabsToTabStops(text, tabSize = 8, startPosition = 0) {
|
|
|
76
42
|
}
|
|
77
43
|
return result;
|
|
78
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
|
+
}
|
|
79
88
|
/**
|
|
80
|
-
* Expand tabs
|
|
81
|
-
* This
|
|
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
|
+
*
|
|
82
93
|
* @param text - Text containing tabs to expand
|
|
83
94
|
* @param doc - PDFKit document for measuring text width
|
|
84
95
|
* @param textOptions - PDFKit text options (font, size, etc.)
|
|
85
96
|
* @param tabSizeInSpaces - Number of spaces per tab stop (default 8)
|
|
86
97
|
* @param currentWidth - Current text width in points (default 0)
|
|
87
|
-
* @returns
|
|
98
|
+
* @returns Array of segments with expanded text and wordSpacing adjustments
|
|
88
99
|
*/
|
|
89
|
-
function
|
|
90
|
-
if (!text) {
|
|
91
|
-
|
|
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
|
+
};
|
|
92
108
|
}
|
|
93
|
-
// Measure the width of one space character
|
|
109
|
+
// Measure the width of one space character (for rendering tab spaces)
|
|
94
110
|
const spaceWidth = doc.widthOfString(' ', textOptions);
|
|
95
|
-
const
|
|
96
|
-
|
|
111
|
+
const effectiveTabSize = getEffectiveTabSize(tabSizeInSpaces);
|
|
112
|
+
const tabStopWidth = Math.max(spaceWidth * effectiveTabSize, spaceWidth || 1);
|
|
113
|
+
const shouldLog = isTabDebuggingEnabled() && !!debugContext;
|
|
114
|
+
const segments = [];
|
|
97
115
|
let width = currentWidth;
|
|
116
|
+
let currentSegment = '';
|
|
98
117
|
for (let i = 0; i < text.length; i++) {
|
|
99
118
|
const char = text[i];
|
|
100
119
|
if (char === '\t') {
|
|
101
|
-
//
|
|
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
|
|
102
128
|
const currentTabPosition = width % tabStopWidth;
|
|
103
|
-
const
|
|
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));
|
|
104
133
|
const spaces = ' '.repeat(spacesNeeded);
|
|
105
|
-
|
|
106
|
-
|
|
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;
|
|
107
152
|
}
|
|
108
153
|
else if (char === '\n') {
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
112
163
|
}
|
|
113
164
|
else {
|
|
114
|
-
|
|
115
|
-
// Measure the actual width of this character
|
|
116
|
-
const charWidth = doc.widthOfString(char, textOptions);
|
|
117
|
-
width += charWidth;
|
|
165
|
+
currentSegment += char;
|
|
118
166
|
}
|
|
119
167
|
}
|
|
120
|
-
|
|
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 };
|
|
121
175
|
}
|
|
122
176
|
function decodeHtmlEntities(text) {
|
|
123
177
|
if (!text) {
|
|
@@ -152,11 +206,10 @@ function normalizeRichText(text) {
|
|
|
152
206
|
normalized = normalized.replace(/\n{3,}/g, '\n\n');
|
|
153
207
|
// Trim stray leading/trailing newlines introduced by paragraph conversion
|
|
154
208
|
normalized = normalized.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
normalized = expandTabsToTabStops(normalized, 8);
|
|
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.
|
|
160
213
|
// Decode common HTML non-breaking space entities into their unicode counterpart
|
|
161
214
|
normalized = normalized.replace(/&(nbsp|#160|#xA0);/gi, '\u00A0');
|
|
162
215
|
// Strip zero-width characters that can create missing-glyph boxes in PDF output
|
|
@@ -260,30 +313,18 @@ export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', itali
|
|
|
260
313
|
const urls = getUrls(text);
|
|
261
314
|
return urls.values().next().value;
|
|
262
315
|
}
|
|
263
|
-
export async function loadFontIfNeeded(doc, element, fonts) {
|
|
264
|
-
// check if universal font is already defined
|
|
265
|
-
if (fonts[element.fontFamily]) {
|
|
266
|
-
doc.font(element.fontFamily);
|
|
267
|
-
return element.fontFamily;
|
|
268
|
-
}
|
|
269
|
-
const isItalic = element.fontStyle?.indexOf('italic') >= 0;
|
|
270
|
-
const isBold = element.fontWeight == 'bold';
|
|
271
|
-
const fontKey = getFontKey(element.fontFamily, isBold, isItalic, element.fontWeight);
|
|
272
|
-
if (!fonts[fontKey]) {
|
|
273
|
-
const src = await getGoogleFontPath(element.fontFamily, element.fontWeight, isItalic);
|
|
274
|
-
doc.registerFont(fontKey, await srcToBuffer(src));
|
|
275
|
-
fonts[fontKey] = true;
|
|
276
|
-
}
|
|
277
|
-
doc.font(fontKey);
|
|
278
|
-
return fontKey;
|
|
279
|
-
}
|
|
280
316
|
/**
|
|
281
|
-
* Load font for a
|
|
317
|
+
* Load font for a text element or segment
|
|
282
318
|
*/
|
|
283
319
|
async function loadFontForSegment(doc, segment, element, fonts) {
|
|
284
320
|
const fontFamily = element.fontFamily;
|
|
285
|
-
|
|
286
|
-
const
|
|
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;
|
|
287
328
|
// Check if universal font is already defined
|
|
288
329
|
if (fonts[fontFamily]) {
|
|
289
330
|
doc.font(fontFamily);
|
|
@@ -299,6 +340,55 @@ async function loadFontForSegment(doc, segment, element, fonts) {
|
|
|
299
340
|
doc.font(fontKey);
|
|
300
341
|
return fontKey;
|
|
301
342
|
}
|
|
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
|
+
}
|
|
302
392
|
/**
|
|
303
393
|
* Parse HTML into tokens (text and tags)
|
|
304
394
|
*/
|
|
@@ -789,15 +879,14 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
789
879
|
return lines;
|
|
790
880
|
}
|
|
791
881
|
/**
|
|
792
|
-
* Calculate
|
|
793
|
-
* @param element - Text element with alignment settings
|
|
794
|
-
* @param lineWidth - Width of the current line
|
|
795
|
-
* @returns X offset for positioning the line
|
|
882
|
+
* Calculate X offset for list markers (not used for text content positioning)
|
|
796
883
|
*/
|
|
797
884
|
function calculateLineXOffset(element, line) {
|
|
885
|
+
// Markers are always at the left edge, regardless of text alignment
|
|
798
886
|
if (line.listMeta) {
|
|
799
887
|
return 0;
|
|
800
888
|
}
|
|
889
|
+
// For non-list lines, markers follow text alignment
|
|
801
890
|
const align = element.align;
|
|
802
891
|
const targetWidth = line.width;
|
|
803
892
|
if (align === 'right') {
|
|
@@ -806,26 +895,13 @@ function calculateLineXOffset(element, line) {
|
|
|
806
895
|
else if (align === 'center') {
|
|
807
896
|
return (element.width - targetWidth) / 2;
|
|
808
897
|
}
|
|
809
|
-
|
|
810
|
-
return 0;
|
|
811
|
-
}
|
|
898
|
+
// left or justify: markers at position 0
|
|
812
899
|
return 0;
|
|
813
900
|
}
|
|
814
901
|
function calculateTextContentXOffset(element, line) {
|
|
815
902
|
const align = element.align;
|
|
816
903
|
const textWidth = line.width;
|
|
817
|
-
|
|
818
|
-
if (align === 'right') {
|
|
819
|
-
return element.width - textWidth;
|
|
820
|
-
}
|
|
821
|
-
else if (align === 'center') {
|
|
822
|
-
return (element.width - textWidth) / 2;
|
|
823
|
-
}
|
|
824
|
-
else {
|
|
825
|
-
return 0;
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
const baseStart = line.listMeta.textStartPx;
|
|
904
|
+
const baseStart = line.listMeta?.textStartPx ?? 0;
|
|
829
905
|
const availableWidth = Math.max(element.width - baseStart, 0);
|
|
830
906
|
if (align === 'right') {
|
|
831
907
|
return baseStart + Math.max(availableWidth - textWidth, 0);
|
|
@@ -833,8 +909,103 @@ function calculateTextContentXOffset(element, line) {
|
|
|
833
909
|
else if (align === 'center') {
|
|
834
910
|
return baseStart + Math.max((availableWidth - textWidth) / 2, 0);
|
|
835
911
|
}
|
|
836
|
-
|
|
837
|
-
|
|
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;
|
|
923
|
+
}
|
|
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
|
+
});
|
|
838
1009
|
}
|
|
839
1010
|
}
|
|
840
1011
|
/**
|
|
@@ -975,8 +1146,7 @@ async function drawListMarker(doc, element, line, lineXOffset, lineYOffset, font
|
|
|
975
1146
|
async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
976
1147
|
const strokeColor = parseColor(element.stroke).hex;
|
|
977
1148
|
const strokeWidth = element.strokeWidth;
|
|
978
|
-
|
|
979
|
-
// Generate stroke offsets in a circle pattern
|
|
1149
|
+
// Generate stroke offsets in a circle pattern (8 directions)
|
|
980
1150
|
const offsets = [];
|
|
981
1151
|
for (let angle = 0; angle < 360; angle += 45) {
|
|
982
1152
|
const radian = (angle * Math.PI) / 180;
|
|
@@ -990,66 +1160,21 @@ async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx
|
|
|
990
1160
|
doc.fillColor(strokeColor, element.opacity);
|
|
991
1161
|
for (let i = 0; i < textLines.length; i++) {
|
|
992
1162
|
const line = textLines[i];
|
|
993
|
-
const
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
const widthOption = isJustify
|
|
997
|
-
? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
|
|
998
|
-
: undefined;
|
|
999
|
-
if (line.listMeta?.showMarker) {
|
|
1000
|
-
await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, strokeColor, element.opacity, 'fill', textOptions);
|
|
1001
|
-
doc.fillColor(strokeColor, element.opacity);
|
|
1002
|
-
}
|
|
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
|
|
1003
1166
|
for (const offset of offsets) {
|
|
1004
|
-
doc.text('', contentStartX + offset.x, lineYOffset + offset.y, {
|
|
1167
|
+
doc.text('', context.contentStartX + offset.x, context.lineYOffset + offset.y, {
|
|
1005
1168
|
height: 0,
|
|
1006
1169
|
width: 0,
|
|
1007
1170
|
});
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
const hasTabs = segment.text.includes('\t');
|
|
1016
|
-
if (hasTabs) {
|
|
1017
|
-
// Load font for this segment to get accurate measurements
|
|
1018
|
-
await loadFontForSegment(doc, segment, element, fonts);
|
|
1019
|
-
doc.fontSize(element.fontSize);
|
|
1020
|
-
// Create text options for this segment
|
|
1021
|
-
const segmentTextOptions = {
|
|
1022
|
-
...textOptions,
|
|
1023
|
-
};
|
|
1024
|
-
// Expand tabs based on actual width
|
|
1025
|
-
const expanded = expandTabsToTabStopsByWidth(segment.text, doc, segmentTextOptions, 8, currentLineWidth);
|
|
1026
|
-
currentLineWidth = expanded.width;
|
|
1027
|
-
segmentsWithExpandedTabs.push({ ...segment, text: expanded.text });
|
|
1028
|
-
}
|
|
1029
|
-
else {
|
|
1030
|
-
// No tabs, just measure the width and update position
|
|
1031
|
-
await loadFontForSegment(doc, segment, element, fonts);
|
|
1032
|
-
doc.fontSize(element.fontSize);
|
|
1033
|
-
const segmentWidth = doc.widthOfString(segment.text, textOptions);
|
|
1034
|
-
currentLineWidth += segmentWidth;
|
|
1035
|
-
segmentsWithExpandedTabs.push(segment);
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
for (let segmentIndex = 0; segmentIndex < segmentsWithExpandedTabs.length; segmentIndex++) {
|
|
1039
|
-
const segment = segmentsWithExpandedTabs[segmentIndex];
|
|
1040
|
-
const fontKey = await loadFontForSegment(doc, segment, element, fonts);
|
|
1041
|
-
doc.font(fontKey);
|
|
1042
|
-
doc.fontSize(element.fontSize);
|
|
1043
|
-
doc.text(segment.text, {
|
|
1044
|
-
...textOptions,
|
|
1045
|
-
width: widthOption,
|
|
1046
|
-
stroke: false,
|
|
1047
|
-
fill: true,
|
|
1048
|
-
continued: segmentIndex !== segmentsWithExpandedTabs.length - 1,
|
|
1049
|
-
underline: segment.underline || textOptions.underline || false,
|
|
1050
|
-
lineBreak: !!segment.underline,
|
|
1051
|
-
});
|
|
1052
|
-
}
|
|
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,
|
|
1177
|
+
});
|
|
1053
1178
|
}
|
|
1054
1179
|
}
|
|
1055
1180
|
doc.restore();
|
|
@@ -1059,7 +1184,6 @@ async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx
|
|
|
1059
1184
|
* Render text stroke using standard PDF stroke
|
|
1060
1185
|
*/
|
|
1061
1186
|
async function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
1062
|
-
const isJustify = element.align === 'justify';
|
|
1063
1187
|
const strokeParsedColor = parseColor(element.stroke);
|
|
1064
1188
|
doc.save();
|
|
1065
1189
|
doc.lineWidth(element.strokeWidth);
|
|
@@ -1068,51 +1192,15 @@ async function renderStandardStroke(doc, element, textLines, yOffset, lineHeight
|
|
|
1068
1192
|
let cumulativeYOffset = 0;
|
|
1069
1193
|
for (let i = 0; i < textLines.length; i++) {
|
|
1070
1194
|
const line = textLines[i];
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
:
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
|
|
1081
|
-
: undefined;
|
|
1082
|
-
if (line.listMeta?.showMarker) {
|
|
1083
|
-
await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, strokeParsedColor.hex, element.opacity, 'stroke', textOptions);
|
|
1084
|
-
doc.strokeColor(strokeParsedColor.hex, element.opacity);
|
|
1085
|
-
}
|
|
1086
|
-
// Position cursor at line start
|
|
1087
|
-
// Use moveTo to set position directly without corrupting text rendering state
|
|
1088
|
-
doc.x = contentStartX;
|
|
1089
|
-
doc.y = lineYOffset;
|
|
1090
|
-
const segments = parseHTMLToSegments(line.text, element);
|
|
1091
|
-
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
|
|
1092
|
-
const segment = segments[segmentIndex];
|
|
1093
|
-
const fontKey = await loadFontForSegment(doc, segment, element, fonts);
|
|
1094
|
-
doc.font(fontKey);
|
|
1095
|
-
doc.fontSize(element.fontSize);
|
|
1096
|
-
doc.strokeColor(strokeParsedColor.hex, element.opacity);
|
|
1097
|
-
const hasUnderline = segment.underline || textOptions.underline || false;
|
|
1098
|
-
// When underline is enabled, we need lineBreak: true to avoid NaN errors in PDFKit
|
|
1099
|
-
// But we must preserve the width constraint to prevent unwanted text wrapping
|
|
1100
|
-
const effectiveWidth = widthOption !== undefined
|
|
1101
|
-
? widthOption
|
|
1102
|
-
: hasUnderline
|
|
1103
|
-
? element.width
|
|
1104
|
-
: widthOption;
|
|
1105
|
-
doc.text(segment.text, {
|
|
1106
|
-
...textOptions,
|
|
1107
|
-
width: effectiveWidth,
|
|
1108
|
-
height: heightOfLine,
|
|
1109
|
-
continued: segmentIndex !== segments.length - 1,
|
|
1110
|
-
stroke: true,
|
|
1111
|
-
fill: false,
|
|
1112
|
-
underline: hasUnderline,
|
|
1113
|
-
lineBreak: hasUnderline, // Workaround for pdfkit bug - enable lineBreak when underline is used
|
|
1114
|
-
});
|
|
1115
|
-
}
|
|
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,
|
|
1203
|
+
});
|
|
1116
1204
|
}
|
|
1117
1205
|
doc.restore();
|
|
1118
1206
|
}
|
|
@@ -1126,71 +1214,14 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
|
|
|
1126
1214
|
const baseParsedColor = parseColor(element.fill);
|
|
1127
1215
|
const baseOpacity = Math.min(baseParsedColor.rgba[3] ?? 1, element.opacity, 1);
|
|
1128
1216
|
doc.fillColor(baseParsedColor.hex, baseOpacity);
|
|
1129
|
-
const isJustify = element.align === 'justify';
|
|
1130
1217
|
let cumulativeYOffset = 0;
|
|
1131
1218
|
for (let i = 0; i < textLines.length; i++) {
|
|
1132
1219
|
const line = textLines[i];
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
: doc.heightOfString(strippedLineText, textOptions);
|
|
1139
|
-
cumulativeYOffset += heightOfLine;
|
|
1140
|
-
const contentStartX = calculateTextContentXOffset(element, line);
|
|
1141
|
-
const widthOption = isJustify
|
|
1142
|
-
? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
|
|
1143
|
-
: undefined;
|
|
1144
|
-
if (line.listMeta?.showMarker) {
|
|
1145
|
-
await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, baseParsedColor.hex, baseOpacity, 'fill', textOptions);
|
|
1146
|
-
doc.fillColor(baseParsedColor.hex, baseOpacity);
|
|
1147
|
-
}
|
|
1148
|
-
// Position cursor at line start
|
|
1149
|
-
// Use direct assignment to set position without corrupting text rendering state
|
|
1150
|
-
doc.x = contentStartX;
|
|
1151
|
-
doc.y = lineYOffset;
|
|
1152
|
-
// Parse line into styled segments
|
|
1153
|
-
const segments = parseHTMLToSegments(line.text, element);
|
|
1154
|
-
// Expand tabs in segments while tracking actual width across segments
|
|
1155
|
-
// This maintains tab stop alignment based on actual font metrics, not character count
|
|
1156
|
-
// Note: Tabs should already be expanded by normalizeRichText, but we handle them here
|
|
1157
|
-
// in case line.text still contains tabs (e.g., from HTML parsing that preserves tabs)
|
|
1158
|
-
let currentLineWidth = 0;
|
|
1159
|
-
const segmentsWithExpandedTabs = [];
|
|
1160
|
-
for (const segment of segments) {
|
|
1161
|
-
// Check if segment has tabs
|
|
1162
|
-
const hasTabs = segment.text.includes('\t');
|
|
1163
|
-
if (hasTabs) {
|
|
1164
|
-
// Load font for this segment to get accurate measurements
|
|
1165
|
-
await loadFontForSegment(doc, segment, element, fonts);
|
|
1166
|
-
doc.fontSize(element.fontSize);
|
|
1167
|
-
// Create text options for this segment
|
|
1168
|
-
const segmentTextOptions = {
|
|
1169
|
-
...textOptions,
|
|
1170
|
-
};
|
|
1171
|
-
// Expand tabs based on actual width
|
|
1172
|
-
const expanded = expandTabsToTabStopsByWidth(segment.text, doc, segmentTextOptions, 8, currentLineWidth);
|
|
1173
|
-
currentLineWidth = expanded.width;
|
|
1174
|
-
segmentsWithExpandedTabs.push({ ...segment, text: expanded.text });
|
|
1175
|
-
}
|
|
1176
|
-
else {
|
|
1177
|
-
// No tabs, just measure the width and update position
|
|
1178
|
-
// Load font to measure correctly
|
|
1179
|
-
await loadFontForSegment(doc, segment, element, fonts);
|
|
1180
|
-
doc.fontSize(element.fontSize);
|
|
1181
|
-
const segmentWidth = doc.widthOfString(segment.text, textOptions);
|
|
1182
|
-
currentLineWidth += segmentWidth;
|
|
1183
|
-
segmentsWithExpandedTabs.push(segment);
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
// Render each segment with its own styling
|
|
1187
|
-
for (let segmentIndex = 0; segmentIndex < segmentsWithExpandedTabs.length; segmentIndex++) {
|
|
1188
|
-
const segment = segmentsWithExpandedTabs[segmentIndex];
|
|
1189
|
-
const isLastSegment = segmentIndex === segmentsWithExpandedTabs.length - 1;
|
|
1190
|
-
// Load appropriate font for this segment
|
|
1191
|
-
await loadFontForSegment(doc, segment, element, fonts);
|
|
1192
|
-
doc.fontSize(element.fontSize);
|
|
1193
|
-
// Apply segment color
|
|
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) => {
|
|
1194
1225
|
const segmentColor = segment.color
|
|
1195
1226
|
? parseColor(segment.color).hex
|
|
1196
1227
|
: parseColor(element.fill).hex;
|
|
@@ -1199,28 +1230,12 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
|
|
|
1199
1230
|
: parseColor(element.fill);
|
|
1200
1231
|
const segmentOpacity = Math.min(segmentParsedColor.rgba[3] ?? 1, element.opacity, 1);
|
|
1201
1232
|
doc.fillColor(segmentColor, segmentOpacity);
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
const effectiveWidth = widthOption !== undefined
|
|
1209
|
-
? widthOption
|
|
1210
|
-
: hasUnderline
|
|
1211
|
-
? element.width
|
|
1212
|
-
: widthOption;
|
|
1213
|
-
doc.text(segment.text, {
|
|
1214
|
-
...textOptions,
|
|
1215
|
-
width: effectiveWidth,
|
|
1216
|
-
height: heightOfLine,
|
|
1217
|
-
continued: !isLastSegment,
|
|
1218
|
-
underline: hasUnderline,
|
|
1219
|
-
lineBreak: hasUnderline, // Workaround for pdfkit bug - enable lineBreak when underline is used
|
|
1220
|
-
stroke: false,
|
|
1221
|
-
fill: true,
|
|
1222
|
-
});
|
|
1223
|
-
}
|
|
1233
|
+
};
|
|
1234
|
+
await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
|
|
1235
|
+
mode: 'fill',
|
|
1236
|
+
heightOfLine: context.heightOfLine,
|
|
1237
|
+
applySegmentColor,
|
|
1238
|
+
});
|
|
1224
1239
|
}
|
|
1225
1240
|
}
|
|
1226
1241
|
/**
|