@polotno/pdf-export 0.1.24 → 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.
Files changed (2) hide show
  1. package/lib/text.js +448 -45
  2. package/package.json +1 -1
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, '&quot;')}"`)
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 paragraphs = element.text.split('\n');
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 <= element.width || element.align === 'justify') {
529
+ if (paragraphWidth <= availableWidth || element.align === 'justify') {
257
530
  // Paragraph fits on one line
258
- lines.push({ text: paragraph, width: paragraphWidth });
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 <= element.width) {
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
- lines.push({ text: result.html, width: currentWidth });
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
- lines.push({ text: result.html, width: currentWidth });
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, lineWidth) {
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 - lineWidth;
638
+ return element.width - targetWidth;
333
639
  }
334
640
  else if (align === 'center') {
335
- return (element.width - lineWidth) / 2;
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 lineXOffset = calculateLineXOffset(element, line.width);
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(line.text, lineXOffset + offset.x, lineYOffset + offset.y, {
469
- ...textOptions,
470
- width: isJustify ? element.width : undefined,
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
- // Render fill layer on top
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(parseColor(element.stroke).hex, element.opacity);
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 lineXOffset = calculateLineXOffset(element, line.width);
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
- doc.text(line.text, lineXOffset, lineYOffset, {
509
- ...textOptions,
510
- width: isJustify ? element.width : undefined,
511
- height: heightOfLine,
512
- stroke: true,
513
- fill: false,
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 lineXOffset = calculateLineXOffset(element, line.width);
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('', lineXOffset, lineYOffset, { height: 0, width: 0 });
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: isJustify ? element.width : undefined,
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polotno/pdf-export",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Convert Polotno JSON into vector PDF",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",