@polotno/pdf-export 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/text.js +471 -45
- package/package.json +1 -1
package/lib/text.js
CHANGED
|
@@ -194,6 +194,251 @@ function tokenizeHTML(html) {
|
|
|
194
194
|
}
|
|
195
195
|
return tokens;
|
|
196
196
|
}
|
|
197
|
+
const VOID_ELEMENTS = new Set(['br']);
|
|
198
|
+
function parseAttributes(raw) {
|
|
199
|
+
const attributes = {};
|
|
200
|
+
const attrRegex = /([^\s=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
201
|
+
let attrMatch;
|
|
202
|
+
while ((attrMatch = attrRegex.exec(raw)) !== null) {
|
|
203
|
+
const name = attrMatch[1].toLowerCase();
|
|
204
|
+
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
|
|
205
|
+
attributes[name] = value;
|
|
206
|
+
}
|
|
207
|
+
return attributes;
|
|
208
|
+
}
|
|
209
|
+
function parseSimpleHTML(html) {
|
|
210
|
+
const root = {
|
|
211
|
+
type: 'element',
|
|
212
|
+
tagName: 'root',
|
|
213
|
+
attributes: {},
|
|
214
|
+
children: [],
|
|
215
|
+
};
|
|
216
|
+
const stack = [root];
|
|
217
|
+
const tagRegex = /<\/?([a-zA-Z0-9:-]+)([^>]*)>/g;
|
|
218
|
+
let lastIndex = 0;
|
|
219
|
+
let match;
|
|
220
|
+
while ((match = tagRegex.exec(html)) !== null) {
|
|
221
|
+
if (match.index > lastIndex) {
|
|
222
|
+
const textContent = html.slice(lastIndex, match.index);
|
|
223
|
+
if (textContent) {
|
|
224
|
+
stack[stack.length - 1].children.push({
|
|
225
|
+
type: 'text',
|
|
226
|
+
content: textContent,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const fullMatch = match[0];
|
|
231
|
+
const isClosing = fullMatch.startsWith('</');
|
|
232
|
+
const tagName = match[1].toLowerCase();
|
|
233
|
+
const attrChunk = match[2] || '';
|
|
234
|
+
if (isClosing) {
|
|
235
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
236
|
+
if (stack[i].tagName === tagName) {
|
|
237
|
+
stack.length = i;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
const attributes = parseAttributes(attrChunk);
|
|
244
|
+
const elementNode = {
|
|
245
|
+
type: 'element',
|
|
246
|
+
tagName,
|
|
247
|
+
attributes,
|
|
248
|
+
children: [],
|
|
249
|
+
};
|
|
250
|
+
const parent = stack[stack.length - 1];
|
|
251
|
+
parent.children.push(elementNode);
|
|
252
|
+
const selfClosing = fullMatch.endsWith('/>') || VOID_ELEMENTS.has(tagName);
|
|
253
|
+
if (!selfClosing) {
|
|
254
|
+
stack.push(elementNode);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
lastIndex = tagRegex.lastIndex;
|
|
258
|
+
}
|
|
259
|
+
if (lastIndex < html.length) {
|
|
260
|
+
const remaining = html.slice(lastIndex);
|
|
261
|
+
if (remaining) {
|
|
262
|
+
stack[stack.length - 1].children.push({
|
|
263
|
+
type: 'text',
|
|
264
|
+
content: remaining,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return root;
|
|
269
|
+
}
|
|
270
|
+
function serializeAttributes(attributes) {
|
|
271
|
+
const entries = Object.entries(attributes);
|
|
272
|
+
if (!entries.length) {
|
|
273
|
+
return '';
|
|
274
|
+
}
|
|
275
|
+
return (' ' +
|
|
276
|
+
entries
|
|
277
|
+
.map(([key, value]) => value === '' ? key : `${key}="${value.replace(/"/g, '"')}"`)
|
|
278
|
+
.join(' '));
|
|
279
|
+
}
|
|
280
|
+
function shouldSkipNode(node) {
|
|
281
|
+
if (node.type === 'element' && node.tagName === 'span') {
|
|
282
|
+
const classAttr = node.attributes['class'] || node.attributes['className'] || '';
|
|
283
|
+
if (/\bql-cursor\b/.test(classAttr)) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
function serializeNodes(nodes) {
|
|
290
|
+
return nodes
|
|
291
|
+
.map((node) => {
|
|
292
|
+
if (shouldSkipNode(node)) {
|
|
293
|
+
return '';
|
|
294
|
+
}
|
|
295
|
+
if (node.type === 'text') {
|
|
296
|
+
return node.content.replace(/\uFEFF/g, '');
|
|
297
|
+
}
|
|
298
|
+
const attrs = serializeAttributes(node.attributes);
|
|
299
|
+
if (VOID_ELEMENTS.has(node.tagName)) {
|
|
300
|
+
return `<${node.tagName}${attrs}>`;
|
|
301
|
+
}
|
|
302
|
+
return `<${node.tagName}${attrs}>${serializeNodes(node.children)}</${node.tagName}>`;
|
|
303
|
+
})
|
|
304
|
+
.join('');
|
|
305
|
+
}
|
|
306
|
+
function getIndentLevelFromAttributes(attributes) {
|
|
307
|
+
const classAttr = attributes['class'] || attributes['className'] || '';
|
|
308
|
+
const match = classAttr.match(/ql-indent-(\d+)/);
|
|
309
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
310
|
+
}
|
|
311
|
+
function detectIndentLevel(node) {
|
|
312
|
+
const directIndent = getIndentLevelFromAttributes(node.attributes);
|
|
313
|
+
if (directIndent > 0) {
|
|
314
|
+
return directIndent;
|
|
315
|
+
}
|
|
316
|
+
for (const child of node.children) {
|
|
317
|
+
if (child.type === 'element' &&
|
|
318
|
+
child.tagName !== 'ul' &&
|
|
319
|
+
child.tagName !== 'ol') {
|
|
320
|
+
const childIndent = detectIndentLevel(child);
|
|
321
|
+
if (childIndent > 0) {
|
|
322
|
+
return childIndent;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
function nodesToParagraphs(nodes, baseIndentLevel = 0) {
|
|
329
|
+
const paragraphs = [];
|
|
330
|
+
let pendingInline = [];
|
|
331
|
+
const flushInline = () => {
|
|
332
|
+
if (pendingInline.length === 0) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const html = serializeNodes(pendingInline);
|
|
336
|
+
paragraphs.push({ html });
|
|
337
|
+
pendingInline = [];
|
|
338
|
+
};
|
|
339
|
+
for (const node of nodes) {
|
|
340
|
+
if (shouldSkipNode(node)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (node.type === 'text') {
|
|
344
|
+
const newlineRegex = /\n+/g;
|
|
345
|
+
let lastIndex = 0;
|
|
346
|
+
let match;
|
|
347
|
+
while ((match = newlineRegex.exec(node.content)) !== null) {
|
|
348
|
+
const chunk = node.content.slice(lastIndex, match.index);
|
|
349
|
+
if (chunk.length > 0) {
|
|
350
|
+
pendingInline.push({
|
|
351
|
+
type: 'text',
|
|
352
|
+
content: chunk,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
flushInline();
|
|
356
|
+
const extraBreaks = match[0].length - 1;
|
|
357
|
+
for (let extra = 0; extra < extraBreaks; extra++) {
|
|
358
|
+
paragraphs.push({ html: '' });
|
|
359
|
+
}
|
|
360
|
+
lastIndex = newlineRegex.lastIndex;
|
|
361
|
+
}
|
|
362
|
+
const rest = node.content.slice(lastIndex);
|
|
363
|
+
if (rest.length > 0) {
|
|
364
|
+
pendingInline.push({
|
|
365
|
+
type: 'text',
|
|
366
|
+
content: rest,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (node.tagName === 'p') {
|
|
372
|
+
flushInline();
|
|
373
|
+
const html = serializeNodes(node.children);
|
|
374
|
+
paragraphs.push({
|
|
375
|
+
html,
|
|
376
|
+
});
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (node.tagName === 'br') {
|
|
380
|
+
flushInline();
|
|
381
|
+
paragraphs.push({ html: '' });
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (node.tagName === 'ul' || node.tagName === 'ol') {
|
|
385
|
+
flushInline();
|
|
386
|
+
paragraphs.push(...collectListParagraphs(node, baseIndentLevel));
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
pendingInline.push(node);
|
|
390
|
+
}
|
|
391
|
+
flushInline();
|
|
392
|
+
return paragraphs;
|
|
393
|
+
}
|
|
394
|
+
function collectListParagraphs(listNode, baseIndentLevel) {
|
|
395
|
+
const paragraphs = [];
|
|
396
|
+
const listIndent = baseIndentLevel + getIndentLevelFromAttributes(listNode.attributes);
|
|
397
|
+
let counter = 1;
|
|
398
|
+
for (const child of listNode.children) {
|
|
399
|
+
if (child.type !== 'element' || child.tagName !== 'li') {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const liIndentFromAttribute = getIndentLevelFromAttributes(child.attributes);
|
|
403
|
+
const detectedIndent = detectIndentLevel(child);
|
|
404
|
+
const indentLevel = liIndentFromAttribute > 0
|
|
405
|
+
? listIndent + liIndentFromAttribute
|
|
406
|
+
: listIndent + detectedIndent;
|
|
407
|
+
const itemParagraphs = nodesToParagraphs(child.children, indentLevel);
|
|
408
|
+
let markerAssigned = false;
|
|
409
|
+
for (const paragraph of itemParagraphs) {
|
|
410
|
+
if (!paragraph.listMeta) {
|
|
411
|
+
paragraph.listMeta = {
|
|
412
|
+
type: listNode.tagName,
|
|
413
|
+
index: counter,
|
|
414
|
+
indentLevel,
|
|
415
|
+
displayMarker: !markerAssigned,
|
|
416
|
+
};
|
|
417
|
+
markerAssigned = true;
|
|
418
|
+
}
|
|
419
|
+
paragraphs.push(paragraph);
|
|
420
|
+
}
|
|
421
|
+
if (!markerAssigned) {
|
|
422
|
+
paragraphs.push({
|
|
423
|
+
html: '',
|
|
424
|
+
listMeta: {
|
|
425
|
+
type: listNode.tagName,
|
|
426
|
+
index: counter,
|
|
427
|
+
indentLevel,
|
|
428
|
+
displayMarker: true,
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
if (listNode.tagName === 'ol') {
|
|
433
|
+
counter += 1;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return paragraphs;
|
|
437
|
+
}
|
|
438
|
+
function parseHtmlToParagraphs(html) {
|
|
439
|
+
const root = parseSimpleHTML(html);
|
|
440
|
+
return nodesToParagraphs(root.children);
|
|
441
|
+
}
|
|
197
442
|
/**
|
|
198
443
|
* Reconstruct HTML from tokens while maintaining proper tag nesting across line breaks
|
|
199
444
|
* @param tokens - Array of parsed HTML tokens
|
|
@@ -240,22 +485,79 @@ function tokensToHTML(tokens, openTags) {
|
|
|
240
485
|
* Split text into lines that fit within the element width while preserving HTML formatting
|
|
241
486
|
* Handles word wrapping and ensures HTML tags are properly opened/closed across line breaks
|
|
242
487
|
*/
|
|
488
|
+
function cloneListMetaForLine(meta, showMarker) {
|
|
489
|
+
if (!meta) {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
...meta,
|
|
494
|
+
showMarker,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function createListLineMeta(doc, element, props, paragraphMeta) {
|
|
498
|
+
const indentPx = paragraphMeta.indentLevel * element.fontSize * 0.5;
|
|
499
|
+
const markerText = paragraphMeta.type === 'ul' ? '•' : `${paragraphMeta.index.toString()}.`;
|
|
500
|
+
const previousFontSize = doc._fontSize !== undefined
|
|
501
|
+
? doc._fontSize
|
|
502
|
+
: element.fontSize;
|
|
503
|
+
const markerFontSize = paragraphMeta.type === 'ul' ? element.fontSize * 1.2 : element.fontSize;
|
|
504
|
+
doc.fontSize(markerFontSize);
|
|
505
|
+
const markerLabelWidth = doc.widthOfString(markerText, {
|
|
506
|
+
...props,
|
|
507
|
+
width: undefined,
|
|
508
|
+
});
|
|
509
|
+
doc.fontSize(previousFontSize);
|
|
510
|
+
const markerGapPx = element.fontSize * (paragraphMeta.type === 'ul' ? 1.5 : 0.8);
|
|
511
|
+
const markerBoxMinPx = element.fontSize * (paragraphMeta.type === 'ul' ? 2.5 : 2.8);
|
|
512
|
+
const markerBoxWidth = Math.max(markerLabelWidth + markerGapPx, markerBoxMinPx);
|
|
513
|
+
const textStartPx = indentPx + markerBoxWidth;
|
|
514
|
+
return {
|
|
515
|
+
type: paragraphMeta.type,
|
|
516
|
+
markerText,
|
|
517
|
+
indentPx,
|
|
518
|
+
markerBoxWidth,
|
|
519
|
+
markerFontSize,
|
|
520
|
+
markerAlignment: paragraphMeta.type === 'ul' ? 'center' : 'right',
|
|
521
|
+
showMarker: paragraphMeta.displayMarker,
|
|
522
|
+
textStartPx,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
243
525
|
function splitTextIntoLines(doc, element, props) {
|
|
244
526
|
const lines = [];
|
|
245
|
-
const
|
|
527
|
+
const rawText = typeof element.text === 'string'
|
|
528
|
+
? element.text
|
|
529
|
+
: String(element.text ?? '');
|
|
530
|
+
const paragraphs = parseHtmlToParagraphs(rawText);
|
|
531
|
+
if (paragraphs.length === 0) {
|
|
532
|
+
paragraphs.push({ html: '' });
|
|
533
|
+
}
|
|
246
534
|
for (const paragraph of paragraphs) {
|
|
247
535
|
// Tokenize the paragraph
|
|
248
|
-
const tokens = tokenizeHTML(paragraph);
|
|
536
|
+
const tokens = tokenizeHTML(paragraph.html);
|
|
249
537
|
// Extract plain text for width calculation
|
|
250
538
|
const plainText = tokens
|
|
251
539
|
.filter((t) => t.type === 'text')
|
|
252
540
|
.map((t) => t.content)
|
|
253
541
|
.join('');
|
|
542
|
+
const baseMeta = paragraph.listMeta
|
|
543
|
+
? createListLineMeta(doc, element, props, paragraph.listMeta)
|
|
544
|
+
: undefined;
|
|
545
|
+
const availableWidthRaw = element.width - (baseMeta ? baseMeta.textStartPx : 0);
|
|
546
|
+
const availableWidth = element.align === 'justify'
|
|
547
|
+
? Math.max(availableWidthRaw, 1)
|
|
548
|
+
: Math.max(availableWidthRaw, element.width * 0.1, 1);
|
|
254
549
|
const paragraphWidth = doc.widthOfString(plainText, props);
|
|
550
|
+
let showMarkerForLine = baseMeta?.showMarker ?? false;
|
|
255
551
|
// Justify alignment using native pdfkit instruments
|
|
256
|
-
if (paragraphWidth <=
|
|
552
|
+
if (paragraphWidth <= availableWidth || element.align === 'justify') {
|
|
257
553
|
// Paragraph fits on one line
|
|
258
|
-
|
|
554
|
+
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
555
|
+
lines.push({
|
|
556
|
+
text: paragraph.html,
|
|
557
|
+
width: paragraphWidth,
|
|
558
|
+
fullWidth: paragraphWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
559
|
+
listMeta,
|
|
560
|
+
});
|
|
259
561
|
}
|
|
260
562
|
else {
|
|
261
563
|
// Need to split paragraph into multiple lines
|
|
@@ -276,7 +578,7 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
276
578
|
? `${currentLine}${i > 0 ? ' ' : ''}${word}`
|
|
277
579
|
: word;
|
|
278
580
|
const testWidth = doc.widthOfString(testLine, props);
|
|
279
|
-
if (testWidth <=
|
|
581
|
+
if (testWidth <= availableWidth) {
|
|
280
582
|
currentLine = testLine;
|
|
281
583
|
currentWidth = testWidth;
|
|
282
584
|
// Add text token (with space if not first word in token)
|
|
@@ -298,9 +600,16 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
298
600
|
// Line is too long, save current line and start new one
|
|
299
601
|
if (currentLine) {
|
|
300
602
|
const result = tokensToHTML(currentTokens, openTags);
|
|
301
|
-
|
|
603
|
+
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
604
|
+
lines.push({
|
|
605
|
+
text: result.html,
|
|
606
|
+
width: currentWidth,
|
|
607
|
+
fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
608
|
+
listMeta,
|
|
609
|
+
});
|
|
302
610
|
openTags = result.openTags;
|
|
303
611
|
currentTokens = [];
|
|
612
|
+
showMarkerForLine = false;
|
|
304
613
|
}
|
|
305
614
|
currentLine = word;
|
|
306
615
|
currentWidth = doc.widthOfString(word, props);
|
|
@@ -314,7 +623,23 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
314
623
|
// Add the last line
|
|
315
624
|
if (currentLine) {
|
|
316
625
|
const result = tokensToHTML(currentTokens, openTags);
|
|
317
|
-
|
|
626
|
+
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
627
|
+
lines.push({
|
|
628
|
+
text: result.html,
|
|
629
|
+
width: currentWidth,
|
|
630
|
+
fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
631
|
+
listMeta,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
else if (currentTokens.length === 0) {
|
|
635
|
+
// Handle case when paragraph becomes empty after wrapping logic
|
|
636
|
+
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
637
|
+
lines.push({
|
|
638
|
+
text: '',
|
|
639
|
+
width: 0,
|
|
640
|
+
fullWidth: listMeta ? listMeta.textStartPx : 0,
|
|
641
|
+
listMeta,
|
|
642
|
+
});
|
|
318
643
|
}
|
|
319
644
|
}
|
|
320
645
|
}
|
|
@@ -326,21 +651,49 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
326
651
|
* @param lineWidth - Width of the current line
|
|
327
652
|
* @returns X offset for positioning the line
|
|
328
653
|
*/
|
|
329
|
-
function calculateLineXOffset(element,
|
|
654
|
+
function calculateLineXOffset(element, line) {
|
|
655
|
+
if (line.listMeta) {
|
|
656
|
+
return 0;
|
|
657
|
+
}
|
|
330
658
|
const align = element.align;
|
|
659
|
+
const targetWidth = line.width;
|
|
331
660
|
if (align === 'right') {
|
|
332
|
-
return element.width -
|
|
661
|
+
return element.width - targetWidth;
|
|
333
662
|
}
|
|
334
663
|
else if (align === 'center') {
|
|
335
|
-
return (element.width -
|
|
664
|
+
return (element.width - targetWidth) / 2;
|
|
336
665
|
}
|
|
337
666
|
else if (align === 'justify') {
|
|
338
|
-
// Justify alignment is handled by PDFKit's align property
|
|
339
667
|
return 0;
|
|
340
668
|
}
|
|
341
|
-
// Default: left alignment
|
|
342
669
|
return 0;
|
|
343
670
|
}
|
|
671
|
+
function calculateTextContentXOffset(element, line) {
|
|
672
|
+
const align = element.align;
|
|
673
|
+
const textWidth = line.width;
|
|
674
|
+
if (!line.listMeta) {
|
|
675
|
+
if (align === 'right') {
|
|
676
|
+
return element.width - textWidth;
|
|
677
|
+
}
|
|
678
|
+
else if (align === 'center') {
|
|
679
|
+
return (element.width - textWidth) / 2;
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
return 0;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const baseStart = line.listMeta.textStartPx;
|
|
686
|
+
const availableWidth = Math.max(element.width - baseStart, 0);
|
|
687
|
+
if (align === 'right') {
|
|
688
|
+
return baseStart + Math.max(availableWidth - textWidth, 0);
|
|
689
|
+
}
|
|
690
|
+
else if (align === 'center') {
|
|
691
|
+
return baseStart + Math.max((availableWidth - textWidth) / 2, 0);
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
return baseStart;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
344
697
|
/**
|
|
345
698
|
* Calculate text rendering metrics including line height and baseline offset
|
|
346
699
|
*/
|
|
@@ -441,10 +794,42 @@ function renderTextBackground(doc, element, verticalAlignmentOffset, textOptions
|
|
|
441
794
|
doc.fill();
|
|
442
795
|
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
443
796
|
}
|
|
797
|
+
async function drawListMarker(doc, element, line, lineXOffset, lineYOffset, fonts, color, opacity, mode, textOptions) {
|
|
798
|
+
if (!line.listMeta || !line.listMeta.showMarker) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
const markerSegment = {
|
|
802
|
+
text: line.listMeta.markerText,
|
|
803
|
+
};
|
|
804
|
+
const fontKey = await loadFontForSegment(doc, markerSegment, element, fonts);
|
|
805
|
+
const previousFontSize = doc._fontSize !== undefined
|
|
806
|
+
? doc._fontSize
|
|
807
|
+
: element.fontSize;
|
|
808
|
+
doc.font(fontKey);
|
|
809
|
+
doc.fontSize(line.listMeta.markerFontSize);
|
|
810
|
+
if (mode === 'fill') {
|
|
811
|
+
doc.fillColor(color, opacity);
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
doc.strokeColor(color, opacity);
|
|
815
|
+
}
|
|
816
|
+
doc.text(line.listMeta.markerText, lineXOffset + line.listMeta.indentPx, lineYOffset, {
|
|
817
|
+
...textOptions,
|
|
818
|
+
width: line.listMeta.markerBoxWidth,
|
|
819
|
+
align: line.listMeta.markerAlignment,
|
|
820
|
+
continued: false,
|
|
821
|
+
lineBreak: false,
|
|
822
|
+
underline: false,
|
|
823
|
+
characterSpacing: 0,
|
|
824
|
+
stroke: mode === 'stroke',
|
|
825
|
+
fill: mode === 'fill',
|
|
826
|
+
});
|
|
827
|
+
doc.fontSize(previousFontSize);
|
|
828
|
+
}
|
|
444
829
|
/**
|
|
445
830
|
* Render text stroke using PDF/X-1a compatible method (multiple offset fills)
|
|
446
831
|
*/
|
|
447
|
-
function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
|
|
832
|
+
async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
448
833
|
const strokeColor = parseColor(element.stroke).hex;
|
|
449
834
|
const strokeWidth = element.strokeWidth;
|
|
450
835
|
const isJustify = element.align === 'justify';
|
|
@@ -462,56 +847,89 @@ function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, text
|
|
|
462
847
|
doc.fillColor(strokeColor, element.opacity);
|
|
463
848
|
for (let i = 0; i < textLines.length; i++) {
|
|
464
849
|
const line = textLines[i];
|
|
465
|
-
const
|
|
850
|
+
const markerXOffset = calculateLineXOffset(element, line);
|
|
466
851
|
const lineYOffset = yOffset + i * lineHeightPx;
|
|
852
|
+
const contentStartX = calculateTextContentXOffset(element, line);
|
|
853
|
+
const widthOption = isJustify
|
|
854
|
+
? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
|
|
855
|
+
: undefined;
|
|
856
|
+
if (line.listMeta?.showMarker) {
|
|
857
|
+
await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, strokeColor, element.opacity, 'fill', textOptions);
|
|
858
|
+
doc.fillColor(strokeColor, element.opacity);
|
|
859
|
+
}
|
|
467
860
|
for (const offset of offsets) {
|
|
468
|
-
doc.text(
|
|
469
|
-
|
|
470
|
-
width:
|
|
471
|
-
stroke: false,
|
|
861
|
+
doc.text('', contentStartX + offset.x, lineYOffset + offset.y, {
|
|
862
|
+
height: 0,
|
|
863
|
+
width: 0,
|
|
472
864
|
});
|
|
865
|
+
const segments = parseHTMLToSegments(line.text, element);
|
|
866
|
+
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
|
|
867
|
+
const segment = segments[segmentIndex];
|
|
868
|
+
const fontKey = await loadFontForSegment(doc, segment, element, fonts);
|
|
869
|
+
doc.font(fontKey);
|
|
870
|
+
doc.fontSize(element.fontSize);
|
|
871
|
+
doc.text(segment.text, {
|
|
872
|
+
...textOptions,
|
|
873
|
+
width: widthOption,
|
|
874
|
+
stroke: false,
|
|
875
|
+
fill: true,
|
|
876
|
+
continued: segmentIndex !== segments.length - 1,
|
|
877
|
+
underline: segment.underline || textOptions.underline || false,
|
|
878
|
+
lineBreak: !!segment.underline,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
473
881
|
}
|
|
474
882
|
}
|
|
475
883
|
doc.restore();
|
|
476
|
-
|
|
477
|
-
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
478
|
-
for (let i = 0; i < textLines.length; i++) {
|
|
479
|
-
const line = textLines[i];
|
|
480
|
-
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
481
|
-
const lineYOffset = yOffset + i * lineHeightPx;
|
|
482
|
-
doc.text(line.text, lineXOffset, lineYOffset, {
|
|
483
|
-
...textOptions,
|
|
484
|
-
width: isJustify ? element.width : undefined,
|
|
485
|
-
stroke: false,
|
|
486
|
-
});
|
|
487
|
-
}
|
|
884
|
+
await renderTextFill(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts);
|
|
488
885
|
}
|
|
489
886
|
/**
|
|
490
887
|
* Render text stroke using standard PDF stroke
|
|
491
888
|
*/
|
|
492
|
-
function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
|
|
889
|
+
async function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
493
890
|
const isJustify = element.align === 'justify';
|
|
891
|
+
const strokeParsedColor = parseColor(element.stroke);
|
|
494
892
|
doc.save();
|
|
495
893
|
doc.lineWidth(element.strokeWidth);
|
|
496
894
|
doc.lineCap('round').lineJoin('round');
|
|
497
|
-
doc.strokeColor(
|
|
895
|
+
doc.strokeColor(strokeParsedColor.hex, element.opacity);
|
|
498
896
|
let cumulativeYOffset = 0;
|
|
499
897
|
for (let i = 0; i < textLines.length; i++) {
|
|
500
898
|
const line = textLines[i];
|
|
501
|
-
const
|
|
899
|
+
const markerXOffset = calculateLineXOffset(element, line);
|
|
502
900
|
const lineYOffset = yOffset + cumulativeYOffset;
|
|
503
901
|
const strippedLineText = stripHtml(line.text).result;
|
|
504
902
|
const heightOfLine = line.text === ''
|
|
505
903
|
? lineHeightPx
|
|
506
904
|
: doc.heightOfString(strippedLineText, textOptions);
|
|
507
905
|
cumulativeYOffset += heightOfLine;
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
width
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
906
|
+
const contentStartX = calculateTextContentXOffset(element, line);
|
|
907
|
+
const widthOption = isJustify
|
|
908
|
+
? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
|
|
909
|
+
: undefined;
|
|
910
|
+
if (line.listMeta?.showMarker) {
|
|
911
|
+
await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, strokeParsedColor.hex, element.opacity, 'stroke', textOptions);
|
|
912
|
+
doc.strokeColor(strokeParsedColor.hex, element.opacity);
|
|
913
|
+
}
|
|
914
|
+
doc.text('', contentStartX, lineYOffset, { height: 0, width: 0 });
|
|
915
|
+
const segments = parseHTMLToSegments(line.text, element);
|
|
916
|
+
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
|
|
917
|
+
const segment = segments[segmentIndex];
|
|
918
|
+
const fontKey = await loadFontForSegment(doc, segment, element, fonts);
|
|
919
|
+
doc.font(fontKey);
|
|
920
|
+
doc.fontSize(element.fontSize);
|
|
921
|
+
doc.strokeColor(strokeParsedColor.hex, element.opacity);
|
|
922
|
+
doc.text(segment.text, {
|
|
923
|
+
...textOptions,
|
|
924
|
+
width: widthOption,
|
|
925
|
+
height: heightOfLine,
|
|
926
|
+
continued: segmentIndex !== segments.length - 1,
|
|
927
|
+
stroke: true,
|
|
928
|
+
fill: false,
|
|
929
|
+
underline: segment.underline || textOptions.underline || false,
|
|
930
|
+
lineBreak: !!segment.underline,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
515
933
|
}
|
|
516
934
|
doc.restore();
|
|
517
935
|
}
|
|
@@ -529,15 +947,23 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
|
|
|
529
947
|
let cumulativeYOffset = 0;
|
|
530
948
|
for (let i = 0; i < textLines.length; i++) {
|
|
531
949
|
const line = textLines[i];
|
|
532
|
-
const
|
|
950
|
+
const markerXOffset = calculateLineXOffset(element, line);
|
|
533
951
|
const lineYOffset = yOffset + cumulativeYOffset;
|
|
534
952
|
const strippedLineText = stripHtml(line.text).result;
|
|
535
953
|
const heightOfLine = line.text === ''
|
|
536
954
|
? lineHeightPx
|
|
537
955
|
: doc.heightOfString(strippedLineText, textOptions);
|
|
538
956
|
cumulativeYOffset += heightOfLine;
|
|
957
|
+
const contentStartX = calculateTextContentXOffset(element, line);
|
|
958
|
+
const widthOption = isJustify
|
|
959
|
+
? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
|
|
960
|
+
: undefined;
|
|
961
|
+
if (line.listMeta?.showMarker) {
|
|
962
|
+
await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, baseParsedColor.hex, baseOpacity, 'fill', textOptions);
|
|
963
|
+
doc.fillColor(baseParsedColor.hex, baseOpacity);
|
|
964
|
+
}
|
|
539
965
|
// Position cursor at line start
|
|
540
|
-
doc.text('',
|
|
966
|
+
doc.text('', contentStartX, lineYOffset, { height: 0, width: 0 });
|
|
541
967
|
// Parse line into styled segments
|
|
542
968
|
const segments = parseHTMLToSegments(line.text, element);
|
|
543
969
|
// Render each segment with its own styling
|
|
@@ -559,7 +985,7 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
|
|
|
559
985
|
// Render segment text
|
|
560
986
|
doc.text(segment.text, {
|
|
561
987
|
...textOptions,
|
|
562
|
-
width:
|
|
988
|
+
width: widthOption,
|
|
563
989
|
height: heightOfLine,
|
|
564
990
|
continued: !isLastSegment,
|
|
565
991
|
underline: segment.underline || textOptions.underline || false,
|
|
@@ -595,12 +1021,12 @@ export async function renderText(doc, element, fonts, attrs = {}) {
|
|
|
595
1021
|
// Render text based on stroke and PDF/X-1a requirements
|
|
596
1022
|
if (hasStroke && isPDFX1a) {
|
|
597
1023
|
// PDF/X-1a mode: simulate stroke with offset fills
|
|
598
|
-
renderPDFX1aStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
1024
|
+
await renderPDFX1aStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
599
1025
|
}
|
|
600
1026
|
else {
|
|
601
1027
|
// Standard rendering: stroke first, then fill
|
|
602
1028
|
if (hasStroke) {
|
|
603
|
-
renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
1029
|
+
await renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
604
1030
|
}
|
|
605
1031
|
await renderTextFill(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
606
1032
|
}
|