@polotno/pdf-export 0.1.23 → 0.1.25
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/image.js +1 -1
- package/lib/text.js +448 -45
- package/package.json +1 -1
package/lib/image.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import Konva from 'konva';
|
|
8
|
-
import { elementFilterToKonva } from './filters';
|
|
8
|
+
import { elementFilterToKonva } from './filters.js';
|
|
9
9
|
async function applyFlip(image, element) {
|
|
10
10
|
const { flipX, flipY } = element;
|
|
11
11
|
if (!flipX && !flipY) {
|
package/lib/text.js
CHANGED
|
@@ -194,6 +194,228 @@ 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 serializeNodes(nodes) {
|
|
281
|
+
return nodes
|
|
282
|
+
.map((node) => {
|
|
283
|
+
if (node.type === 'text') {
|
|
284
|
+
return node.content;
|
|
285
|
+
}
|
|
286
|
+
const attrs = serializeAttributes(node.attributes);
|
|
287
|
+
if (VOID_ELEMENTS.has(node.tagName)) {
|
|
288
|
+
return `<${node.tagName}${attrs}>`;
|
|
289
|
+
}
|
|
290
|
+
return `<${node.tagName}${attrs}>${serializeNodes(node.children)}</${node.tagName}>`;
|
|
291
|
+
})
|
|
292
|
+
.join('');
|
|
293
|
+
}
|
|
294
|
+
function getIndentLevelFromAttributes(attributes) {
|
|
295
|
+
const classAttr = attributes['class'] || attributes['className'] || '';
|
|
296
|
+
const match = classAttr.match(/ql-indent-(\d+)/);
|
|
297
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
298
|
+
}
|
|
299
|
+
function detectIndentLevel(node) {
|
|
300
|
+
const directIndent = getIndentLevelFromAttributes(node.attributes);
|
|
301
|
+
if (directIndent > 0) {
|
|
302
|
+
return directIndent;
|
|
303
|
+
}
|
|
304
|
+
for (const child of node.children) {
|
|
305
|
+
if (child.type === 'element' &&
|
|
306
|
+
child.tagName !== 'ul' &&
|
|
307
|
+
child.tagName !== 'ol') {
|
|
308
|
+
const childIndent = detectIndentLevel(child);
|
|
309
|
+
if (childIndent > 0) {
|
|
310
|
+
return childIndent;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
function nodesToParagraphs(nodes, baseIndentLevel = 0) {
|
|
317
|
+
const paragraphs = [];
|
|
318
|
+
let pendingInline = [];
|
|
319
|
+
const flushInline = () => {
|
|
320
|
+
if (pendingInline.length === 0) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const html = serializeNodes(pendingInline);
|
|
324
|
+
paragraphs.push({ html });
|
|
325
|
+
pendingInline = [];
|
|
326
|
+
};
|
|
327
|
+
for (const node of nodes) {
|
|
328
|
+
if (node.type === 'text') {
|
|
329
|
+
const parts = node.content.split('\n');
|
|
330
|
+
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
|
|
331
|
+
const part = parts[partIndex];
|
|
332
|
+
if (part !== '' &&
|
|
333
|
+
!(pendingInline.length === 0 && /^\s*$/.test(part))) {
|
|
334
|
+
pendingInline.push({
|
|
335
|
+
type: 'text',
|
|
336
|
+
content: part,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (partIndex < parts.length - 1) {
|
|
340
|
+
flushInline();
|
|
341
|
+
if (parts[partIndex + 1] === '') {
|
|
342
|
+
paragraphs.push({ html: '' });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (node.tagName === 'p') {
|
|
349
|
+
flushInline();
|
|
350
|
+
const html = serializeNodes(node.children);
|
|
351
|
+
paragraphs.push({
|
|
352
|
+
html,
|
|
353
|
+
});
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (node.tagName === 'br') {
|
|
357
|
+
flushInline();
|
|
358
|
+
paragraphs.push({ html: '' });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (node.tagName === 'ul' || node.tagName === 'ol') {
|
|
362
|
+
flushInline();
|
|
363
|
+
paragraphs.push(...collectListParagraphs(node, baseIndentLevel));
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
pendingInline.push(node);
|
|
367
|
+
}
|
|
368
|
+
flushInline();
|
|
369
|
+
return paragraphs;
|
|
370
|
+
}
|
|
371
|
+
function collectListParagraphs(listNode, baseIndentLevel) {
|
|
372
|
+
const paragraphs = [];
|
|
373
|
+
const listIndent = baseIndentLevel + getIndentLevelFromAttributes(listNode.attributes);
|
|
374
|
+
let counter = 1;
|
|
375
|
+
for (const child of listNode.children) {
|
|
376
|
+
if (child.type !== 'element' || child.tagName !== 'li') {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const liIndentFromAttribute = getIndentLevelFromAttributes(child.attributes);
|
|
380
|
+
const detectedIndent = detectIndentLevel(child);
|
|
381
|
+
const indentLevel = liIndentFromAttribute > 0
|
|
382
|
+
? listIndent + liIndentFromAttribute
|
|
383
|
+
: listIndent + detectedIndent;
|
|
384
|
+
const itemParagraphs = nodesToParagraphs(child.children, indentLevel);
|
|
385
|
+
let markerAssigned = false;
|
|
386
|
+
for (const paragraph of itemParagraphs) {
|
|
387
|
+
if (!paragraph.listMeta) {
|
|
388
|
+
paragraph.listMeta = {
|
|
389
|
+
type: listNode.tagName,
|
|
390
|
+
index: counter,
|
|
391
|
+
indentLevel,
|
|
392
|
+
displayMarker: !markerAssigned,
|
|
393
|
+
};
|
|
394
|
+
markerAssigned = true;
|
|
395
|
+
}
|
|
396
|
+
paragraphs.push(paragraph);
|
|
397
|
+
}
|
|
398
|
+
if (!markerAssigned) {
|
|
399
|
+
paragraphs.push({
|
|
400
|
+
html: '',
|
|
401
|
+
listMeta: {
|
|
402
|
+
type: listNode.tagName,
|
|
403
|
+
index: counter,
|
|
404
|
+
indentLevel,
|
|
405
|
+
displayMarker: true,
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
if (listNode.tagName === 'ol') {
|
|
410
|
+
counter += 1;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return paragraphs;
|
|
414
|
+
}
|
|
415
|
+
function parseHtmlToParagraphs(html) {
|
|
416
|
+
const root = parseSimpleHTML(html);
|
|
417
|
+
return nodesToParagraphs(root.children);
|
|
418
|
+
}
|
|
197
419
|
/**
|
|
198
420
|
* Reconstruct HTML from tokens while maintaining proper tag nesting across line breaks
|
|
199
421
|
* @param tokens - Array of parsed HTML tokens
|
|
@@ -240,22 +462,79 @@ function tokensToHTML(tokens, openTags) {
|
|
|
240
462
|
* Split text into lines that fit within the element width while preserving HTML formatting
|
|
241
463
|
* Handles word wrapping and ensures HTML tags are properly opened/closed across line breaks
|
|
242
464
|
*/
|
|
465
|
+
function cloneListMetaForLine(meta, showMarker) {
|
|
466
|
+
if (!meta) {
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
...meta,
|
|
471
|
+
showMarker,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function createListLineMeta(doc, element, props, paragraphMeta) {
|
|
475
|
+
const indentPx = paragraphMeta.indentLevel * element.fontSize * 0.5;
|
|
476
|
+
const markerText = paragraphMeta.type === 'ul' ? '•' : `${paragraphMeta.index.toString()}.`;
|
|
477
|
+
const previousFontSize = doc._fontSize !== undefined
|
|
478
|
+
? doc._fontSize
|
|
479
|
+
: element.fontSize;
|
|
480
|
+
const markerFontSize = paragraphMeta.type === 'ul' ? element.fontSize * 1.2 : element.fontSize;
|
|
481
|
+
doc.fontSize(markerFontSize);
|
|
482
|
+
const markerLabelWidth = doc.widthOfString(markerText, {
|
|
483
|
+
...props,
|
|
484
|
+
width: undefined,
|
|
485
|
+
});
|
|
486
|
+
doc.fontSize(previousFontSize);
|
|
487
|
+
const markerGapPx = element.fontSize * (paragraphMeta.type === 'ul' ? 1.5 : 0.8);
|
|
488
|
+
const markerBoxMinPx = element.fontSize * (paragraphMeta.type === 'ul' ? 2.5 : 2.8);
|
|
489
|
+
const markerBoxWidth = Math.max(markerLabelWidth + markerGapPx, markerBoxMinPx);
|
|
490
|
+
const textStartPx = indentPx + markerBoxWidth;
|
|
491
|
+
return {
|
|
492
|
+
type: paragraphMeta.type,
|
|
493
|
+
markerText,
|
|
494
|
+
indentPx,
|
|
495
|
+
markerBoxWidth,
|
|
496
|
+
markerFontSize,
|
|
497
|
+
markerAlignment: paragraphMeta.type === 'ul' ? 'center' : 'right',
|
|
498
|
+
showMarker: paragraphMeta.displayMarker,
|
|
499
|
+
textStartPx,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
243
502
|
function splitTextIntoLines(doc, element, props) {
|
|
244
503
|
const lines = [];
|
|
245
|
-
const
|
|
504
|
+
const rawText = typeof element.text === 'string'
|
|
505
|
+
? element.text
|
|
506
|
+
: String(element.text ?? '');
|
|
507
|
+
const paragraphs = parseHtmlToParagraphs(rawText);
|
|
508
|
+
if (paragraphs.length === 0) {
|
|
509
|
+
paragraphs.push({ html: '' });
|
|
510
|
+
}
|
|
246
511
|
for (const paragraph of paragraphs) {
|
|
247
512
|
// Tokenize the paragraph
|
|
248
|
-
const tokens = tokenizeHTML(paragraph);
|
|
513
|
+
const tokens = tokenizeHTML(paragraph.html);
|
|
249
514
|
// Extract plain text for width calculation
|
|
250
515
|
const plainText = tokens
|
|
251
516
|
.filter((t) => t.type === 'text')
|
|
252
517
|
.map((t) => t.content)
|
|
253
518
|
.join('');
|
|
519
|
+
const baseMeta = paragraph.listMeta
|
|
520
|
+
? createListLineMeta(doc, element, props, paragraph.listMeta)
|
|
521
|
+
: undefined;
|
|
522
|
+
const availableWidthRaw = element.width - (baseMeta ? baseMeta.textStartPx : 0);
|
|
523
|
+
const availableWidth = element.align === 'justify'
|
|
524
|
+
? Math.max(availableWidthRaw, 1)
|
|
525
|
+
: Math.max(availableWidthRaw, element.width * 0.1, 1);
|
|
254
526
|
const paragraphWidth = doc.widthOfString(plainText, props);
|
|
527
|
+
let showMarkerForLine = baseMeta?.showMarker ?? false;
|
|
255
528
|
// Justify alignment using native pdfkit instruments
|
|
256
|
-
if (paragraphWidth <=
|
|
529
|
+
if (paragraphWidth <= availableWidth || element.align === 'justify') {
|
|
257
530
|
// Paragraph fits on one line
|
|
258
|
-
|
|
531
|
+
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
532
|
+
lines.push({
|
|
533
|
+
text: paragraph.html,
|
|
534
|
+
width: paragraphWidth,
|
|
535
|
+
fullWidth: paragraphWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
536
|
+
listMeta,
|
|
537
|
+
});
|
|
259
538
|
}
|
|
260
539
|
else {
|
|
261
540
|
// Need to split paragraph into multiple lines
|
|
@@ -276,7 +555,7 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
276
555
|
? `${currentLine}${i > 0 ? ' ' : ''}${word}`
|
|
277
556
|
: word;
|
|
278
557
|
const testWidth = doc.widthOfString(testLine, props);
|
|
279
|
-
if (testWidth <=
|
|
558
|
+
if (testWidth <= availableWidth) {
|
|
280
559
|
currentLine = testLine;
|
|
281
560
|
currentWidth = testWidth;
|
|
282
561
|
// Add text token (with space if not first word in token)
|
|
@@ -298,9 +577,16 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
298
577
|
// Line is too long, save current line and start new one
|
|
299
578
|
if (currentLine) {
|
|
300
579
|
const result = tokensToHTML(currentTokens, openTags);
|
|
301
|
-
|
|
580
|
+
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
581
|
+
lines.push({
|
|
582
|
+
text: result.html,
|
|
583
|
+
width: currentWidth,
|
|
584
|
+
fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
585
|
+
listMeta,
|
|
586
|
+
});
|
|
302
587
|
openTags = result.openTags;
|
|
303
588
|
currentTokens = [];
|
|
589
|
+
showMarkerForLine = false;
|
|
304
590
|
}
|
|
305
591
|
currentLine = word;
|
|
306
592
|
currentWidth = doc.widthOfString(word, props);
|
|
@@ -314,7 +600,23 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
314
600
|
// Add the last line
|
|
315
601
|
if (currentLine) {
|
|
316
602
|
const result = tokensToHTML(currentTokens, openTags);
|
|
317
|
-
|
|
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
|
+
});
|
|
610
|
+
}
|
|
611
|
+
else if (currentTokens.length === 0) {
|
|
612
|
+
// Handle case when paragraph becomes empty after wrapping logic
|
|
613
|
+
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
614
|
+
lines.push({
|
|
615
|
+
text: '',
|
|
616
|
+
width: 0,
|
|
617
|
+
fullWidth: listMeta ? listMeta.textStartPx : 0,
|
|
618
|
+
listMeta,
|
|
619
|
+
});
|
|
318
620
|
}
|
|
319
621
|
}
|
|
320
622
|
}
|
|
@@ -326,21 +628,49 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
326
628
|
* @param lineWidth - Width of the current line
|
|
327
629
|
* @returns X offset for positioning the line
|
|
328
630
|
*/
|
|
329
|
-
function calculateLineXOffset(element,
|
|
631
|
+
function calculateLineXOffset(element, line) {
|
|
632
|
+
if (line.listMeta) {
|
|
633
|
+
return 0;
|
|
634
|
+
}
|
|
330
635
|
const align = element.align;
|
|
636
|
+
const targetWidth = line.width;
|
|
331
637
|
if (align === 'right') {
|
|
332
|
-
return element.width -
|
|
638
|
+
return element.width - targetWidth;
|
|
333
639
|
}
|
|
334
640
|
else if (align === 'center') {
|
|
335
|
-
return (element.width -
|
|
641
|
+
return (element.width - targetWidth) / 2;
|
|
336
642
|
}
|
|
337
643
|
else if (align === 'justify') {
|
|
338
|
-
// Justify alignment is handled by PDFKit's align property
|
|
339
644
|
return 0;
|
|
340
645
|
}
|
|
341
|
-
// Default: left alignment
|
|
342
646
|
return 0;
|
|
343
647
|
}
|
|
648
|
+
function calculateTextContentXOffset(element, line) {
|
|
649
|
+
const align = element.align;
|
|
650
|
+
const textWidth = line.width;
|
|
651
|
+
if (!line.listMeta) {
|
|
652
|
+
if (align === 'right') {
|
|
653
|
+
return element.width - textWidth;
|
|
654
|
+
}
|
|
655
|
+
else if (align === 'center') {
|
|
656
|
+
return (element.width - textWidth) / 2;
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
return 0;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const baseStart = line.listMeta.textStartPx;
|
|
663
|
+
const availableWidth = Math.max(element.width - baseStart, 0);
|
|
664
|
+
if (align === 'right') {
|
|
665
|
+
return baseStart + Math.max(availableWidth - textWidth, 0);
|
|
666
|
+
}
|
|
667
|
+
else if (align === 'center') {
|
|
668
|
+
return baseStart + Math.max((availableWidth - textWidth) / 2, 0);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
return baseStart;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
344
674
|
/**
|
|
345
675
|
* Calculate text rendering metrics including line height and baseline offset
|
|
346
676
|
*/
|
|
@@ -441,10 +771,42 @@ function renderTextBackground(doc, element, verticalAlignmentOffset, textOptions
|
|
|
441
771
|
doc.fill();
|
|
442
772
|
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
443
773
|
}
|
|
774
|
+
async function drawListMarker(doc, element, line, lineXOffset, lineYOffset, fonts, color, opacity, mode, textOptions) {
|
|
775
|
+
if (!line.listMeta || !line.listMeta.showMarker) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const markerSegment = {
|
|
779
|
+
text: line.listMeta.markerText,
|
|
780
|
+
};
|
|
781
|
+
const fontKey = await loadFontForSegment(doc, markerSegment, element, fonts);
|
|
782
|
+
const previousFontSize = doc._fontSize !== undefined
|
|
783
|
+
? doc._fontSize
|
|
784
|
+
: element.fontSize;
|
|
785
|
+
doc.font(fontKey);
|
|
786
|
+
doc.fontSize(line.listMeta.markerFontSize);
|
|
787
|
+
if (mode === 'fill') {
|
|
788
|
+
doc.fillColor(color, opacity);
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
doc.strokeColor(color, opacity);
|
|
792
|
+
}
|
|
793
|
+
doc.text(line.listMeta.markerText, lineXOffset + line.listMeta.indentPx, lineYOffset, {
|
|
794
|
+
...textOptions,
|
|
795
|
+
width: line.listMeta.markerBoxWidth,
|
|
796
|
+
align: line.listMeta.markerAlignment,
|
|
797
|
+
continued: false,
|
|
798
|
+
lineBreak: false,
|
|
799
|
+
underline: false,
|
|
800
|
+
characterSpacing: 0,
|
|
801
|
+
stroke: mode === 'stroke',
|
|
802
|
+
fill: mode === 'fill',
|
|
803
|
+
});
|
|
804
|
+
doc.fontSize(previousFontSize);
|
|
805
|
+
}
|
|
444
806
|
/**
|
|
445
807
|
* Render text stroke using PDF/X-1a compatible method (multiple offset fills)
|
|
446
808
|
*/
|
|
447
|
-
function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
|
|
809
|
+
async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
448
810
|
const strokeColor = parseColor(element.stroke).hex;
|
|
449
811
|
const strokeWidth = element.strokeWidth;
|
|
450
812
|
const isJustify = element.align === 'justify';
|
|
@@ -462,56 +824,89 @@ function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, text
|
|
|
462
824
|
doc.fillColor(strokeColor, element.opacity);
|
|
463
825
|
for (let i = 0; i < textLines.length; i++) {
|
|
464
826
|
const line = textLines[i];
|
|
465
|
-
const
|
|
827
|
+
const markerXOffset = calculateLineXOffset(element, line);
|
|
466
828
|
const lineYOffset = yOffset + i * lineHeightPx;
|
|
829
|
+
const contentStartX = calculateTextContentXOffset(element, line);
|
|
830
|
+
const widthOption = isJustify
|
|
831
|
+
? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
|
|
832
|
+
: undefined;
|
|
833
|
+
if (line.listMeta?.showMarker) {
|
|
834
|
+
await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, strokeColor, element.opacity, 'fill', textOptions);
|
|
835
|
+
doc.fillColor(strokeColor, element.opacity);
|
|
836
|
+
}
|
|
467
837
|
for (const offset of offsets) {
|
|
468
|
-
doc.text(
|
|
469
|
-
|
|
470
|
-
width:
|
|
471
|
-
stroke: false,
|
|
838
|
+
doc.text('', contentStartX + offset.x, lineYOffset + offset.y, {
|
|
839
|
+
height: 0,
|
|
840
|
+
width: 0,
|
|
472
841
|
});
|
|
842
|
+
const segments = parseHTMLToSegments(line.text, element);
|
|
843
|
+
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
|
|
844
|
+
const segment = segments[segmentIndex];
|
|
845
|
+
const fontKey = await loadFontForSegment(doc, segment, element, fonts);
|
|
846
|
+
doc.font(fontKey);
|
|
847
|
+
doc.fontSize(element.fontSize);
|
|
848
|
+
doc.text(segment.text, {
|
|
849
|
+
...textOptions,
|
|
850
|
+
width: widthOption,
|
|
851
|
+
stroke: false,
|
|
852
|
+
fill: true,
|
|
853
|
+
continued: segmentIndex !== segments.length - 1,
|
|
854
|
+
underline: segment.underline || textOptions.underline || false,
|
|
855
|
+
lineBreak: !!segment.underline,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
473
858
|
}
|
|
474
859
|
}
|
|
475
860
|
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
|
-
}
|
|
861
|
+
await renderTextFill(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts);
|
|
488
862
|
}
|
|
489
863
|
/**
|
|
490
864
|
* Render text stroke using standard PDF stroke
|
|
491
865
|
*/
|
|
492
|
-
function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
|
|
866
|
+
async function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
493
867
|
const isJustify = element.align === 'justify';
|
|
868
|
+
const strokeParsedColor = parseColor(element.stroke);
|
|
494
869
|
doc.save();
|
|
495
870
|
doc.lineWidth(element.strokeWidth);
|
|
496
871
|
doc.lineCap('round').lineJoin('round');
|
|
497
|
-
doc.strokeColor(
|
|
872
|
+
doc.strokeColor(strokeParsedColor.hex, element.opacity);
|
|
498
873
|
let cumulativeYOffset = 0;
|
|
499
874
|
for (let i = 0; i < textLines.length; i++) {
|
|
500
875
|
const line = textLines[i];
|
|
501
|
-
const
|
|
876
|
+
const markerXOffset = calculateLineXOffset(element, line);
|
|
502
877
|
const lineYOffset = yOffset + cumulativeYOffset;
|
|
503
878
|
const strippedLineText = stripHtml(line.text).result;
|
|
504
879
|
const heightOfLine = line.text === ''
|
|
505
880
|
? lineHeightPx
|
|
506
881
|
: doc.heightOfString(strippedLineText, textOptions);
|
|
507
882
|
cumulativeYOffset += heightOfLine;
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
width
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
883
|
+
const contentStartX = calculateTextContentXOffset(element, line);
|
|
884
|
+
const widthOption = isJustify
|
|
885
|
+
? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
|
|
886
|
+
: undefined;
|
|
887
|
+
if (line.listMeta?.showMarker) {
|
|
888
|
+
await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, strokeParsedColor.hex, element.opacity, 'stroke', textOptions);
|
|
889
|
+
doc.strokeColor(strokeParsedColor.hex, element.opacity);
|
|
890
|
+
}
|
|
891
|
+
doc.text('', contentStartX, lineYOffset, { height: 0, width: 0 });
|
|
892
|
+
const segments = parseHTMLToSegments(line.text, element);
|
|
893
|
+
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
|
|
894
|
+
const segment = segments[segmentIndex];
|
|
895
|
+
const fontKey = await loadFontForSegment(doc, segment, element, fonts);
|
|
896
|
+
doc.font(fontKey);
|
|
897
|
+
doc.fontSize(element.fontSize);
|
|
898
|
+
doc.strokeColor(strokeParsedColor.hex, element.opacity);
|
|
899
|
+
doc.text(segment.text, {
|
|
900
|
+
...textOptions,
|
|
901
|
+
width: widthOption,
|
|
902
|
+
height: heightOfLine,
|
|
903
|
+
continued: segmentIndex !== segments.length - 1,
|
|
904
|
+
stroke: true,
|
|
905
|
+
fill: false,
|
|
906
|
+
underline: segment.underline || textOptions.underline || false,
|
|
907
|
+
lineBreak: !!segment.underline,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
515
910
|
}
|
|
516
911
|
doc.restore();
|
|
517
912
|
}
|
|
@@ -529,15 +924,23 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
|
|
|
529
924
|
let cumulativeYOffset = 0;
|
|
530
925
|
for (let i = 0; i < textLines.length; i++) {
|
|
531
926
|
const line = textLines[i];
|
|
532
|
-
const
|
|
927
|
+
const markerXOffset = calculateLineXOffset(element, line);
|
|
533
928
|
const lineYOffset = yOffset + cumulativeYOffset;
|
|
534
929
|
const strippedLineText = stripHtml(line.text).result;
|
|
535
930
|
const heightOfLine = line.text === ''
|
|
536
931
|
? lineHeightPx
|
|
537
932
|
: doc.heightOfString(strippedLineText, textOptions);
|
|
538
933
|
cumulativeYOffset += heightOfLine;
|
|
934
|
+
const contentStartX = calculateTextContentXOffset(element, line);
|
|
935
|
+
const widthOption = isJustify
|
|
936
|
+
? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
|
|
937
|
+
: undefined;
|
|
938
|
+
if (line.listMeta?.showMarker) {
|
|
939
|
+
await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, baseParsedColor.hex, baseOpacity, 'fill', textOptions);
|
|
940
|
+
doc.fillColor(baseParsedColor.hex, baseOpacity);
|
|
941
|
+
}
|
|
539
942
|
// Position cursor at line start
|
|
540
|
-
doc.text('',
|
|
943
|
+
doc.text('', contentStartX, lineYOffset, { height: 0, width: 0 });
|
|
541
944
|
// Parse line into styled segments
|
|
542
945
|
const segments = parseHTMLToSegments(line.text, element);
|
|
543
946
|
// Render each segment with its own styling
|
|
@@ -559,7 +962,7 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
|
|
|
559
962
|
// Render segment text
|
|
560
963
|
doc.text(segment.text, {
|
|
561
964
|
...textOptions,
|
|
562
|
-
width:
|
|
965
|
+
width: widthOption,
|
|
563
966
|
height: heightOfLine,
|
|
564
967
|
continued: !isLastSegment,
|
|
565
968
|
underline: segment.underline || textOptions.underline || false,
|
|
@@ -595,12 +998,12 @@ export async function renderText(doc, element, fonts, attrs = {}) {
|
|
|
595
998
|
// Render text based on stroke and PDF/X-1a requirements
|
|
596
999
|
if (hasStroke && isPDFX1a) {
|
|
597
1000
|
// PDF/X-1a mode: simulate stroke with offset fills
|
|
598
|
-
renderPDFX1aStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
1001
|
+
await renderPDFX1aStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
599
1002
|
}
|
|
600
1003
|
else {
|
|
601
1004
|
// Standard rendering: stroke first, then fill
|
|
602
1005
|
if (hasStroke) {
|
|
603
|
-
renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
1006
|
+
await renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
604
1007
|
}
|
|
605
1008
|
await renderTextFill(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
606
1009
|
}
|