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