@polotno/pdf-export 0.1.37 → 0.1.39
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/README.md +61 -8
- package/lib/index.d.ts +66 -8
- package/lib/index.js +25 -145
- package/package.json +17 -18
- package/lib/browser-entry.d.ts +0 -7
- package/lib/browser-entry.js +0 -11
- package/lib/compare-render.d.ts +0 -1
- package/lib/compare-render.js +0 -185
- package/lib/core/index.d.ts +0 -26
- package/lib/core/index.js +0 -87
- package/lib/figure.d.ts +0 -10
- package/lib/figure.js +0 -54
- package/lib/filters.d.ts +0 -2
- package/lib/filters.js +0 -163
- package/lib/ghostscript.d.ts +0 -21
- package/lib/ghostscript.js +0 -132
- package/lib/group.d.ts +0 -5
- package/lib/group.js +0 -5
- package/lib/image.d.ts +0 -38
- package/lib/image.js +0 -279
- package/lib/line.d.ts +0 -10
- package/lib/line.js +0 -66
- package/lib/platform/adapter.d.ts +0 -37
- package/lib/platform/adapter.js +0 -13
- package/lib/platform/browser-polyfill.d.ts +0 -1
- 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/spot-colors.d.ts +0 -38
- package/lib/spot-colors.js +0 -141
- package/lib/svg-render.d.ts +0 -9
- package/lib/svg-render.js +0 -63
- package/lib/svg.d.ts +0 -12
- package/lib/svg.js +0 -224
- package/lib/text/fonts.d.ts +0 -16
- package/lib/text/fonts.js +0 -82
- package/lib/text/index.d.ts +0 -8
- package/lib/text/index.js +0 -42
- package/lib/text/layout.d.ts +0 -22
- package/lib/text/layout.js +0 -522
- package/lib/text/parser.d.ts +0 -46
- package/lib/text/parser.js +0 -415
- package/lib/text/render.d.ts +0 -8
- package/lib/text/render.js +0 -237
- package/lib/text/types.d.ts +0 -91
- package/lib/text/types.js +0 -1
- package/lib/text.d.ts +0 -49
- package/lib/text.js +0 -1277
- package/lib/utils.d.ts +0 -16
- package/lib/utils.js +0 -124
package/lib/text.js
DELETED
|
@@ -1,1277 +0,0 @@
|
|
|
1
|
-
import { parseColor, srcToBuffer } from './utils.js';
|
|
2
|
-
import getUrls from 'get-urls';
|
|
3
|
-
import fetch from 'node-fetch';
|
|
4
|
-
import { stripHtml } from 'string-strip-html';
|
|
5
|
-
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
|
-
}
|
|
184
|
-
/**
|
|
185
|
-
* Check if text contains HTML tags
|
|
186
|
-
*/
|
|
187
|
-
function containsHTML(text) {
|
|
188
|
-
const htmlTagRegex = /<\/?(?:strong|b|em|i|u|span|p|br)[^>]*>/i;
|
|
189
|
-
return htmlTagRegex.test(text);
|
|
190
|
-
}
|
|
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
|
-
/**
|
|
220
|
-
* Parse HTML text into styled segments
|
|
221
|
-
*/
|
|
222
|
-
function parseHTMLToSegments(html, baseElement) {
|
|
223
|
-
const segments = [];
|
|
224
|
-
const tagStack = [];
|
|
225
|
-
// Regex to match tags and text content
|
|
226
|
-
const regex = /<(\/?)(strong|b|em|i|u|span)([^>]*)>|([^<]+)/gi;
|
|
227
|
-
let match;
|
|
228
|
-
while ((match = regex.exec(html)) !== null) {
|
|
229
|
-
if (match[4]) {
|
|
230
|
-
// Text content
|
|
231
|
-
const text = decodeHtmlEntities(match[4]);
|
|
232
|
-
// Calculate current styles from tag stack
|
|
233
|
-
let bold = false;
|
|
234
|
-
let italic = false;
|
|
235
|
-
let underline = false;
|
|
236
|
-
let color = undefined;
|
|
237
|
-
for (const tag of tagStack) {
|
|
238
|
-
if (tag.tag === 'strong' || tag.tag === 'b')
|
|
239
|
-
bold = true;
|
|
240
|
-
if (tag.tag === 'em' || tag.tag === 'i')
|
|
241
|
-
italic = true;
|
|
242
|
-
if (tag.tag === 'u')
|
|
243
|
-
underline = true;
|
|
244
|
-
if (tag.color)
|
|
245
|
-
color = tag.color;
|
|
246
|
-
}
|
|
247
|
-
segments.push({
|
|
248
|
-
text,
|
|
249
|
-
bold,
|
|
250
|
-
italic,
|
|
251
|
-
underline,
|
|
252
|
-
color,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
else {
|
|
256
|
-
// Tag
|
|
257
|
-
const isClosing = match[1] === '/';
|
|
258
|
-
const tagName = match[2].toLowerCase();
|
|
259
|
-
const attributes = match[3];
|
|
260
|
-
if (isClosing) {
|
|
261
|
-
// Remove from stack
|
|
262
|
-
const index = tagStack.findIndex((t) => t.tag === tagName);
|
|
263
|
-
if (index !== -1) {
|
|
264
|
-
tagStack.splice(index, 1);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
// Add to stack
|
|
269
|
-
const tagData = { tag: tagName };
|
|
270
|
-
// Parse color from span style attribute
|
|
271
|
-
if (attributes) {
|
|
272
|
-
const colorMatch = /style=["'](?:[^"']*)?color:\s*([^;"']+)/i.exec(attributes);
|
|
273
|
-
if (colorMatch) {
|
|
274
|
-
tagData.color = colorMatch[1].trim();
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
tagStack.push(tagData);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return segments;
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Get font weight string based on bold/italic state
|
|
285
|
-
*/
|
|
286
|
-
function getFontWeight(bold, italic, baseFontWeight) {
|
|
287
|
-
if (bold) {
|
|
288
|
-
return 'bold';
|
|
289
|
-
}
|
|
290
|
-
return baseFontWeight || 'normal';
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Get font key for caching
|
|
294
|
-
*/
|
|
295
|
-
function getFontKey(fontFamily, bold, italic, baseFontWeight) {
|
|
296
|
-
const weight = getFontWeight(bold, italic, baseFontWeight);
|
|
297
|
-
const style = italic ? 'italic' : 'normal';
|
|
298
|
-
return `${fontFamily}-${weight}-${style}`;
|
|
299
|
-
}
|
|
300
|
-
export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', italic = false) {
|
|
301
|
-
const weight = fontWeight === 'bold' ? '700' : '400';
|
|
302
|
-
const italicParam = italic ? 'italic' : '';
|
|
303
|
-
const url = `https://fonts.googleapis.com/css?family=${fontFamily}:${italicParam}${weight}`;
|
|
304
|
-
const req = await fetch(url);
|
|
305
|
-
if (!req.ok) {
|
|
306
|
-
if (weight !== '400' || italic) {
|
|
307
|
-
// Fallback: try normal weight without italic
|
|
308
|
-
return getGoogleFontPath(fontFamily, 'normal', false);
|
|
309
|
-
}
|
|
310
|
-
throw new Error(`Failed to fetch Google font: ${fontFamily}`);
|
|
311
|
-
}
|
|
312
|
-
const text = await req.text();
|
|
313
|
-
const urls = getUrls(text);
|
|
314
|
-
return urls.values().next().value;
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Load font for a text element or segment
|
|
318
|
-
*/
|
|
319
|
-
async function loadFontForSegment(doc, segment, element, fonts) {
|
|
320
|
-
const fontFamily = element.fontFamily;
|
|
321
|
-
// Determine bold/italic from segment or element
|
|
322
|
-
const bold = segment
|
|
323
|
-
? segment.bold || element.fontWeight == 'bold' || false
|
|
324
|
-
: element.fontWeight == 'bold';
|
|
325
|
-
const italic = segment
|
|
326
|
-
? segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false
|
|
327
|
-
: element.fontStyle?.indexOf('italic') >= 0 || false;
|
|
328
|
-
// Check if universal font is already defined
|
|
329
|
-
if (fonts[fontFamily]) {
|
|
330
|
-
doc.font(fontFamily);
|
|
331
|
-
return fontFamily;
|
|
332
|
-
}
|
|
333
|
-
const fontKey = getFontKey(fontFamily, bold, italic, element.fontWeight);
|
|
334
|
-
if (!fonts[fontKey]) {
|
|
335
|
-
const weight = getFontWeight(bold, italic, element.fontWeight);
|
|
336
|
-
const src = await getGoogleFontPath(fontFamily, weight, italic);
|
|
337
|
-
doc.registerFont(fontKey, await srcToBuffer(src));
|
|
338
|
-
fonts[fontKey] = true;
|
|
339
|
-
}
|
|
340
|
-
doc.font(fontKey);
|
|
341
|
-
return fontKey;
|
|
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
|
-
}
|
|
392
|
-
/**
|
|
393
|
-
* Parse HTML into tokens (text and tags)
|
|
394
|
-
*/
|
|
395
|
-
function tokenizeHTML(html) {
|
|
396
|
-
const tokens = [];
|
|
397
|
-
const regex = /<(\/?)(strong|b|em|i|u|span)([^>]*)>|([^<]+)/gi;
|
|
398
|
-
let match;
|
|
399
|
-
while ((match = regex.exec(html)) !== null) {
|
|
400
|
-
if (match[4]) {
|
|
401
|
-
// Text content
|
|
402
|
-
const decodedContent = decodeHtmlEntities(match[4]);
|
|
403
|
-
tokens.push({
|
|
404
|
-
type: 'text',
|
|
405
|
-
content: match[4],
|
|
406
|
-
decodedContent,
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
else {
|
|
410
|
-
// Tag
|
|
411
|
-
const isClosing = match[1] === '/';
|
|
412
|
-
const tagName = match[2].toLowerCase();
|
|
413
|
-
tokens.push({
|
|
414
|
-
type: 'tag',
|
|
415
|
-
content: match[0],
|
|
416
|
-
tagName: tagName,
|
|
417
|
-
isClosing: isClosing,
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
return tokens;
|
|
422
|
-
}
|
|
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
|
-
/**
|
|
669
|
-
* Reconstruct HTML from tokens while maintaining proper tag nesting across line breaks
|
|
670
|
-
* @param tokens - Array of parsed HTML tokens
|
|
671
|
-
* @param openTags - Tags that were opened in previous lines and should be carried forward
|
|
672
|
-
* @returns Reconstructed HTML string and the updated list of open tags
|
|
673
|
-
*/
|
|
674
|
-
function tokensToHTML(tokens, openTags) {
|
|
675
|
-
let html = '';
|
|
676
|
-
const tagStack = [...openTags]; // Clone the open tags
|
|
677
|
-
// Prepend any open tags
|
|
678
|
-
for (const tag of openTags) {
|
|
679
|
-
html += tag.fullTag;
|
|
680
|
-
}
|
|
681
|
-
// Process tokens
|
|
682
|
-
for (const token of tokens) {
|
|
683
|
-
if (token.type === 'text') {
|
|
684
|
-
html += token.content;
|
|
685
|
-
}
|
|
686
|
-
else if (token.type === 'tag') {
|
|
687
|
-
html += token.content;
|
|
688
|
-
if (token.isClosing) {
|
|
689
|
-
// Remove from stack
|
|
690
|
-
const idx = tagStack.findIndex((t) => t.name === token.tagName);
|
|
691
|
-
if (idx !== -1) {
|
|
692
|
-
tagStack.splice(idx, 1);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
else {
|
|
696
|
-
// Add to stack
|
|
697
|
-
tagStack.push({
|
|
698
|
-
name: token.tagName,
|
|
699
|
-
fullTag: token.content,
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
// Close any remaining open tags for this line
|
|
705
|
-
for (let i = tagStack.length - 1; i >= 0; i--) {
|
|
706
|
-
html += `</${tagStack[i].name}>`;
|
|
707
|
-
}
|
|
708
|
-
return { html, openTags: tagStack };
|
|
709
|
-
}
|
|
710
|
-
/**
|
|
711
|
-
* Split text into lines that fit within the element width while preserving HTML formatting
|
|
712
|
-
* Handles word wrapping and ensures HTML tags are properly opened/closed across line breaks
|
|
713
|
-
*/
|
|
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
|
-
function splitTextIntoLines(doc, element, props) {
|
|
752
|
-
const lines = [];
|
|
753
|
-
const rawText = typeof element.text === 'string'
|
|
754
|
-
? element.text
|
|
755
|
-
: String(element.text ?? '');
|
|
756
|
-
const paragraphs = parseHtmlToParagraphs(rawText);
|
|
757
|
-
if (paragraphs.length === 0) {
|
|
758
|
-
paragraphs.push({ html: '' });
|
|
759
|
-
}
|
|
760
|
-
for (const paragraph of paragraphs) {
|
|
761
|
-
// Tokenize the paragraph
|
|
762
|
-
const tokens = tokenizeHTML(paragraph.html);
|
|
763
|
-
// Extract plain text for width calculation
|
|
764
|
-
// Expand tabs to tab stops for accurate width measurement
|
|
765
|
-
const plainText = expandTabsToTabStops(tokens
|
|
766
|
-
.filter((t) => t.type === 'text')
|
|
767
|
-
.map((t) => t.decodedContent ?? decodeHtmlEntities(t.content))
|
|
768
|
-
.join(''), 8);
|
|
769
|
-
const baseMeta = paragraph.listMeta
|
|
770
|
-
? createListLineMeta(doc, element, props, paragraph.listMeta)
|
|
771
|
-
: undefined;
|
|
772
|
-
const availableWidthRaw = element.width - (baseMeta ? baseMeta.textStartPx : 0);
|
|
773
|
-
const availableWidth = element.align === 'justify'
|
|
774
|
-
? Math.max(availableWidthRaw, 1)
|
|
775
|
-
: Math.max(availableWidthRaw, element.width * 0.1, 1);
|
|
776
|
-
const paragraphWidth = doc.widthOfString(plainText, props);
|
|
777
|
-
let showMarkerForLine = baseMeta?.showMarker ?? false;
|
|
778
|
-
// Justify alignment using native pdfkit instruments
|
|
779
|
-
if (paragraphWidth <= availableWidth || element.align === 'justify') {
|
|
780
|
-
// Paragraph fits on one line
|
|
781
|
-
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
782
|
-
lines.push({
|
|
783
|
-
text: paragraph.html,
|
|
784
|
-
width: paragraphWidth,
|
|
785
|
-
fullWidth: paragraphWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
786
|
-
listMeta,
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
else {
|
|
790
|
-
// Need to split paragraph into multiple lines
|
|
791
|
-
let currentLineDecoded = '';
|
|
792
|
-
let currentWidth = 0;
|
|
793
|
-
let currentTokens = [];
|
|
794
|
-
let openTags = [];
|
|
795
|
-
for (const token of tokens) {
|
|
796
|
-
if (token.type === 'tag') {
|
|
797
|
-
currentTokens.push(token);
|
|
798
|
-
continue;
|
|
799
|
-
}
|
|
800
|
-
// Text token - split by words
|
|
801
|
-
// Don't expand tabs here - we need to preserve tabs for proper alignment
|
|
802
|
-
const rawWords = token.content.split(' ');
|
|
803
|
-
const decodedText = token.decodedContent ?? decodeHtmlEntities(token.content);
|
|
804
|
-
const decodedWords = decodedText.split(' ');
|
|
805
|
-
for (let i = 0; i < rawWords.length; i++) {
|
|
806
|
-
const rawWord = rawWords[i];
|
|
807
|
-
const decodedWord = decodedWords[i] ?? decodeHtmlEntities(rawWord);
|
|
808
|
-
const separator = i > 0 ? ' ' : '';
|
|
809
|
-
const hasCurrentLine = currentLineDecoded.length > 0;
|
|
810
|
-
const testLineDecoded = hasCurrentLine
|
|
811
|
-
? `${currentLineDecoded}${separator}${decodedWord}`
|
|
812
|
-
: decodedWord;
|
|
813
|
-
// Expand tabs in test line for accurate width measurement
|
|
814
|
-
// Tabs are expanded based on the full line position, maintaining tab stop alignment
|
|
815
|
-
const testLineExpanded = expandTabsToTabStops(testLineDecoded, 8);
|
|
816
|
-
const testWidth = doc.widthOfString(testLineExpanded, props);
|
|
817
|
-
if (testWidth <= availableWidth) {
|
|
818
|
-
currentLineDecoded = testLineDecoded;
|
|
819
|
-
currentWidth = testWidth;
|
|
820
|
-
// Add text token (with space if not first word in token)
|
|
821
|
-
const rawContent = separator.length > 0 ? `${separator}${rawWord}` : rawWord;
|
|
822
|
-
const decodedContent = separator.length > 0 ? `${separator}${decodedWord}` : decodedWord;
|
|
823
|
-
currentTokens.push({
|
|
824
|
-
type: 'text',
|
|
825
|
-
content: rawContent,
|
|
826
|
-
decodedContent,
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
else {
|
|
830
|
-
// Line is too long, save current line and start new one
|
|
831
|
-
if (currentLineDecoded.length > 0) {
|
|
832
|
-
const result = tokensToHTML(currentTokens, openTags);
|
|
833
|
-
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
834
|
-
lines.push({
|
|
835
|
-
text: result.html,
|
|
836
|
-
width: currentWidth,
|
|
837
|
-
fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
838
|
-
listMeta,
|
|
839
|
-
});
|
|
840
|
-
openTags = result.openTags;
|
|
841
|
-
currentTokens = [];
|
|
842
|
-
showMarkerForLine = false;
|
|
843
|
-
}
|
|
844
|
-
currentLineDecoded = decodedWord;
|
|
845
|
-
// Expand tabs for accurate width measurement
|
|
846
|
-
const decodedWordExpanded = expandTabsToTabStops(decodedWord, 8);
|
|
847
|
-
currentWidth = doc.widthOfString(decodedWordExpanded, props);
|
|
848
|
-
currentTokens.push({
|
|
849
|
-
type: 'text',
|
|
850
|
-
content: rawWord,
|
|
851
|
-
decodedContent: decodedWord,
|
|
852
|
-
});
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
// Add the last line
|
|
857
|
-
if (currentLineDecoded.length > 0) {
|
|
858
|
-
const result = tokensToHTML(currentTokens, openTags);
|
|
859
|
-
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
860
|
-
lines.push({
|
|
861
|
-
text: result.html,
|
|
862
|
-
width: currentWidth,
|
|
863
|
-
fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
864
|
-
listMeta,
|
|
865
|
-
});
|
|
866
|
-
}
|
|
867
|
-
else if (currentTokens.length === 0) {
|
|
868
|
-
// Handle case when paragraph becomes empty after wrapping logic
|
|
869
|
-
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
870
|
-
lines.push({
|
|
871
|
-
text: '',
|
|
872
|
-
width: 0,
|
|
873
|
-
fullWidth: listMeta ? listMeta.textStartPx : 0,
|
|
874
|
-
listMeta,
|
|
875
|
-
});
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
return lines;
|
|
880
|
-
}
|
|
881
|
-
/**
|
|
882
|
-
* Calculate X offset for list markers (not used for text content positioning)
|
|
883
|
-
*/
|
|
884
|
-
function calculateLineXOffset(element, line) {
|
|
885
|
-
// Markers are always at the left edge, regardless of text alignment
|
|
886
|
-
if (line.listMeta) {
|
|
887
|
-
return 0;
|
|
888
|
-
}
|
|
889
|
-
// For non-list lines, markers follow text alignment
|
|
890
|
-
const align = element.align;
|
|
891
|
-
const targetWidth = line.width;
|
|
892
|
-
if (align === 'right') {
|
|
893
|
-
return element.width - targetWidth;
|
|
894
|
-
}
|
|
895
|
-
else if (align === 'center') {
|
|
896
|
-
return (element.width - targetWidth) / 2;
|
|
897
|
-
}
|
|
898
|
-
// left or justify: markers at position 0
|
|
899
|
-
return 0;
|
|
900
|
-
}
|
|
901
|
-
function calculateTextContentXOffset(element, line) {
|
|
902
|
-
const align = element.align;
|
|
903
|
-
const textWidth = line.width;
|
|
904
|
-
const baseStart = line.listMeta?.textStartPx ?? 0;
|
|
905
|
-
const availableWidth = Math.max(element.width - baseStart, 0);
|
|
906
|
-
if (align === 'right') {
|
|
907
|
-
return baseStart + Math.max(availableWidth - textWidth, 0);
|
|
908
|
-
}
|
|
909
|
-
else if (align === 'center') {
|
|
910
|
-
return baseStart + Math.max((availableWidth - textWidth) / 2, 0);
|
|
911
|
-
}
|
|
912
|
-
return baseStart;
|
|
913
|
-
}
|
|
914
|
-
/**
|
|
915
|
-
* Calculate effective width for text rendering, considering justify and underline constraints
|
|
916
|
-
*/
|
|
917
|
-
function calculateEffectiveWidth(element, line, widthOption, hasUnderline) {
|
|
918
|
-
if (widthOption !== undefined) {
|
|
919
|
-
return widthOption;
|
|
920
|
-
}
|
|
921
|
-
if (hasUnderline) {
|
|
922
|
-
return element.width;
|
|
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
|
-
});
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
/**
|
|
1012
|
-
* Calculate text rendering metrics including line height and baseline offset
|
|
1013
|
-
*/
|
|
1014
|
-
function calculateTextMetrics(doc, element) {
|
|
1015
|
-
const textOptions = {
|
|
1016
|
-
align: element.align === 'justify' ? 'justify' : 'left',
|
|
1017
|
-
baseline: 'alphabetic',
|
|
1018
|
-
lineGap: 1,
|
|
1019
|
-
width: element.width,
|
|
1020
|
-
underline: element.textDecoration.indexOf('underline') >= 0,
|
|
1021
|
-
characterSpacing: element.letterSpacing
|
|
1022
|
-
? element.letterSpacing * element.fontSize
|
|
1023
|
-
: 0,
|
|
1024
|
-
lineBreak: false,
|
|
1025
|
-
stroke: false,
|
|
1026
|
-
fill: false,
|
|
1027
|
-
};
|
|
1028
|
-
const currentLineHeight = doc.heightOfString('A', textOptions);
|
|
1029
|
-
const lineHeight = element.lineHeight * element.fontSize;
|
|
1030
|
-
const fontBoundingBoxAscent = (doc._font.ascender / 1000) * element.fontSize;
|
|
1031
|
-
const fontBoundingBoxDescent = (doc._font.descender / 1000) * element.fontSize;
|
|
1032
|
-
// Calculate baseline offset based on font metrics (similar to Konva rendering)
|
|
1033
|
-
const baselineOffset = (fontBoundingBoxAscent - Math.abs(fontBoundingBoxDescent)) / 2 +
|
|
1034
|
-
lineHeight / 2;
|
|
1035
|
-
// Adjust line gap to match desired line height
|
|
1036
|
-
const lineHeightDiff = currentLineHeight - lineHeight;
|
|
1037
|
-
textOptions.lineGap = textOptions.lineGap - lineHeightDiff;
|
|
1038
|
-
const textLines = splitTextIntoLines(doc, element, textOptions);
|
|
1039
|
-
return {
|
|
1040
|
-
textOptions,
|
|
1041
|
-
lineHeightPx: lineHeight,
|
|
1042
|
-
baselineOffset,
|
|
1043
|
-
textLines,
|
|
1044
|
-
};
|
|
1045
|
-
}
|
|
1046
|
-
/**
|
|
1047
|
-
* Calculate vertical alignment offset for text
|
|
1048
|
-
*/
|
|
1049
|
-
function calculateVerticalAlignment(doc, element, textOptions) {
|
|
1050
|
-
if (!element.verticalAlign || element.verticalAlign === 'top') {
|
|
1051
|
-
return 0;
|
|
1052
|
-
}
|
|
1053
|
-
const strippedContent = stripHtml(element.text).result;
|
|
1054
|
-
const textHeight = doc.heightOfString(strippedContent, textOptions);
|
|
1055
|
-
if (element.verticalAlign === 'middle') {
|
|
1056
|
-
return (element.height - textHeight) / 2;
|
|
1057
|
-
}
|
|
1058
|
-
else if (element.verticalAlign === 'bottom') {
|
|
1059
|
-
return element.height - textHeight;
|
|
1060
|
-
}
|
|
1061
|
-
return 0;
|
|
1062
|
-
}
|
|
1063
|
-
/**
|
|
1064
|
-
* Reduce font size to fit text within element height
|
|
1065
|
-
*/
|
|
1066
|
-
function fitTextToHeight(doc, element, textOptions) {
|
|
1067
|
-
const strippedContent = stripHtml(element.text).result;
|
|
1068
|
-
for (let size = element.fontSize; size > 0; size -= 1) {
|
|
1069
|
-
doc.fontSize(size);
|
|
1070
|
-
const height = doc.heightOfString(strippedContent, textOptions);
|
|
1071
|
-
if (height <= element.height) {
|
|
1072
|
-
break;
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
/**
|
|
1077
|
-
* Render text background box
|
|
1078
|
-
*/
|
|
1079
|
-
function renderTextBackground(doc, element, verticalAlignmentOffset, textOptions) {
|
|
1080
|
-
if (!element.backgroundEnabled) {
|
|
1081
|
-
return;
|
|
1082
|
-
}
|
|
1083
|
-
const strippedContent = stripHtml(element.text).result;
|
|
1084
|
-
const padding = element.backgroundPadding * (element.fontSize * element.lineHeight);
|
|
1085
|
-
const cornerRadius = element.backgroundCornerRadius *
|
|
1086
|
-
(element.fontSize * element.lineHeight * 0.5);
|
|
1087
|
-
const textWidth = doc.widthOfString(strippedContent, {
|
|
1088
|
-
...textOptions,
|
|
1089
|
-
width: element.width,
|
|
1090
|
-
});
|
|
1091
|
-
const textHeight = doc.heightOfString(strippedContent, {
|
|
1092
|
-
...textOptions,
|
|
1093
|
-
width: element.width,
|
|
1094
|
-
});
|
|
1095
|
-
let bgX = -padding / 2;
|
|
1096
|
-
let bgY = verticalAlignmentOffset - padding / 2;
|
|
1097
|
-
const bgWidth = textWidth + padding;
|
|
1098
|
-
const bgHeight = textHeight + padding;
|
|
1099
|
-
// Adjust horizontal position based on text alignment
|
|
1100
|
-
if (element.align === 'center') {
|
|
1101
|
-
bgX = (element.width - textWidth) / 2 - padding / 2;
|
|
1102
|
-
}
|
|
1103
|
-
else if (element.align === 'right') {
|
|
1104
|
-
bgX = element.width - textWidth - padding / 2;
|
|
1105
|
-
}
|
|
1106
|
-
doc.roundedRect(bgX, bgY, bgWidth, bgHeight, cornerRadius);
|
|
1107
|
-
doc.fillColor(parseColor(element.backgroundColor).hex);
|
|
1108
|
-
doc.fill();
|
|
1109
|
-
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
1110
|
-
}
|
|
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
|
-
/**
|
|
1144
|
-
* Render text stroke using PDF/X-1a compatible method (multiple offset fills)
|
|
1145
|
-
*/
|
|
1146
|
-
async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
1147
|
-
const strokeColor = parseColor(element.stroke).hex;
|
|
1148
|
-
const strokeWidth = element.strokeWidth;
|
|
1149
|
-
// Generate stroke offsets in a circle pattern (8 directions)
|
|
1150
|
-
const offsets = [];
|
|
1151
|
-
for (let angle = 0; angle < 360; angle += 45) {
|
|
1152
|
-
const radian = (angle * Math.PI) / 180;
|
|
1153
|
-
offsets.push({
|
|
1154
|
-
x: Math.cos(radian) * strokeWidth,
|
|
1155
|
-
y: Math.sin(radian) * strokeWidth,
|
|
1156
|
-
});
|
|
1157
|
-
}
|
|
1158
|
-
// Render stroke layer by drawing text multiple times with offsets
|
|
1159
|
-
doc.save();
|
|
1160
|
-
doc.fillColor(strokeColor, element.opacity);
|
|
1161
|
-
for (let i = 0; i < textLines.length; i++) {
|
|
1162
|
-
const line = textLines[i];
|
|
1163
|
-
const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, i * lineHeightPx, textOptions, fonts, strokeColor, element.opacity, 'fill');
|
|
1164
|
-
const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
|
|
1165
|
-
// Render with each offset to create stroke effect
|
|
1166
|
-
for (const offset of offsets) {
|
|
1167
|
-
doc.text('', context.contentStartX + offset.x, context.lineYOffset + offset.y, {
|
|
1168
|
-
height: 0,
|
|
1169
|
-
width: 0,
|
|
1170
|
-
});
|
|
1171
|
-
await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
|
|
1172
|
-
mode: 'fill',
|
|
1173
|
-
color: strokeColor,
|
|
1174
|
-
opacity: element.opacity,
|
|
1175
|
-
offsetX: offset.x,
|
|
1176
|
-
offsetY: offset.y,
|
|
1177
|
-
});
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
doc.restore();
|
|
1181
|
-
await renderTextFill(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts);
|
|
1182
|
-
}
|
|
1183
|
-
/**
|
|
1184
|
-
* Render text stroke using standard PDF stroke
|
|
1185
|
-
*/
|
|
1186
|
-
async function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
1187
|
-
const strokeParsedColor = parseColor(element.stroke);
|
|
1188
|
-
doc.save();
|
|
1189
|
-
doc.lineWidth(element.strokeWidth);
|
|
1190
|
-
doc.lineCap('round').lineJoin('round');
|
|
1191
|
-
doc.strokeColor(strokeParsedColor.hex, element.opacity);
|
|
1192
|
-
let cumulativeYOffset = 0;
|
|
1193
|
-
for (let i = 0; i < textLines.length; i++) {
|
|
1194
|
-
const line = textLines[i];
|
|
1195
|
-
const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, cumulativeYOffset, textOptions, fonts, strokeParsedColor.hex, element.opacity, 'stroke');
|
|
1196
|
-
cumulativeYOffset += context.heightOfLine;
|
|
1197
|
-
const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
|
|
1198
|
-
await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
|
|
1199
|
-
mode: 'stroke',
|
|
1200
|
-
color: strokeParsedColor.hex,
|
|
1201
|
-
opacity: element.opacity,
|
|
1202
|
-
heightOfLine: context.heightOfLine,
|
|
1203
|
-
});
|
|
1204
|
-
}
|
|
1205
|
-
doc.restore();
|
|
1206
|
-
}
|
|
1207
|
-
/**
|
|
1208
|
-
* Render text fill with rich text support (HTML segments)
|
|
1209
|
-
*/
|
|
1210
|
-
async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
1211
|
-
if (!element.fill) {
|
|
1212
|
-
return;
|
|
1213
|
-
}
|
|
1214
|
-
const baseParsedColor = parseColor(element.fill);
|
|
1215
|
-
const baseOpacity = Math.min(baseParsedColor.rgba[3] ?? 1, element.opacity, 1);
|
|
1216
|
-
doc.fillColor(baseParsedColor.hex, baseOpacity);
|
|
1217
|
-
let cumulativeYOffset = 0;
|
|
1218
|
-
for (let i = 0; i < textLines.length; i++) {
|
|
1219
|
-
const line = textLines[i];
|
|
1220
|
-
const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, cumulativeYOffset, textOptions, fonts, baseParsedColor.hex, baseOpacity, 'fill');
|
|
1221
|
-
cumulativeYOffset += context.heightOfLine;
|
|
1222
|
-
const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
|
|
1223
|
-
// Apply segment-specific colors for rich text
|
|
1224
|
-
const applySegmentColor = (segment) => {
|
|
1225
|
-
const segmentColor = segment.color
|
|
1226
|
-
? parseColor(segment.color).hex
|
|
1227
|
-
: parseColor(element.fill).hex;
|
|
1228
|
-
const segmentParsedColor = segment.color
|
|
1229
|
-
? parseColor(segment.color)
|
|
1230
|
-
: parseColor(element.fill);
|
|
1231
|
-
const segmentOpacity = Math.min(segmentParsedColor.rgba[3] ?? 1, element.opacity, 1);
|
|
1232
|
-
doc.fillColor(segmentColor, segmentOpacity);
|
|
1233
|
-
};
|
|
1234
|
-
await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
|
|
1235
|
-
mode: 'fill',
|
|
1236
|
-
heightOfLine: context.heightOfLine,
|
|
1237
|
-
applySegmentColor,
|
|
1238
|
-
});
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
/**
|
|
1242
|
-
* Main text rendering function
|
|
1243
|
-
*/
|
|
1244
|
-
export async function renderText(doc, element, fonts, attrs = {}) {
|
|
1245
|
-
const normalizedText = typeof element.text === 'string'
|
|
1246
|
-
? normalizeRichText(element.text)
|
|
1247
|
-
: element.text;
|
|
1248
|
-
const elementToRender = typeof element.text === 'string' && normalizedText !== element.text
|
|
1249
|
-
? { ...element, text: normalizedText }
|
|
1250
|
-
: element;
|
|
1251
|
-
doc.fontSize(elementToRender.fontSize);
|
|
1252
|
-
const hasStroke = elementToRender.strokeWidth > 0;
|
|
1253
|
-
const isPDFX1a = attrs.pdfx1a;
|
|
1254
|
-
// Calculate text metrics and line positioning
|
|
1255
|
-
const metrics = calculateTextMetrics(doc, elementToRender);
|
|
1256
|
-
const verticalAlignmentOffset = calculateVerticalAlignment(doc, elementToRender, metrics.textOptions);
|
|
1257
|
-
// Fit text to element height if needed
|
|
1258
|
-
fitTextToHeight(doc, elementToRender, metrics.textOptions);
|
|
1259
|
-
// Calculate final vertical offset
|
|
1260
|
-
const finalYOffset = verticalAlignmentOffset + metrics.baselineOffset;
|
|
1261
|
-
// Render background if enabled
|
|
1262
|
-
renderTextBackground(doc, elementToRender, verticalAlignmentOffset, metrics.textOptions);
|
|
1263
|
-
// Render text based on stroke and PDF/X-1a requirements
|
|
1264
|
-
if (hasStroke && isPDFX1a) {
|
|
1265
|
-
// PDF/X-1a mode: simulate stroke with offset fills
|
|
1266
|
-
await renderPDFX1aStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
1267
|
-
}
|
|
1268
|
-
else {
|
|
1269
|
-
// Standard rendering: stroke first, then fill
|
|
1270
|
-
if (hasStroke) {
|
|
1271
|
-
await renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
1272
|
-
}
|
|
1273
|
-
await renderTextFill(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
// Internal exports for testing
|
|
1277
|
-
export { normalizeRichText as __normalizeRichTextForTests, parseHTMLToSegments as __parseHTMLToSegmentsForTests, };
|