@polotno/pdf-export 0.1.37 → 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/pdf-import/coordinate-transform.d.ts +51 -0
- package/lib/pdf-import/coordinate-transform.js +99 -0
- package/lib/pdf-import/element-builder.d.ts +21 -0
- package/lib/pdf-import/element-builder.js +163 -0
- package/lib/pdf-import/font-mapper.d.ts +17 -0
- package/lib/pdf-import/font-mapper.js +142 -0
- package/lib/pdf-import/index.d.ts +35 -0
- package/lib/pdf-import/index.js +105 -0
- package/lib/pdf-import/parser.d.ts +29 -0
- package/lib/pdf-import/parser.js +285 -0
- package/lib/pdf-import/text-analysis.d.ts +17 -0
- package/lib/pdf-import/text-analysis.js +186 -0
- package/lib/pdf-import/types.d.ts +101 -0
- package/lib/scripts/compare-json.d.ts +1 -0
- package/lib/scripts/compare-json.js +141 -0
- package/lib/text/fonts.js +31 -0
- package/lib/text.d.ts +0 -10
- package/lib/text.js +161 -862
- package/package.json +1 -1
- package/lib/browser-entry.d.ts +0 -7
- package/lib/browser-entry.js +0 -11
- package/lib/core/index.d.ts +0 -26
- package/lib/core/index.js +0 -87
- package/lib/platform/adapter.d.ts +0 -37
- package/lib/platform/adapter.js +0 -13
- package/lib/platform/browser-polyfill.js +0 -5
- package/lib/platform/browser.d.ts +0 -7
- package/lib/platform/browser.js +0 -145
- package/lib/platform/node.d.ts +0 -7
- package/lib/platform/node.js +0 -142
- /package/lib/{platform/browser-polyfill.d.ts → pdf-import/types.js} +0 -0
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
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
322
|
-
const
|
|
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, '"')}"`)
|
|
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(
|
|
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
|
|
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
|
|
227
|
+
const tokens = tokenizeHTML(paragraph);
|
|
763
228
|
// Extract plain text for width calculation
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
.
|
|
767
|
-
.
|
|
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 <=
|
|
235
|
+
if (paragraphWidth <= element.width || element.align === 'justify') {
|
|
780
236
|
// Paragraph fits on one line
|
|
781
|
-
|
|
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
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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 (
|
|
276
|
+
if (currentLine) {
|
|
832
277
|
const result = tokensToHTML(currentTokens, openTags);
|
|
833
|
-
|
|
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
|
-
|
|
845
|
-
|
|
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:
|
|
851
|
-
decodedContent: decodedWord,
|
|
286
|
+
content: word
|
|
852
287
|
});
|
|
853
288
|
}
|
|
854
289
|
}
|
|
855
290
|
}
|
|
856
291
|
// Add the last line
|
|
857
|
-
if (
|
|
292
|
+
if (currentLine) {
|
|
858
293
|
const result = tokensToHTML(currentTokens, openTags);
|
|
859
|
-
|
|
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
|
|
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,
|
|
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 -
|
|
309
|
+
return element.width - lineWidth;
|
|
894
310
|
}
|
|
895
311
|
else if (align === 'center') {
|
|
896
|
-
return (element.width -
|
|
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
|
-
|
|
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
|
-
|
|
422
|
+
function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
|
|
1147
423
|
const strokeColor = parseColor(element.stroke).hex;
|
|
1148
424
|
const strokeWidth = element.strokeWidth;
|
|
1149
|
-
|
|
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
|
|
1164
|
-
const
|
|
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(
|
|
1168
|
-
|
|
1169
|
-
width:
|
|
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
|
-
|
|
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
|
-
|
|
1187
|
-
const
|
|
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(
|
|
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
|
|
1196
|
-
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
|
1221
|
-
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
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,
|
|
1256
|
-
const verticalAlignmentOffset = calculateVerticalAlignment(doc,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
572
|
+
renderStandardStroke(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
1272
573
|
}
|
|
1273
|
-
await renderTextFill(doc,
|
|
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, };
|