jspdf-md-renderer 3.5.1 → 4.0.1

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/dist/index.js CHANGED
@@ -50,6 +50,30 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
50
50
  let marked = require("marked");
51
51
  let jspdf_autotable = require("jspdf-autotable");
52
52
  jspdf_autotable = __toESM(jspdf_autotable);
53
+ //#region src/enums/mdTokenType.ts
54
+ let MdTokenType = /* @__PURE__ */ function(MdTokenType) {
55
+ MdTokenType["Heading"] = "heading";
56
+ MdTokenType["Paragraph"] = "paragraph";
57
+ MdTokenType["List"] = "list";
58
+ MdTokenType["ListItem"] = "list_item";
59
+ MdTokenType["Blockquote"] = "blockquote";
60
+ MdTokenType["Code"] = "code";
61
+ MdTokenType["CodeSpan"] = "codespan";
62
+ MdTokenType["Table"] = "table";
63
+ MdTokenType["Html"] = "html";
64
+ MdTokenType["Hr"] = "hr";
65
+ MdTokenType["Image"] = "image";
66
+ MdTokenType["Link"] = "link";
67
+ MdTokenType["Strong"] = "strong";
68
+ MdTokenType["Em"] = "em";
69
+ MdTokenType["TableHeader"] = "table_header";
70
+ MdTokenType["TableCell"] = "table_cell";
71
+ MdTokenType["Raw"] = "raw";
72
+ MdTokenType["Text"] = "text";
73
+ MdTokenType["Br"] = "br";
74
+ return MdTokenType;
75
+ }({});
76
+ //#endregion
53
77
  //#region src/parser/imageExtension.ts
54
78
  /**
55
79
  * Internal hash prefix used to encode image attributes in the URL fragment.
@@ -68,8 +92,12 @@ const ATTR_HASH_PREFIX = "__jmr_";
68
92
  const IMAGE_WITH_ATTRS_REGEX = /(!\[[^\]]*\]\()([^)]+)(\))\s*\{([^}]+)\}/g;
69
93
  /**
70
94
  * Regex to extract individual key=value pairs from the attribute block.
95
+ * Supports:
96
+ * - Bare values: width=200
97
+ * - Quoted values: width="200.5" or width='200'
98
+ * - Decimal values: width=200.5
71
99
  */
72
- const ATTR_PAIR_REGEX = /(\w+)\s*=\s*(\w+)/g;
100
+ const ATTR_PAIR_REGEX = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
73
101
  /** Valid alignment values */
74
102
  const VALID_ALIGNMENTS = [
75
103
  "left",
@@ -96,17 +124,17 @@ const parseRawAttributes = (attrString) => {
96
124
  let match;
97
125
  while ((match = ATTR_PAIR_REGEX.exec(attrString)) !== null) {
98
126
  const key = match[1].toLowerCase();
99
- const value = match[2];
127
+ const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
100
128
  switch (key) {
101
129
  case "width":
102
130
  case "w": {
103
- const num = parseInt(value, 10);
131
+ const num = parseFloat(value);
104
132
  if (!isNaN(num) && num > 0) attrs.width = num;
105
133
  break;
106
134
  }
107
135
  case "height":
108
136
  case "h": {
109
- const num = parseInt(value, 10);
137
+ const num = parseFloat(value);
110
138
  if (!isNaN(num) && num > 0) attrs.height = num;
111
139
  break;
112
140
  }
@@ -160,12 +188,12 @@ const parseImageAttrsFromHref = (href) => {
160
188
  const [key, value] = pair.split("=");
161
189
  switch (key) {
162
190
  case "w": {
163
- const num = parseInt(value, 10);
191
+ const num = parseFloat(value);
164
192
  if (!isNaN(num) && num > 0) attrs.width = num;
165
193
  break;
166
194
  }
167
195
  case "h": {
168
- const num = parseInt(value, 10);
196
+ const num = parseFloat(value);
169
197
  if (!isNaN(num) && num > 0) attrs.height = num;
170
198
  break;
171
199
  }
@@ -240,6 +268,8 @@ const tokenHandlers = {
240
268
  ["list_item"]: (token) => ({
241
269
  type: "list_item",
242
270
  content: token.text,
271
+ task: token.task ?? false,
272
+ checked: token.checked ?? false,
243
273
  items: token.tokens ? convertTokens(token.tokens) : []
244
274
  }),
245
275
  ["code"]: (token) => ({
@@ -306,14 +336,39 @@ const tokenHandlers = {
306
336
  items: token.tokens ? convertTokens(token.tokens) : []
307
337
  }),
308
338
  ["html"]: (token) => {
309
- const rawHtml = String(token.raw ?? token.text ?? "").trim();
310
- if (/^<br\s*\/?>$/i.test(rawHtml)) return {
339
+ const raw = String(token.raw ?? token.text ?? "").trim();
340
+ if (/^<br\s*\/?>$/i.test(raw)) return {
311
341
  type: "br",
312
342
  content: "\n"
313
343
  };
344
+ const inlineTagMap = {
345
+ strong: "strong",
346
+ b: "strong",
347
+ em: "em",
348
+ i: "em"
349
+ };
350
+ const inlineMatch = raw.match(/^<(\w+)[^>]*>(.*?)<\/\1>$/is);
351
+ if (inlineMatch) {
352
+ const tag = inlineMatch[1].toLowerCase();
353
+ const innerText = inlineMatch[2];
354
+ const mappedType = inlineTagMap[tag];
355
+ if (mappedType) return {
356
+ type: mappedType,
357
+ content: innerText
358
+ };
359
+ }
360
+ if (/^<(s|del)[^>]*>(.*?)<\/(s|del)>$/is.test(raw)) return {
361
+ type: "raw",
362
+ content: raw.match(/>([^<]+)</)?.[1] ?? raw
363
+ };
364
+ const strippedText = raw.replace(/<[^>]+>/g, "").trim();
365
+ if (strippedText) return {
366
+ type: "raw",
367
+ content: strippedText
368
+ };
314
369
  return {
315
370
  type: "raw",
316
- content: token.raw ?? token.text ?? ""
371
+ content: ""
317
372
  };
318
373
  },
319
374
  ["br"]: () => ({
@@ -322,14 +377,6 @@ const tokenHandlers = {
322
377
  })
323
378
  };
324
379
  //#endregion
325
- //#region src/utils/doc-helpers.ts
326
- const getCharHight = (doc) => {
327
- return doc.getTextDimensions("H").h;
328
- };
329
- const getCharWidth = (doc) => {
330
- return doc.getTextDimensions("H").w;
331
- };
332
- //#endregion
333
380
  //#region src/utils/handlePageBreak.ts
334
381
  const HandlePageBreaks = (doc, store) => {
335
382
  if (typeof store.options.pageBreakHandler === "function") store.options.pageBreakHandler(doc);
@@ -337,6 +384,51 @@ const HandlePageBreaks = (doc, store) => {
337
384
  store.updateY(store.options.page.topmargin);
338
385
  store.updateX(store.options.page.xpading);
339
386
  };
387
+ /**
388
+ * Returns true if adding `height` to the current Y would exceed the page.
389
+ */
390
+ const willOverflow = (store, height) => {
391
+ return store.Y + height > store.options.page.maxContentHeight;
392
+ };
393
+ /**
394
+ * Checks if we will overflow, and if so, breaks the page first.
395
+ * Returns true if a page break was performed.
396
+ */
397
+ const breakIfOverflow = (doc, store, height) => {
398
+ if (willOverflow(store, height)) {
399
+ HandlePageBreaks(doc, store);
400
+ return true;
401
+ }
402
+ return false;
403
+ };
404
+ /**
405
+ * Ensures there is at least `minHeight` space remaining on the page.
406
+ * If not, breaks to next page.
407
+ */
408
+ const ensureSpace = (doc, store, minHeight) => {
409
+ if (store.options.page.maxContentHeight - store.Y < minHeight) HandlePageBreaks(doc, store);
410
+ };
411
+ //#endregion
412
+ //#region src/utils/doc-helpers.ts
413
+ const getCharHight = (doc) => {
414
+ return doc.getFontSize() / doc.internal.scaleFactor;
415
+ };
416
+ /**
417
+ * Saves the current jsPDF font, size, and text color, executes `fn`,
418
+ * then restores those properties — even if `fn` throws.
419
+ */
420
+ const withSavedDocState = (doc, fn) => {
421
+ const savedFont = doc.getFont();
422
+ const savedSize = doc.getFontSize();
423
+ const savedColor = doc.getTextColor();
424
+ try {
425
+ return fn();
426
+ } finally {
427
+ doc.setFont(savedFont.fontName, savedFont.fontStyle);
428
+ doc.setFontSize(savedSize);
429
+ doc.setTextColor(savedColor);
430
+ }
431
+ };
340
432
  //#endregion
341
433
  //#region src/utils/image-utils.ts
342
434
  /**
@@ -360,6 +452,29 @@ const pxToDocUnit = (px, unit = "mm") => {
360
452
  }
361
453
  };
362
454
  /**
455
+ * Detects the image format from a ParsedElement's data URI and source URL.
456
+ * Returns a format string suitable for jsPDF's addImage (e.g. 'PNG', 'JPEG').
457
+ */
458
+ const detectImageFormat = (element) => {
459
+ if (element.data) {
460
+ if (element.data.startsWith("data:image/png")) return "PNG";
461
+ if (element.data.startsWith("data:image/jpeg") || element.data.startsWith("data:image/jpg")) return "JPEG";
462
+ if (element.data.startsWith("data:image/webp")) return "WEBP";
463
+ if (element.data.startsWith("data:image/gif")) return "GIF";
464
+ }
465
+ if (element.src) {
466
+ const ext = element.src.split("?")[0].split("#")[0].split(".").pop()?.toUpperCase();
467
+ if (ext && [
468
+ "PNG",
469
+ "JPEG",
470
+ "JPG",
471
+ "WEBP",
472
+ "GIF"
473
+ ].includes(ext)) return ext === "JPG" ? "JPEG" : ext;
474
+ }
475
+ return "JPEG";
476
+ };
477
+ /**
363
478
  * Extracts width and height from an SVG data URI if possible.
364
479
  */
365
480
  const extractSvgDimensions = (dataUri) => {
@@ -504,115 +619,135 @@ const prefetchImages = async (elements) => {
504
619
  }
505
620
  };
506
621
  //#endregion
507
- //#region src/utils/justifiedTextRenderer.ts
622
+ //#region src/layout/wordSplitter.ts
508
623
  /**
509
- * JustifiedTextRenderer - Renders mixed inline elements with proper alignment.
510
- *
511
- * Features:
512
- * - Handles bold, italic, codespan, links mixed in paragraph
513
- * - Proper word spacing distribution for justified alignment
514
- * - Supports left, right, center, and justify alignments
515
- * - Page break handling
516
- * - Preserves link clickability
517
- * - Codespan background rendering
624
+ * Maps a ParsedElement type string to a TextStyle.
518
625
  */
519
- var JustifiedTextRenderer = class {
520
- static getCodespanOptions(store) {
521
- const opts = store.options.codespan ?? {};
522
- return {
523
- backgroundColor: opts.backgroundColor ?? "#EEEEEE",
524
- padding: opts.padding ?? .5,
525
- showBackground: opts.showBackground !== false,
526
- fontSizeScale: opts.fontSizeScale ?? .9
527
- };
626
+ const resolveStyle = (type, parentStyle) => {
627
+ switch (type) {
628
+ case "strong": return parentStyle === "italic" ? "bolditalic" : "bold";
629
+ case "em": return parentStyle === "bold" ? "bolditalic" : "italic";
630
+ case "codespan": return "codespan";
631
+ default: return parentStyle ?? "normal";
528
632
  }
529
- /**
530
- * Apply font style to the jsPDF document.
531
- */
532
- static applyStyle(doc, style, store) {
533
- const currentFont = doc.getFont().fontName;
534
- const currentFontSize = doc.getFontSize();
535
- const getBoldFont = () => {
536
- const boldName = store.options.font.bold?.name;
537
- return boldName && boldName !== "" ? boldName : currentFont;
538
- };
539
- const getRegularFont = () => {
540
- const regularName = store.options.font.regular?.name;
541
- return regularName && regularName !== "" ? regularName : currentFont;
542
- };
543
- switch (style) {
544
- case "bold":
545
- doc.setFont(getBoldFont(), store.options.font.bold?.style || "bold");
546
- break;
547
- case "italic":
548
- doc.setFont(getRegularFont(), "italic");
549
- break;
550
- case "bolditalic":
551
- doc.setFont(getBoldFont(), "bolditalic");
552
- break;
553
- case "codespan":
554
- const codeFont = store.options.font.code || {
555
- name: "courier",
556
- style: "normal"
557
- };
558
- doc.setFont(codeFont.name, codeFont.style);
559
- doc.setFontSize(currentFontSize * this.getCodespanOptions(store).fontSizeScale);
560
- break;
561
- default:
562
- doc.setFont(getRegularFont(), doc.getFont().fontStyle);
563
- break;
564
- }
565
- }
566
- /**
567
- * Measure word width with a specific style applied.
568
- * NOTE: jsPDF's getTextWidth() does NOT include charSpace in its calculation,
569
- * so we must manually add it: effectiveWidth = getTextWidth(text) + (text.length * charSpace)
570
- */
571
- static measureWordWidth(doc, text, style, store) {
572
- const savedFont = doc.getFont();
573
- const savedSize = doc.getFontSize();
574
- this.applyStyle(doc, style, store);
575
- const baseWidth = doc.getTextWidth(text);
576
- const charSpace = doc.getCharSpace?.() ?? 0;
577
- const effectiveWidth = baseWidth + text.length * charSpace;
578
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
579
- doc.setFontSize(savedSize);
580
- return effectiveWidth;
633
+ };
634
+ /**
635
+ * Measures the width of `text` rendered with `style`, including jsPDF charSpace.
636
+ */
637
+ const measureStyledWidth = (doc, text, style, store) => {
638
+ const savedFont = doc.getFont();
639
+ const savedSize = doc.getFontSize();
640
+ applyStyleToDoc(doc, style, store);
641
+ const charSpace = doc.getCharSpace?.() ?? 0;
642
+ const width = doc.getTextWidth(text) + text.length * charSpace;
643
+ doc.setFont(savedFont.fontName, savedFont.fontStyle);
644
+ doc.setFontSize(savedSize);
645
+ return width;
646
+ };
647
+ /**
648
+ * Applies a TextStyle to the jsPDF document.
649
+ */
650
+ const applyStyleToDoc = (doc, style, store) => {
651
+ const curFont = doc.getFont().fontName;
652
+ const curSize = doc.getFontSize();
653
+ const boldFont = store.options.font.bold?.name || curFont;
654
+ const regularFont = store.options.font.regular?.name || curFont;
655
+ const italicFont = store.options.font.italic || {
656
+ name: regularFont,
657
+ style: "italic"
658
+ };
659
+ const boldItalicFont = store.options.font.boldItalic || {
660
+ name: italicFont.name,
661
+ style: "bolditalic"
662
+ };
663
+ const codeFont = store.options.font.code || {
664
+ name: "courier",
665
+ style: "normal"
666
+ };
667
+ switch (style) {
668
+ case "bold":
669
+ doc.setFont(boldFont, store.options.font.bold?.style || "bold");
670
+ break;
671
+ case "italic":
672
+ doc.setFont(italicFont.name, italicFont.style);
673
+ break;
674
+ case "bolditalic":
675
+ doc.setFont(boldItalicFont.name, boldItalicFont.style);
676
+ break;
677
+ case "codespan":
678
+ doc.setFont(codeFont.name, codeFont.style);
679
+ doc.setFontSize(curSize * (store.options.codespan?.fontSizeScale ?? .88));
680
+ break;
681
+ default:
682
+ doc.setFont(regularFont, "normal");
683
+ break;
581
684
  }
582
- /**
583
- * Extract style from element type string.
584
- */
585
- static getStyleFromType(type, parentStyle) {
586
- switch (type) {
587
- case "strong":
588
- if (parentStyle === "italic") return "bolditalic";
589
- return "bold";
590
- case "em":
591
- if (parentStyle === "bold") return "bolditalic";
592
- return "italic";
593
- case "codespan": return "codespan";
594
- default: return parentStyle || "normal";
685
+ };
686
+ /**
687
+ * Flattens a ParsedElement tree into a flat array of StyledWordInfo.
688
+ * This is the single source of truth for converting AST → layout words.
689
+ */
690
+ const flattenToWords = (doc, elements, store, parentStyle = "normal", isLink = false, href) => {
691
+ const result = [];
692
+ for (const el of elements) {
693
+ const style = resolveStyle(el.type, parentStyle);
694
+ const elIsLink = el.type === "link" || isLink;
695
+ const elHref = el.href || href;
696
+ if (el.type === "br") {
697
+ result.push({
698
+ text: "",
699
+ width: 0,
700
+ style,
701
+ isBr: true
702
+ });
703
+ continue;
595
704
  }
596
- }
597
- /**
598
- * Flatten ParsedElement tree into an array of StyledWordInfo.
599
- * Handles nested inline elements.
600
- */
601
- static flattenToWords(doc, elements, store, parentStyle = "normal", isLink = false, href) {
602
- const result = [];
603
- for (const el of elements) {
604
- const style = this.getStyleFromType(el.type, parentStyle);
605
- const elIsLink = el.type === "link" || isLink;
606
- const elHref = el.href || href;
607
- if (el.items && el.items.length > 0) {
608
- const nested = this.flattenToWords(doc, el.items, store, style, elIsLink, elHref);
609
- result.push(...nested);
610
- } else if (el.type === "image") {
611
- const maxH = store.options.page.maxContentHeight - store.options.page.topmargin;
612
- const { finalWidth, finalHeight } = calculateImageDimensions(doc, el, store.options.page.maxContentWidth - store.options.page.indent * 0, maxH, store.options.page.unit || "mm");
705
+ if (el.type === "image") {
706
+ const { finalWidth, finalHeight } = calculateImageDimensions(doc, el, store.options.page.maxContentWidth, store.options.page.maxContentHeight - store.options.page.topmargin, store.options.page.unit || "mm");
707
+ result.push({
708
+ text: "",
709
+ width: finalWidth,
710
+ style,
711
+ isLink: elIsLink,
712
+ href: elHref,
713
+ isImage: true,
714
+ imageElement: el,
715
+ imageHeight: finalHeight
716
+ });
717
+ continue;
718
+ }
719
+ if (el.items && el.items.length > 0) {
720
+ result.push(...flattenToWords(doc, el.items, store, style, elIsLink, elHref));
721
+ continue;
722
+ }
723
+ const text = el.content || el.text || "";
724
+ if (!text) continue;
725
+ if (/^\s/.test(text) && result.length > 0) result[result.length - 1].hasTrailingSpace = true;
726
+ if (style === "codespan") {
727
+ const trimmed = text.trim();
728
+ if (trimmed) result.push({
729
+ text: trimmed,
730
+ width: measureStyledWidth(doc, trimmed, style, store),
731
+ style,
732
+ isLink: elIsLink,
733
+ href: elHref,
734
+ linkColor: elIsLink ? store.options.link?.linkColor || [
735
+ 0,
736
+ 0,
737
+ 255
738
+ ] : void 0,
739
+ hasTrailingSpace: /\s$/.test(text)
740
+ });
741
+ continue;
742
+ }
743
+ const lines = text.split("\n");
744
+ for (let li = 0; li < lines.length; li++) {
745
+ const words = lines[li].trim().split(/[ \t\r\v\f]+/).filter(Boolean);
746
+ for (let wi = 0; wi < words.length; wi++) {
747
+ const isLastInLine = wi === words.length - 1;
613
748
  result.push({
614
- text: "",
615
- width: finalWidth,
749
+ text: words[wi],
750
+ width: measureStyledWidth(doc, words[wi], style, store),
616
751
  style,
617
752
  isLink: elIsLink,
618
753
  href: elHref,
@@ -621,338 +756,243 @@ var JustifiedTextRenderer = class {
621
756
  0,
622
757
  255
623
758
  ] : void 0,
624
- isImage: true,
625
- imageElement: el,
626
- imageHeight: finalHeight
759
+ hasTrailingSpace: !isLastInLine || /[ \t]$/.test(lines[li])
627
760
  });
628
- } else if (el.type === "br") result.push({
761
+ }
762
+ if (li < lines.length - 1) result.push({
629
763
  text: "",
630
764
  width: 0,
631
765
  style,
632
766
  isBr: true
633
767
  });
634
- else {
635
- const text = el.content || el.text || "";
636
- if (!text) continue;
637
- if (/^\s/.test(text) && result.length > 0) result[result.length - 1].hasTrailingSpace = true;
638
- if (style === "codespan") {
639
- const trimmedText = text.trim();
640
- if (trimmedText) result.push({
641
- text: trimmedText,
642
- width: this.measureWordWidth(doc, trimmedText, style, store),
643
- style,
644
- isLink: elIsLink,
645
- href: elHref,
646
- linkColor: elIsLink ? store.options.link?.linkColor || [
647
- 0,
648
- 0,
649
- 255
650
- ] : void 0,
651
- hasTrailingSpace: /\s$/.test(text)
652
- });
653
- continue;
654
- }
655
- const lines = text.split("\n");
656
- for (let partIndex = 0; partIndex < lines.length; partIndex++) {
657
- const lineStr = lines[partIndex];
658
- const words = lineStr.trim().split(/[ \t\r\v\f]+/).filter((w) => w.length > 0);
659
- for (let i = 0; i < words.length; i++) {
660
- const hasTrailingSpace = !(i === words.length - 1) || /[ \t\r\v\f]$/.test(lineStr);
661
- result.push({
662
- text: words[i],
663
- width: this.measureWordWidth(doc, words[i], style, store),
664
- style,
665
- isLink: elIsLink,
666
- href: elHref,
667
- linkColor: elIsLink ? store.options.link?.linkColor || [
668
- 0,
669
- 0,
670
- 255
671
- ] : void 0,
672
- hasTrailingSpace
673
- });
674
- }
675
- if (partIndex < lines.length - 1) result.push({
676
- text: "",
677
- width: 0,
678
- style,
679
- isBr: true
680
- });
681
- }
682
- }
683
768
  }
684
- return result;
685
769
  }
686
- /**
687
- * Break a flat list of words into lines that fit within maxWidth.
688
- * Correctly tracks totalTextWidth (sum of word widths only) for justification.
689
- */
690
- static breakIntoLines(doc, words, maxWidth, store) {
691
- const lines = [];
692
- let currentLine = [];
693
- let currentTextWidth = 0;
694
- let currentLineWidth = 0;
695
- let currentLineHeight = getCharHight(doc) * store.options.page.defaultLineHeightFactor;
696
- const spaceWidth = doc.getTextWidth(" ");
697
- for (let i = 0; i < words.length; i++) {
698
- const word = words[i];
699
- const neededWidthWithSpace = currentLine[currentLine.length - 1]?.hasTrailingSpace ? spaceWidth + word.width : word.width;
700
- const itemHeight = word.isImage && word.imageHeight ? word.imageHeight : getCharHight(doc) * store.options.page.defaultLineHeightFactor;
701
- if (word.isBr) {
702
- lines.push({
703
- words: currentLine,
704
- totalTextWidth: currentTextWidth,
705
- isLastLine: true,
706
- lineHeight: currentLineHeight
707
- });
708
- currentLine = [];
709
- currentTextWidth = 0;
710
- currentLineWidth = 0;
711
- currentLineHeight = getCharHight(doc) * store.options.page.defaultLineHeightFactor;
712
- continue;
713
- }
714
- if (currentLineWidth + neededWidthWithSpace > maxWidth && currentLine.length > 0) {
715
- lines.push({
716
- words: currentLine,
717
- totalTextWidth: currentTextWidth,
718
- isLastLine: false,
719
- lineHeight: currentLineHeight
720
- });
721
- currentLine = [word];
722
- currentTextWidth = word.width;
723
- currentLineWidth = word.width;
724
- currentLineHeight = itemHeight;
725
- } else {
726
- currentLine.push(word);
727
- currentTextWidth += word.width;
728
- currentLineWidth += neededWidthWithSpace;
729
- currentLineHeight = Math.max(currentLineHeight, itemHeight);
730
- }
770
+ return result;
771
+ };
772
+ //#endregion
773
+ //#region src/layout/lineBreaker.ts
774
+ const splitOversizedWord = (doc, word, maxWidth, store) => {
775
+ if (!word.text || word.width <= maxWidth || word.isImage) return [word];
776
+ const chunks = [];
777
+ let remaining = word.text;
778
+ while (remaining.length > 0) {
779
+ let chunk = "";
780
+ for (const ch of remaining) {
781
+ const candidate = chunk + ch;
782
+ const candidateWidth = measureStyledWidth(doc, candidate, word.style, store);
783
+ if (candidateWidth > maxWidth && chunk.length > 0) break;
784
+ chunk = candidate;
785
+ if (candidateWidth >= maxWidth) break;
731
786
  }
787
+ if (!chunk) chunk = remaining.charAt(0);
788
+ chunks.push({
789
+ ...word,
790
+ text: chunk,
791
+ width: measureStyledWidth(doc, chunk, word.style, store),
792
+ hasTrailingSpace: false
793
+ });
794
+ remaining = remaining.slice(chunk.length);
795
+ }
796
+ if (chunks.length > 0) chunks[chunks.length - 1].hasTrailingSpace = word.hasTrailingSpace;
797
+ return chunks;
798
+ };
799
+ const breakIntoLines = (doc, words, maxWidth, store) => {
800
+ const normalizedWords = [];
801
+ for (const word of words) normalizedWords.push(...splitOversizedWord(doc, word, maxWidth, store));
802
+ const lines = [];
803
+ let currentLine = [];
804
+ let currentTextWidth = 0;
805
+ let currentLineWidth = 0;
806
+ const baseLineHeight = getCharHight(doc) * store.options.page.defaultLineHeightFactor;
807
+ let currentLineHeight = baseLineHeight;
808
+ const spaceWidth = doc.getTextWidth(" ");
809
+ const pushLine = (isLast) => {
732
810
  if (currentLine.length > 0) lines.push({
733
811
  words: currentLine,
734
812
  totalTextWidth: currentTextWidth,
735
- isLastLine: true,
813
+ isLastLine: isLast,
736
814
  lineHeight: currentLineHeight
737
815
  });
738
- return lines;
816
+ };
817
+ for (const word of normalizedWords) {
818
+ if (word.isBr) {
819
+ pushLine(true);
820
+ currentLine = [];
821
+ currentTextWidth = 0;
822
+ currentLineWidth = 0;
823
+ currentLineHeight = baseLineHeight;
824
+ continue;
825
+ }
826
+ const neededWidth = (currentLine[currentLine.length - 1]?.hasTrailingSpace ? spaceWidth : 0) + word.width;
827
+ const itemHeight = word.isImage && word.imageHeight ? word.imageHeight : baseLineHeight;
828
+ if (currentLineWidth + neededWidth > maxWidth && currentLine.length > 0) {
829
+ pushLine(false);
830
+ currentLine = [word];
831
+ currentTextWidth = word.width;
832
+ currentLineWidth = word.width;
833
+ currentLineHeight = itemHeight;
834
+ } else {
835
+ currentLine.push(word);
836
+ currentTextWidth += word.width;
837
+ currentLineWidth += neededWidth;
838
+ currentLineHeight = Math.max(currentLineHeight, itemHeight);
839
+ }
739
840
  }
740
- /**
741
- * Render a single word with its style applied.
742
- */
743
- static renderWord(doc, word, x, y, store) {
744
- const savedFont = doc.getFont();
745
- const savedSize = doc.getFontSize();
746
- const savedColor = doc.getTextColor();
747
- this.applyStyle(doc, word.style, store);
841
+ pushLine(true);
842
+ return lines;
843
+ };
844
+ //#endregion
845
+ //#region src/layout/lineRenderer.ts
846
+ const renderLine = (doc, line, x, y, maxWidth, store, alignment = "left") => {
847
+ const { words, totalTextWidth, isLastLine } = line;
848
+ if (words.length === 0) return;
849
+ const normalSpaceWidth = doc.getTextWidth(" ");
850
+ let lineWidthWithSpaces = totalTextWidth;
851
+ let expandableSpaces = 0;
852
+ for (let i = 0; i < words.length - 1; i++) if (words[i].hasTrailingSpace) {
853
+ lineWidthWithSpaces += normalSpaceWidth;
854
+ expandableSpaces++;
855
+ }
856
+ let startX = x;
857
+ let wordSpacing = normalSpaceWidth;
858
+ switch (alignment) {
859
+ case "right":
860
+ startX = x + maxWidth - lineWidthWithSpaces;
861
+ break;
862
+ case "center":
863
+ startX = x + (maxWidth - lineWidthWithSpaces) / 2;
864
+ break;
865
+ case "justify":
866
+ if (!isLastLine && expandableSpaces > 0) wordSpacing = (maxWidth - totalTextWidth) / expandableSpaces;
867
+ break;
868
+ }
869
+ const textLineHeight = getCharHight(doc) * store.options.page.defaultLineHeightFactor;
870
+ let currentX = startX;
871
+ for (let i = 0; i < words.length; i++) {
872
+ const word = words[i];
873
+ const elementHeight = word.isImage && word.imageHeight ? word.imageHeight : textLineHeight;
874
+ const drawY = word.isImage ? y : y + (line.lineHeight - elementHeight);
875
+ renderSingleWord(doc, word, currentX, drawY, store);
876
+ currentX += word.width;
877
+ if (i < words.length - 1 && word.hasTrailingSpace) currentX += wordSpacing;
878
+ }
879
+ };
880
+ const renderSingleWord = (doc, word, x, y, store) => {
881
+ withSavedDocState(doc, () => {
882
+ applyStyleToDoc(doc, word.style, store);
748
883
  if (word.isLink && word.linkColor) doc.setTextColor(...word.linkColor);
749
- if (word.isImage && word.imageElement && word.imageElement.data) try {
750
- let imgFormat = "JPEG";
751
- if (word.imageElement.data.startsWith("data:image/png")) imgFormat = "PNG";
752
- else if (word.imageElement.data.startsWith("data:image/webp")) imgFormat = "WEBP";
753
- else if (word.imageElement.data.startsWith("data:image/gif")) imgFormat = "GIF";
754
- else if (word.imageElement.src) {
755
- const ext = word.imageElement.src.split("?")[0].split("#")[0].split(".").pop()?.toUpperCase();
756
- if (ext && [
757
- "PNG",
758
- "JPEG",
759
- "JPG",
760
- "WEBP",
761
- "GIF"
762
- ].includes(ext)) imgFormat = ext === "JPG" ? "JPEG" : ext;
763
- }
764
- if (word.width > 0 && (word.imageHeight || 0) > 0) {
765
- const imgH = word.imageHeight || 0;
766
- const imgY = y;
767
- doc.addImage(word.imageElement.data, imgFormat, x, imgY, word.width, imgH);
768
- }
769
- } catch (e) {
770
- console.warn("Failed to render inline image", e);
771
- }
884
+ if (word.isImage && word.imageElement?.data) renderInlineImage(doc, word, x, y);
772
885
  else {
773
- if (word.style === "codespan") {
774
- const codespanOpts = this.getCodespanOptions(store);
775
- if (codespanOpts.showBackground) {
776
- const h = getCharHight(doc);
777
- const pad = codespanOpts.padding;
778
- doc.setFillColor(codespanOpts.backgroundColor);
779
- doc.rect(x - pad, y - pad, word.width + pad * 2, h + pad * 2, "F");
780
- doc.setFillColor("#000000");
781
- }
782
- }
886
+ if (word.style === "codespan") renderCodespanBackground(doc, word, x, y, store);
783
887
  doc.text(word.text, x, y, { baseline: "top" });
784
888
  }
785
889
  if (word.isLink && word.href) {
786
- const h = word.isImage && word.imageHeight ? word.imageHeight : getCharHight(doc);
890
+ const h = word.isImage && word.imageHeight ? word.imageHeight : doc.getTextDimensions("H").h;
787
891
  doc.link(x, y, word.width, h, { url: word.href });
788
892
  }
789
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
790
- doc.setFontSize(savedSize);
791
- doc.setTextColor(savedColor);
792
- }
793
- /**
794
- * Render a single line with specified alignment.
795
- */
796
- static renderAlignedLine(doc, line, x, y, maxWidth, store, alignment = "left") {
797
- const { words, totalTextWidth, isLastLine } = line;
798
- if (words.length === 0) return;
799
- const normalSpaceWidth = doc.getTextWidth(" ");
800
- let startX = x;
801
- let wordSpacing = normalSpaceWidth;
802
- let lineWidthWithNormalSpaces = totalTextWidth;
803
- let expandableSpacesCount = 0;
804
- for (let i = 0; i < words.length - 1; i++) if (words[i].hasTrailingSpace) {
805
- lineWidthWithNormalSpaces += normalSpaceWidth;
806
- expandableSpacesCount++;
807
- }
808
- switch (alignment) {
809
- case "right":
810
- startX = x + maxWidth - lineWidthWithNormalSpaces;
811
- break;
812
- case "center":
813
- startX = x + (maxWidth - lineWidthWithNormalSpaces) / 2;
814
- break;
815
- case "justify":
816
- if (!isLastLine && expandableSpacesCount > 0) wordSpacing = (maxWidth - totalTextWidth) / expandableSpacesCount;
817
- break;
818
- default: break;
819
- }
820
- let currentX = startX;
821
- const textHeight = getCharHight(doc) * store.options.page.defaultLineHeightFactor;
822
- for (let i = 0; i < words.length; i++) {
823
- const word = words[i];
824
- let drawY = y;
825
- const elementHeight = word.isImage && word.imageHeight ? word.imageHeight : textHeight;
826
- if (word.isImage) drawY = y;
827
- else if (elementHeight < line.lineHeight) drawY = y + (line.lineHeight - elementHeight);
828
- this.renderWord(doc, word, currentX, drawY, store);
829
- currentX += word.width;
830
- if (i < words.length - 1 && word.hasTrailingSpace) currentX += wordSpacing;
831
- }
832
- }
833
- /**
834
- * Main entry point: Render a paragraph with mixed inline elements.
835
- * Respects user's textAlignment option from store.
836
- *
837
- * @param doc jsPDF instance
838
- * @param elements Array of ParsedElement (inline items in a paragraph)
839
- * @param x Starting X coordinate
840
- * @param y Starting Y coordinate
841
- * @param maxWidth Maximum width for text wrapping
842
- * @param store RenderStore instance to use
843
- * @param alignment Optional alignment override (defaults to store option)
844
- */
845
- static renderStyledParagraph(doc, elements, x, y, maxWidth, store, alignment) {
846
- const textAlignment = alignment ?? store.options.content?.textAlignment ?? "left";
847
- const words = this.flattenToWords(doc, elements, store);
848
- if (words.length === 0) return;
849
- const lines = this.breakIntoLines(doc, words, maxWidth, store);
850
- let currentY = y;
851
- for (const line of lines) {
852
- if (currentY + line.lineHeight > store.options.page.maxContentHeight) {
853
- HandlePageBreaks(doc, store);
854
- currentY = store.Y;
855
- }
856
- this.renderAlignedLine(doc, line, x, currentY, maxWidth, store, textAlignment);
857
- store.recordContentY(currentY + line.lineHeight);
858
- currentY += line.lineHeight;
859
- store.updateY(line.lineHeight, "add");
860
- }
861
- const lastLine = lines[lines.length - 1];
862
- if (lastLine) {
863
- let actualSpacesCount = 0;
864
- for (let i = 0; i < lastLine.words.length - 1; i++) if (lastLine.words[i].hasTrailingSpace) actualSpacesCount++;
865
- const lastLineWidth = lastLine.totalTextWidth + actualSpacesCount * doc.getTextWidth(" ");
866
- store.updateX(x + lastLineWidth, "set");
893
+ });
894
+ };
895
+ const renderCodespanBackground = (doc, word, x, y, store) => {
896
+ const opts = store.options.codespan ?? {};
897
+ if (opts.showBackground === false) return;
898
+ const bg = opts.backgroundColor ?? "#EEEEEE";
899
+ const pad = opts.padding ?? .8;
900
+ const h = doc.getTextDimensions("H").h;
901
+ doc.setFillColor(bg);
902
+ doc.rect(x - pad, y - pad, word.width + pad * 2, h + pad * 2, "F");
903
+ doc.setFillColor("#000000");
904
+ };
905
+ const renderInlineImage = (doc, word, x, y) => {
906
+ const el = word.imageElement;
907
+ const fmt = detectImageFormat(el);
908
+ if (word.width > 0 && (word.imageHeight || 0) > 0) doc.addImage(el.data, fmt, x, y, word.width, word.imageHeight);
909
+ };
910
+ //#endregion
911
+ //#region src/layout/layoutEngine.ts
912
+ /**
913
+ * THE single entry point for rendering any mixed inline content.
914
+ * All paragraph, heading, list item, and blockquote text must go through here.
915
+ *
916
+ * Handles: word splitting, line breaking, page breaks, styled rendering.
917
+ * Returns the final Y position after rendering.
918
+ */
919
+ const renderInlineContent = (doc, elements, x, y, maxWidth, store, opts = {}) => {
920
+ const alignment = opts.alignment ?? store.options.content?.textAlignment ?? "left";
921
+ const trimLastLine = opts.trimLastLine ?? false;
922
+ const words = flattenToWords(doc, elements, store);
923
+ if (words.length === 0) return y;
924
+ const lines = breakIntoLines(doc, words, maxWidth, store);
925
+ let currentY = y;
926
+ for (let i = 0; i < lines.length; i++) {
927
+ const line = lines[i];
928
+ if (currentY + line.lineHeight > store.options.page.maxContentHeight) {
929
+ HandlePageBreaks(doc, store);
930
+ currentY = store.Y;
867
931
  }
932
+ renderLine(doc, line, x, currentY, maxWidth, store, alignment);
933
+ const yAdvance = i === lines.length - 1 && trimLastLine && !line.words.some((w) => w.isImage && w.imageHeight) ? getCharHight(doc) : line.lineHeight;
934
+ store.recordContentY(currentY + yAdvance);
935
+ currentY += yAdvance;
936
+ store.updateY(yAdvance, "add");
868
937
  }
869
- /**
870
- * @deprecated Use renderStyledParagraph instead
871
- */
872
- static renderJustifiedParagraph(doc, elements, x, y, maxWidth, store) {
873
- this.renderStyledParagraph(doc, elements, x, y, maxWidth, store);
938
+ const lastLine = lines[lines.length - 1];
939
+ if (lastLine) {
940
+ let spaceCount = 0;
941
+ for (let i = 0; i < lastLine.words.length - 1; i++) if (lastLine.words[i].hasTrailingSpace) spaceCount++;
942
+ const lastLineW = lastLine.totalTextWidth + spaceCount * doc.getTextWidth(" ");
943
+ store.updateX(x + lastLineW, "set");
874
944
  }
945
+ return currentY;
875
946
  };
876
- //#endregion
877
- //#region src/renderer/components/heading.ts
878
947
  /**
879
- * Renders heading elements.
948
+ * Render a single string of plain (unstyled) text with wrapping and page breaks.
949
+ * Use this for simple text in list items and raw items where there are no inline styles.
880
950
  */
881
- const renderHeading = (doc, element, indent, store, parentElementRenderer) => {
882
- const size = 6 - (element?.depth ?? 0) > 0 ? 6 - (element?.depth ?? 0) : 1;
883
- doc.setFontSize(store.options.page.defaultFontSize + size);
884
- if (element?.items && element?.items.length > 0) {
885
- const originalLineHeightFactor = store.options.page.defaultLineHeightFactor;
886
- store.options.page.defaultLineHeightFactor = 1;
887
- JustifiedTextRenderer.renderStyledParagraph(doc, element.items, store.X + indent, store.Y, store.options.page.maxContentWidth - indent, store, "left");
888
- store.options.page.defaultLineHeightFactor = originalLineHeightFactor;
889
- } else {
890
- const charHeight = getCharHight(doc);
891
- doc.text(element?.content ?? "", store.X + indent, store.Y, {
892
- align: "left",
893
- maxWidth: store.options.page.maxContentWidth - indent,
894
- baseline: "top"
895
- });
896
- store.recordContentY(store.Y + charHeight);
897
- store.updateY(getCharHight(doc), "add");
898
- }
899
- doc.setFontSize(store.options.page.defaultFontSize);
900
- store.updateX(store.options.page.xpading, "set");
951
+ const renderPlainText = (doc, text, x, y, maxWidth, store, opts = {}) => {
952
+ return renderInlineContent(doc, [{
953
+ type: "text",
954
+ content: text
955
+ }], x, y, maxWidth, store, opts);
901
956
  };
902
957
  //#endregion
903
- //#region src/utils/text-renderer.ts
904
- var TextRenderer = class {
905
- /**
906
- * Renders text with automatic line wrapping and page breaking.
907
- * @param doc jsPDF instance
908
- * @param text Text to render
909
- * @param store RenderStore instance to use
910
- * @param x X coordinate (if not provided, uses store.X)
911
- * @param y Y coordinate (if not provided, uses store.Y)
912
- * @param maxWidth Max width for text wrapping
913
- * @param justify Whether to justify the text
914
- */
915
- static renderText(doc, text, store, x = store.X, y = store.Y, maxWidth, justify = false) {
916
- const lines = doc.splitTextToSize(text, maxWidth);
917
- const charHeight = getCharHight(doc);
918
- const lineHeight = charHeight * store.options.page.defaultLineHeightFactor;
919
- let currentY = y;
920
- for (let i = 0; i < lines.length; i++) {
921
- const line = lines[i];
922
- if (currentY + lineHeight > store.options.page.maxContentHeight) {
923
- HandlePageBreaks(doc, store);
924
- currentY = store.Y;
925
- }
926
- if (justify) if (i === lines.length - 1) doc.text(line, x, currentY, { baseline: "top" });
927
- else doc.text(line, x, currentY, {
928
- maxWidth,
929
- align: "justify",
930
- baseline: "top"
931
- });
932
- else doc.text(line, x, currentY, { baseline: "top" });
933
- store.recordContentY(currentY + charHeight);
934
- currentY += lineHeight;
935
- store.updateY(lineHeight, "add");
936
- }
937
- return currentY;
938
- }
958
+ //#region src/renderer/components/heading.ts
959
+ const renderHeading = (doc, element, indent, store) => {
960
+ withSavedDocState(doc, () => {
961
+ const headingKey = `h${element?.depth ?? 1}`;
962
+ const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultFontSize;
963
+ const headingColor = store.options.heading?.[`${headingKey}Color`] ?? store.options.heading?.color ?? "#000000";
964
+ doc.setFontSize(fontSize);
965
+ doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
966
+ doc.setTextColor(headingColor);
967
+ breakIfOverflow(doc, store, getCharHight(doc) * 1.8);
968
+ const maxWidth = store.options.page.maxContentWidth - indent;
969
+ if (element.items && element.items.length > 0) renderInlineContent(doc, element.items, store.X + indent, store.Y, maxWidth, store, {
970
+ alignment: "left",
971
+ trimLastLine: true
972
+ });
973
+ else renderPlainText(doc, element?.content ?? "", store.X + indent, store.Y, maxWidth, store, {
974
+ alignment: "left",
975
+ trimLastLine: true
976
+ });
977
+ const bottomSpacing = store.options.heading?.bottomSpacing ?? store.options.spacing?.afterHeading ?? 2;
978
+ store.updateY(bottomSpacing, "add");
979
+ });
980
+ store.updateX(store.options.page.xpading, "set");
939
981
  };
940
982
  //#endregion
941
983
  //#region src/renderer/components/paragraph.ts
942
- /**
943
- * Renders paragraph elements with proper text alignment.
944
- * Handles mixed inline styles (bold, italic, codespan) and links.
945
- * Respects user's textAlignment option from RenderStore.
946
- */
947
984
  const renderParagraph = (doc, element, indent, store, parentElementRenderer) => {
948
- store.activateInlineLock();
985
+ const indentLevel = indent / store.options.page.indent;
986
+ const savedColor = doc.getTextColor();
949
987
  doc.setFontSize(store.options.page.defaultFontSize);
988
+ doc.setFont(store.options.font.regular.name, store.options.font.regular.style);
989
+ doc.setTextColor(store.options.paragraph?.color ?? "#000000");
950
990
  const maxWidth = store.options.page.maxContentWidth - indent;
951
- if (element?.items && element?.items.length > 0) {
991
+ if (element.items && element.items.length > 0) {
952
992
  if (element.items.length === 1 && element.items[0].type === "image") {
953
- parentElementRenderer(element.items[0], indent, store, false);
993
+ parentElementRenderer(element.items[0], indentLevel, store, false);
954
994
  store.updateX(store.options.page.xpading);
955
- store.deactivateInlineLock();
995
+ doc.setTextColor(savedColor);
956
996
  return;
957
997
  }
958
998
  const inlineTypes = [
@@ -966,35 +1006,38 @@ const renderParagraph = (doc, element, indent, store, parentElementRenderer) =>
966
1006
  ];
967
1007
  if (element.items.some((item) => !inlineTypes.includes(item.type))) {
968
1008
  const inlineBuffer = [];
969
- const flushInlineBuffer = () => {
1009
+ const flush = () => {
970
1010
  if (inlineBuffer.length > 0) {
971
- JustifiedTextRenderer.renderStyledParagraph(doc, inlineBuffer, store.X + indent, store.Y, maxWidth, store);
1011
+ renderInlineContent(doc, inlineBuffer, store.X + indent, store.Y, maxWidth, store, { trimLastLine: true });
972
1012
  inlineBuffer.length = 0;
973
1013
  }
974
1014
  };
975
1015
  for (const item of element.items) if (inlineTypes.includes(item.type)) inlineBuffer.push(item);
976
1016
  else {
977
- flushInlineBuffer();
978
- parentElementRenderer(item, indent, store, false);
1017
+ flush();
1018
+ parentElementRenderer(item, indentLevel, store, false);
979
1019
  }
980
- flushInlineBuffer();
981
- } else JustifiedTextRenderer.renderStyledParagraph(doc, element.items, store.X + indent, store.Y, maxWidth, store);
982
- } else {
983
- const content = element.content ?? "";
984
- const textAlignment = store.options.content?.textAlignment ?? "left";
985
- if (content.trim()) TextRenderer.renderText(doc, content, store, store.X + indent, store.Y, maxWidth, textAlignment === "justify");
986
- }
1020
+ flush();
1021
+ } else renderInlineContent(doc, element.items, store.X + indent, store.Y, maxWidth, store, { trimLastLine: true });
1022
+ } else if (element.content?.trim()) renderInlineContent(doc, [{
1023
+ type: "text",
1024
+ content: element.content
1025
+ }], store.X + indent, store.Y, maxWidth, store, { trimLastLine: true });
1026
+ const bottomSpacing = store.options.paragraph?.bottomSpacing ?? store.options.spacing?.afterParagraph ?? store.options.page.lineSpace;
1027
+ store.updateY(bottomSpacing, "add");
987
1028
  store.updateX(store.options.page.xpading);
988
- store.deactivateInlineLock();
1029
+ doc.setTextColor(savedColor);
989
1030
  };
990
1031
  //#endregion
991
1032
  //#region src/renderer/components/list.ts
992
1033
  const renderList = (doc, element, indentLevel, store, parentElementRenderer) => {
993
1034
  doc.setFontSize(store.options.page.defaultFontSize);
994
1035
  for (const [i, point] of element?.items?.entries() ?? []) {
995
- const _start = element.ordered ? (element.start ?? 0) + i : element.start;
1036
+ const _start = element.ordered ? (element.start ?? 1) + i : void 0;
996
1037
  parentElementRenderer(point, indentLevel + 1, store, true, _start, element.ordered);
1038
+ if (i < (element.items?.length ?? 0) - 1) store.updateY(store.options.spacing?.betweenListItems ?? 0, "add");
997
1039
  }
1040
+ store.updateY(store.options.spacing?.afterList ?? 3, "add");
998
1041
  };
999
1042
  //#endregion
1000
1043
  //#region src/renderer/components/listItem.ts
@@ -1002,22 +1045,46 @@ const renderList = (doc, element, indentLevel, store, parentElementRenderer) =>
1002
1045
  * Render a single list item, including bullets/numbering, inline text, and any nested lists.
1003
1046
  */
1004
1047
  const renderListItem = (doc, element, indentLevel, store, parentElementRenderer, start, ordered) => {
1005
- if (store.Y + getCharHight(doc) >= store.options.page.maxContentHeight) HandlePageBreaks(doc, store);
1048
+ breakIfOverflow(doc, store, getCharHight(doc));
1006
1049
  const options = store.options;
1007
- const baseIndent = indentLevel * options.page.indent;
1008
- const bullet = ordered ? `${start}. ` : "• ";
1050
+ const listOpts = store.options.list ?? {};
1051
+ const baseIndent = indentLevel * (listOpts.indentSize ?? options.page.indent);
1052
+ const bullet = ordered ? `${start}. ` : listOpts.bulletChar ?? "• ";
1009
1053
  const xLeft = options.page.xpading;
1010
1054
  store.updateX(xLeft, "set");
1011
1055
  doc.setFont(options.font.regular.name, options.font.regular.style);
1012
- doc.text(bullet, xLeft + baseIndent, store.Y, { baseline: "top" });
1013
- const bulletWidth = doc.getTextWidth(bullet);
1014
- const contentX = xLeft + baseIndent + bulletWidth;
1056
+ let contentX = xLeft + baseIndent;
1057
+ let bulletWidth;
1058
+ if (element.task) {
1059
+ const cbSize = doc.getFontSize() * .5;
1060
+ const cbX = xLeft + baseIndent;
1061
+ const cbY = store.Y + (getCharHight(doc) - cbSize) / 2;
1062
+ doc.setDrawColor("#555555");
1063
+ doc.setLineWidth(.4);
1064
+ doc.rect(cbX, cbY, cbSize, cbSize);
1065
+ if (element.checked) {
1066
+ doc.setDrawColor("#2B6CB0");
1067
+ doc.setLineWidth(.5);
1068
+ const pad = cbSize * .2;
1069
+ doc.line(cbX + pad, cbY + cbSize * .55, cbX + cbSize * .4, cbY + cbSize - pad);
1070
+ doc.line(cbX + cbSize * .4, cbY + cbSize - pad, cbX + cbSize - pad, cbY + pad);
1071
+ doc.setDrawColor("#000000");
1072
+ }
1073
+ doc.setLineWidth(.1);
1074
+ bulletWidth = cbSize + 2;
1075
+ } else {
1076
+ doc.text(bullet, xLeft + baseIndent, store.Y, { baseline: "top" });
1077
+ bulletWidth = doc.getTextWidth(bullet);
1078
+ }
1079
+ contentX += bulletWidth;
1015
1080
  const textMaxWidth = options.page.maxContentWidth - baseIndent - bulletWidth;
1081
+ const originalTextColor = doc.getTextColor();
1082
+ if (element.checked) doc.setTextColor(150, 150, 150);
1016
1083
  if (element.items && element.items.length > 0) {
1017
1084
  const inlineBuffer = [];
1018
1085
  const flushInlineBuffer = () => {
1019
1086
  if (inlineBuffer.length > 0) {
1020
- JustifiedTextRenderer.renderStyledParagraph(doc, inlineBuffer, contentX, store.Y, textMaxWidth, store);
1087
+ renderInlineContent(doc, inlineBuffer, contentX, store.Y, textMaxWidth, store);
1021
1088
  inlineBuffer.length = 0;
1022
1089
  store.updateX(xLeft, "set");
1023
1090
  }
@@ -1030,10 +1097,8 @@ const renderListItem = (doc, element, indentLevel, store, parentElementRenderer,
1030
1097
  parentElementRenderer(subItem, indentLevel, store, true, start, ordered);
1031
1098
  } else inlineBuffer.push(subItem);
1032
1099
  flushInlineBuffer();
1033
- } else if (element.content) {
1034
- const textAlignment = options.content?.textAlignment ?? "left";
1035
- TextRenderer.renderText(doc, element.content, store, contentX, store.Y, textMaxWidth, textAlignment === "justify");
1036
- }
1100
+ } else if (element.content) renderPlainText(doc, element.content, contentX, store.Y, textMaxWidth, store);
1101
+ doc.setTextColor(originalTextColor);
1037
1102
  };
1038
1103
  //#endregion
1039
1104
  //#region src/renderer/components/rawItem.ts
@@ -1042,19 +1107,18 @@ const renderRawItem = (doc, element, indentLevel, store, hasRawBullet, parentEle
1042
1107
  else {
1043
1108
  const options = store.options;
1044
1109
  const indent = indentLevel * options.page.indent;
1045
- const bullet = hasRawBullet ? ordered ? `${start}. ` : "• " : "";
1110
+ const listOpts = store.options.list ?? {};
1111
+ const bullet = hasRawBullet ? ordered ? `${start}. ` : listOpts.bulletChar ?? "• " : "";
1046
1112
  const content = element.content || "";
1047
1113
  const xLeft = options.page.xpading;
1048
1114
  if (!content && !bullet) return;
1049
1115
  if (!content.trim() && !bullet) {
1050
1116
  const newlines = (content.match(/\n/g) || []).length;
1051
1117
  if (newlines > 1) {
1052
- const addedHeight = (newlines - 1) * (doc.getTextDimensions("A").h * options.page.defaultLineHeightFactor);
1053
- if (store.Y + addedHeight > options.page.maxContentHeight) HandlePageBreaks(doc, store);
1054
- else {
1055
- store.updateY(addedHeight, "add");
1056
- store.recordContentY(store.Y);
1057
- }
1118
+ const addedHeight = (newlines - 1) * (getCharHight(doc) * options.page.defaultLineHeightFactor);
1119
+ breakIfOverflow(doc, store, addedHeight);
1120
+ store.updateY(addedHeight, "add");
1121
+ store.recordContentY(store.Y);
1058
1122
  }
1059
1123
  return;
1060
1124
  }
@@ -1064,10 +1128,10 @@ const renderRawItem = (doc, element, indentLevel, store, hasRawBullet, parentEle
1064
1128
  const textMaxWidth = options.page.maxContentWidth - indent - bulletWidth;
1065
1129
  doc.setFont(options.font.regular.name, options.font.regular.style);
1066
1130
  doc.text(bullet, xLeft + indent, store.Y, { baseline: "top" });
1067
- TextRenderer.renderText(doc, content, store, xLeft + indent + bulletWidth, store.Y, textMaxWidth, justify);
1131
+ renderPlainText(doc, content, xLeft + indent + bulletWidth, store.Y, textMaxWidth, store, { alignment: justify ? "justify" : "left" });
1068
1132
  } else {
1069
1133
  const textMaxWidth = options.page.maxContentWidth - indent;
1070
- TextRenderer.renderText(doc, content, store, xLeft + indent, store.Y, textMaxWidth, justify);
1134
+ renderPlainText(doc, content, xLeft + indent, store.Y, textMaxWidth, store, { alignment: justify ? "justify" : "left" });
1071
1135
  }
1072
1136
  store.updateX(xLeft, "set");
1073
1137
  }
@@ -1075,6 +1139,9 @@ const renderRawItem = (doc, element, indentLevel, store, hasRawBullet, parentEle
1075
1139
  //#endregion
1076
1140
  //#region src/renderer/components/hr.ts
1077
1141
  const renderHR = (doc, store) => {
1142
+ const savedDrawColor = doc.getDrawColor();
1143
+ const savedLineWidth = doc.getLineWidth();
1144
+ breakIfOverflow(doc, store, getCharHight(doc));
1078
1145
  const pageWidth = doc.internal.pageSize.getWidth();
1079
1146
  doc.setLineDashPattern([1, 1], 0);
1080
1147
  doc.setLineWidth(.1);
@@ -1082,21 +1149,30 @@ const renderHR = (doc, store) => {
1082
1149
  doc.setLineWidth(.1);
1083
1150
  doc.setLineDashPattern([], 0);
1084
1151
  store.updateY(getCharHight(doc), "add");
1152
+ store.updateY(store.options.spacing?.afterHR ?? 2, "add");
1153
+ doc.setDrawColor(savedDrawColor);
1154
+ doc.setLineWidth(savedLineWidth);
1085
1155
  };
1086
1156
  //#endregion
1087
1157
  //#region src/renderer/components/code.ts
1088
1158
  const renderCodeBlock = (doc, element, indentLevel, store) => {
1089
1159
  const savedFont = doc.getFont();
1090
1160
  const savedFontSize = doc.getFontSize();
1161
+ const savedTextColor = doc.getTextColor();
1162
+ const savedDrawColor = doc.getDrawColor();
1163
+ const savedFillColor = doc.getFillColor();
1091
1164
  const codeFont = store.options.font.code || {
1092
1165
  name: "courier",
1093
1166
  style: "normal"
1094
1167
  };
1095
1168
  doc.setFont(codeFont.name, codeFont.style);
1096
- const codeFontSize = store.options.page.defaultFontSize * .9;
1169
+ const codeOpts = store.options.codeBlock ?? {};
1170
+ const codeFontSizeScale = codeOpts.fontSizeScale ?? .9;
1171
+ const codeFontSize = store.options.page.defaultFontSize * codeFontSizeScale;
1097
1172
  doc.setFontSize(codeFontSize);
1098
1173
  const indent = indentLevel * store.options.page.indent;
1099
- const maxWidth = store.options.page.maxContentWidth - indent - 8;
1174
+ const padding = codeOpts.padding ?? 4;
1175
+ const maxWidth = store.options.page.maxContentWidth - indent - padding * 2;
1100
1176
  const lineHeightFactor = doc.getLineHeightFactor();
1101
1177
  const lineHeight = codeFontSize / doc.internal.scaleFactor * lineHeightFactor;
1102
1178
  const content = (element.code ?? "").replace(/[\r\n\s]+$/, "");
@@ -1112,9 +1188,9 @@ const renderCodeBlock = (doc, element, indentLevel, store) => {
1112
1188
  doc.setFontSize(savedFontSize);
1113
1189
  return;
1114
1190
  }
1115
- const padding = 4;
1116
- const bgColor = "#EEEEEE";
1117
- const drawColor = "#DDDDDD";
1191
+ const bgColor = codeOpts.backgroundColor ?? "#F6F8FA";
1192
+ const drawColor = codeOpts.borderColor ?? "#E1E4E8";
1193
+ const radius = codeOpts.borderRadius ?? 2;
1118
1194
  let currentLineIndex = 0;
1119
1195
  while (currentLineIndex < lines.length) {
1120
1196
  const availableHeight = store.options.page.maxContentHeight - store.Y;
@@ -1133,20 +1209,22 @@ const renderCodeBlock = (doc, element, indentLevel, store) => {
1133
1209
  if (isFirstChunk) store.updateY(padding, "add");
1134
1210
  doc.setFillColor(bgColor);
1135
1211
  doc.setDrawColor(drawColor);
1136
- doc.roundedRect(store.X, store.Y - padding, store.options.page.maxContentWidth, textBlockHeight + (isFirstChunk ? padding : 0) + (isLastChunk ? padding : 0), 2, 2, "FD");
1137
- if (isFirstChunk && element.lang) {
1212
+ doc.roundedRect(store.X, store.Y - padding, store.options.page.maxContentWidth, textBlockHeight + (isFirstChunk ? padding : 0) + (isLastChunk ? padding : 0), radius, radius, "FD");
1213
+ if (isFirstChunk && element.lang && codeOpts.showLanguageLabel !== false) {
1138
1214
  const savedCodeFontSize = doc.getFontSize();
1139
1215
  doc.setFontSize(10);
1140
- doc.setTextColor("#666666");
1216
+ doc.setTextColor(codeOpts.labelColor ?? "#666666");
1141
1217
  doc.text(element.lang, store.X + store.options.page.maxContentWidth - doc.getTextWidth(element.lang) - 4, store.Y, { baseline: "top" });
1142
1218
  doc.setFontSize(savedCodeFontSize);
1143
- doc.setTextColor("#000000");
1219
+ doc.setTextColor(savedTextColor);
1144
1220
  }
1145
1221
  let yPos = store.Y;
1222
+ doc.setTextColor(codeOpts.textColor ?? "#000000");
1146
1223
  for (const line of linesToRender) {
1147
1224
  doc.text(line, store.X + 4, yPos, { baseline: "top" });
1148
1225
  yPos += lineHeight;
1149
1226
  }
1227
+ doc.setTextColor(savedTextColor);
1150
1228
  store.updateY(textBlockHeight, "add");
1151
1229
  store.recordContentY(store.Y + (isLastChunk ? padding : 0));
1152
1230
  if (isLastChunk) store.updateY(padding, "add");
@@ -1155,199 +1233,17 @@ const renderCodeBlock = (doc, element, indentLevel, store) => {
1155
1233
  }
1156
1234
  doc.setFont(savedFont.fontName, savedFont.fontStyle);
1157
1235
  doc.setFontSize(savedFontSize);
1158
- };
1159
- //#endregion
1160
- //#region src/renderer/components/inlineText.ts
1161
- /**
1162
- * Renders inline text elements (Strong, Em, and Text) with proper inline styling.
1163
- */
1164
- const renderInlineText = (doc, element, indent, store) => {
1165
- const currentFont = doc.getFont().fontName;
1166
- const currentFontStyle = doc.getFont().fontStyle;
1167
- const currentFontSize = doc.getFontSize();
1168
- const spaceMultiplier = (style) => {
1169
- switch (style) {
1170
- case "normal": return 0;
1171
- case "bold": return 1;
1172
- case "italic": return 1.5;
1173
- case "bolditalic": return 1.5;
1174
- case "codespan": return .5;
1175
- default: return 0;
1176
- }
1177
- };
1178
- const renderTextWithStyle = (text, style) => {
1179
- if (style === "bold") doc.setFont(store.options.font.bold.name && store.options.font.bold.name !== "" ? store.options.font.bold.name : currentFont, store.options.font.bold.style || "bold");
1180
- else if (style === "italic") doc.setFont(store.options.font.regular.name, "italic");
1181
- else if (style === "bolditalic") doc.setFont(store.options.font.bold.name && store.options.font.bold.name !== "" ? store.options.font.bold.name : currentFont, "bolditalic");
1182
- else if (style === "codespan") {
1183
- const codeFont = store.options.font.code || {
1184
- name: "courier",
1185
- style: "normal"
1186
- };
1187
- doc.setFont(codeFont.name, codeFont.style);
1188
- doc.setFontSize(currentFontSize * .9);
1189
- } else doc.setFont(store.options.font.regular.name, currentFontStyle);
1190
- const availableWidth = store.options.page.maxContentWidth - indent - store.X;
1191
- const textLines = doc.splitTextToSize(text, availableWidth);
1192
- const isCodeSpan = style === "codespan";
1193
- const codePadding = 1;
1194
- const codeBgColor = "#EEEEEE";
1195
- if (store.isInlineLockActive) for (let i = 0; i < textLines.length; i++) {
1196
- if (isCodeSpan) {
1197
- const lineWidth = doc.getTextWidth(textLines[i]) + getCharWidth(doc);
1198
- const lineHeight = getCharHight(doc);
1199
- doc.setFillColor(codeBgColor);
1200
- doc.roundedRect(store.X + indent - codePadding, store.Y - codePadding, lineWidth + codePadding * 2, lineHeight + codePadding * 2, 2, 2, "F");
1201
- doc.setFillColor("#000000");
1202
- }
1203
- doc.text(textLines[i], store.X + indent, store.Y, {
1204
- baseline: "top",
1205
- maxWidth: availableWidth
1206
- });
1207
- store.updateX(doc.getTextDimensions(textLines[i]).w + (isCodeSpan ? codePadding * 2 : 1), "add");
1208
- if (i < textLines.length - 1) {
1209
- store.updateY(getCharHight(doc), "add");
1210
- store.updateX(store.options.page.xpading, "set");
1211
- }
1212
- }
1213
- else if (textLines.length > 1) {
1214
- const firstLine = textLines[0];
1215
- const restContent = textLines?.slice(1)?.join(" ");
1216
- if (isCodeSpan) {
1217
- const w = doc.getTextWidth(firstLine) + getCharWidth(doc);
1218
- const h = getCharHight(doc);
1219
- doc.setFillColor(codeBgColor);
1220
- doc.roundedRect(store.X + (indent >= 2 ? indent + 2 : 0) - codePadding, store.Y - codePadding, w + codePadding * 2, h + codePadding * 2, 2, 2, "F");
1221
- doc.setFillColor("#000000");
1222
- }
1223
- doc.text(firstLine, store.X + (indent >= 2 ? indent + 2 * spaceMultiplier(style) : 0), store.Y, {
1224
- baseline: "top",
1225
- maxWidth: availableWidth
1226
- });
1227
- store.updateX(store.options.page.xpading + indent);
1228
- store.updateY(getCharHight(doc), "add");
1229
- const maxWidthForRest = store.options.page.maxContentWidth - indent - store.options.page.xpading;
1230
- doc.splitTextToSize(restContent, maxWidthForRest).forEach((line) => {
1231
- if (isCodeSpan) {
1232
- const w = doc.getTextWidth(line) + getCharWidth(doc);
1233
- const h = getCharHight(doc);
1234
- doc.setFillColor(codeBgColor);
1235
- doc.roundedRect(store.X + getCharWidth(doc) - codePadding, store.Y - codePadding, w + codePadding * 2, h + codePadding * 2, 2, 2, "F");
1236
- doc.setFillColor("#000000");
1237
- }
1238
- doc.text(line, store.X + getCharWidth(doc), store.Y, {
1239
- baseline: "top",
1240
- maxWidth: maxWidthForRest
1241
- });
1242
- });
1243
- } else {
1244
- if (isCodeSpan) {
1245
- const w = doc.getTextWidth(text) + getCharWidth(doc);
1246
- const h = getCharHight(doc);
1247
- doc.setFillColor(codeBgColor);
1248
- doc.roundedRect(store.X + indent - codePadding, store.Y - codePadding, w + codePadding * 2, h + codePadding * 2, 2, 2, "F");
1249
- doc.setFillColor("#000000");
1250
- }
1251
- doc.text(text, store.X + indent, store.Y, {
1252
- baseline: "top",
1253
- maxWidth: availableWidth
1254
- });
1255
- store.updateX(doc.getTextDimensions(text).w + (indent >= 2 ? text.split(" ").length + 2 : 2) * spaceMultiplier(style) * .5 + (isCodeSpan ? codePadding * 2 : 0), "add");
1256
- }
1257
- };
1258
- if (element.type === "text" && element.items && element.items.length > 0) for (const item of element.items) if (item.type === "codespan") renderTextWithStyle(item.content || "", "codespan");
1259
- else if (item.type === "em" || item.type === "strong") {
1260
- const baseStyle = item.type === "em" ? "italic" : "bold";
1261
- if (item.items && item.items.length > 0) for (const subItem of item.items) if (subItem.type === "strong" && baseStyle === "italic") renderTextWithStyle(subItem.content || "", "bolditalic");
1262
- else if (subItem.type === "em" && baseStyle === "bold") renderTextWithStyle(subItem.content || "", "bolditalic");
1263
- else renderTextWithStyle(subItem.content || "", baseStyle);
1264
- else renderTextWithStyle(item.content || "", baseStyle);
1265
- } else renderTextWithStyle(item.content || "", "normal");
1266
- else if (element.type === "em") renderTextWithStyle(element.content || "", "italic");
1267
- else if (element.type === "strong") renderTextWithStyle(element.content || "", "bold");
1268
- else if (element.type === "codespan") renderTextWithStyle(element.content || "", "codespan");
1269
- else renderTextWithStyle(element.content || "", "normal");
1270
- doc.setFont(currentFont, currentFontStyle);
1271
- doc.setFontSize(currentFontSize);
1272
- };
1273
- //#endregion
1274
- //#region src/renderer/components/link.ts
1275
- /**
1276
- * Renders link elements with proper styling and URL handling.
1277
- * Links are rendered in blue color and underlined to distinguish them from regular text.
1278
- */
1279
- const renderLink = (doc, element, indent, store) => {
1280
- const currentFont = doc.getFont().fontName;
1281
- const currentFontStyle = doc.getFont().fontStyle;
1282
- const currentFontSize = doc.getFontSize();
1283
- const currentTextColor = doc.getTextColor();
1284
- const linkColor = store.options.link?.linkColor || [
1285
- 0,
1286
- 0,
1287
- 255
1288
- ];
1289
- doc.setTextColor(...linkColor);
1290
- const availableWidth = store.options.page.maxContentWidth - indent - store.X;
1291
- const linkText = element.text || element.content || "";
1292
- const linkUrl = element.href || "";
1293
- const textLines = doc.splitTextToSize(linkText, availableWidth);
1294
- if (store.isInlineLockActive) for (let i = 0; i < textLines.length; i++) {
1295
- const textWidth = doc.getTextDimensions(textLines[i]).w;
1296
- const textHeight = getCharHight(doc) / 2;
1297
- doc.link(store.X + indent, store.Y, textWidth, textHeight, { url: linkUrl });
1298
- doc.text(textLines[i], store.X + indent, store.Y, {
1299
- baseline: "top",
1300
- maxWidth: availableWidth
1301
- });
1302
- store.updateX(textWidth + 1, "add");
1303
- if (store.X + textWidth > store.options.page.maxContentWidth - indent) {
1304
- store.updateY(textHeight, "add");
1305
- store.updateX(store.options.page.xpading + indent, "set");
1306
- }
1307
- if (i < textLines.length - 1) {
1308
- store.updateY(textHeight, "add");
1309
- store.updateX(store.options.page.xpading + indent, "set");
1310
- }
1311
- }
1312
- else if (textLines.length > 1) {
1313
- const firstLine = textLines[0];
1314
- const restContent = textLines?.slice(1)?.join(" ");
1315
- const firstLineWidth = doc.getTextDimensions(firstLine).w;
1316
- const textHeight = getCharHight(doc) / 2;
1317
- doc.link(store.X + indent, store.Y, firstLineWidth, textHeight, { url: linkUrl });
1318
- doc.text(firstLine, store.X + indent, store.Y, {
1319
- baseline: "top",
1320
- maxWidth: availableWidth
1321
- });
1322
- store.updateX(store.options.page.xpading + indent);
1323
- store.updateY(textHeight, "add");
1324
- const maxWidthForRest = store.options.page.maxContentWidth - indent - store.options.page.xpading;
1325
- doc.splitTextToSize(restContent, maxWidthForRest).forEach((line) => {
1326
- const lineWidth = doc.getTextDimensions(line).w;
1327
- doc.link(store.X + getCharWidth(doc), store.Y, lineWidth, textHeight, { url: linkUrl });
1328
- doc.text(line, store.X + getCharWidth(doc), store.Y, {
1329
- baseline: "top",
1330
- maxWidth: maxWidthForRest
1331
- });
1332
- });
1333
- } else {
1334
- const textWidth = doc.getTextDimensions(linkText).w;
1335
- const textHeight = getCharHight(doc) / 2;
1336
- doc.link(store.X + indent, store.Y, textWidth, textHeight, { url: linkUrl });
1337
- doc.text(linkText, store.X + indent, store.Y, {
1338
- baseline: "top",
1339
- maxWidth: availableWidth
1340
- });
1341
- store.updateX(textWidth + 2, "add");
1342
- }
1343
- doc.setFont(currentFont, currentFontStyle);
1344
- doc.setFontSize(currentFontSize);
1345
- doc.setTextColor(currentTextColor);
1236
+ doc.setTextColor(savedTextColor);
1237
+ doc.setDrawColor(savedDrawColor);
1238
+ doc.setFillColor(savedFillColor);
1239
+ store.updateY(store.options.spacing?.afterCodeBlock ?? 3, "add");
1346
1240
  };
1347
1241
  //#endregion
1348
1242
  //#region src/renderer/components/blockquote.ts
1349
1243
  const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1350
1244
  const options = store.options;
1245
+ const savedDrawColor = doc.getDrawColor();
1246
+ const savedLineWidth = doc.getLineWidth();
1351
1247
  const blockquoteIndent = indentLevel + 1;
1352
1248
  const currentX = store.X + indentLevel * options.page.indent;
1353
1249
  const currentY = store.Y;
@@ -1357,10 +1253,13 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1357
1253
  if (element.items && element.items.length > 0) element.items.forEach((item) => {
1358
1254
  renderElement(item, blockquoteIndent, store);
1359
1255
  });
1360
- const endY = store.Y;
1256
+ const endY = store.lastContentY || store.Y;
1361
1257
  const endPage = doc.internal.getCurrentPageInfo().pageNumber;
1362
- doc.setDrawColor(100);
1363
- doc.setLineWidth(1);
1258
+ const bqOpts = store.options.blockquote ?? {};
1259
+ const barColor = bqOpts.barColor ?? "#AAAAAA";
1260
+ const barWidth = bqOpts.barWidth ?? 1;
1261
+ doc.setDrawColor(barColor);
1262
+ doc.setLineWidth(barWidth);
1364
1263
  for (let p = startPage; p <= endPage; p++) {
1365
1264
  doc.setPage(p);
1366
1265
  const isStart = p === startPage;
@@ -1371,33 +1270,14 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1371
1270
  }
1372
1271
  store.recordContentY();
1373
1272
  doc.setPage(endPage);
1273
+ const bqBottomSpacing = bqOpts.bottomSpacing ?? options.spacing?.afterBlockquote ?? options.page.lineSpace;
1274
+ store.updateY(bqBottomSpacing, "add");
1275
+ doc.setDrawColor(savedDrawColor);
1276
+ doc.setLineWidth(savedLineWidth);
1374
1277
  };
1375
1278
  //#endregion
1376
1279
  //#region src/renderer/components/image.ts
1377
1280
  /**
1378
- * Detects the image format from element data and source.
1379
- */
1380
- const detectImageFormat = (element) => {
1381
- if (element.data) {
1382
- if (element.data.startsWith("data:image/png")) return "PNG";
1383
- if (element.data.startsWith("data:image/jpeg") || element.data.startsWith("data:image/jpg")) return "JPEG";
1384
- if (element.data.startsWith("data:image/webp")) return "WEBP";
1385
- if (element.data.startsWith("data:image/webp")) return "WEBP";
1386
- if (element.data.startsWith("data:image/gif")) return "GIF";
1387
- }
1388
- if (element.src) {
1389
- const ext = element.src.split("?")[0].split("#")[0].split(".").pop()?.toUpperCase();
1390
- if (ext && [
1391
- "PNG",
1392
- "JPEG",
1393
- "JPG",
1394
- "WEBP",
1395
- "GIF"
1396
- ].includes(ext)) return ext === "JPG" ? "JPEG" : ext;
1397
- }
1398
- return "JPEG";
1399
- };
1400
- /**
1401
1281
  * Renders an image element into the jsPDF document with smart sizing and alignment.
1402
1282
  *
1403
1283
  * Sizing logic (in order of priority):
@@ -1440,9 +1320,10 @@ const renderImage = (doc, element, indentLevel, store) => {
1440
1320
  const imgFormat = detectImageFormat(element);
1441
1321
  if (finalWidth > 0 && finalHeight > 0) doc.addImage(element.data, imgFormat, drawX, currentY, finalWidth, finalHeight);
1442
1322
  store.updateY(finalHeight, "add");
1323
+ store.updateY(store.options.spacing?.afterImage ?? 2, "add");
1443
1324
  store.recordContentY();
1444
1325
  } catch (e) {
1445
- console.warn("Failed to render image", e);
1326
+ console.warn("[jspdf-md-renderer] Failed to render image", e);
1446
1327
  }
1447
1328
  };
1448
1329
  //#endregion
@@ -1455,34 +1336,56 @@ const resolveAutoTable = () => {
1455
1336
  throw new Error("Could not resolve jspdf-autotable export. Expected a callable export.");
1456
1337
  };
1457
1338
  const renderTable = (doc, element, indentLevel, store) => {
1458
- if (!element.header || !element.rows) return;
1339
+ if (!element.header || element.header.length === 0) {
1340
+ console.warn("[jspdf-md-renderer] Table skipped: no header row");
1341
+ return;
1342
+ }
1459
1343
  const options = store.options;
1460
1344
  const marginLeft = options.page.xmargin + indentLevel * options.page.indent;
1345
+ ensureSpace(doc, store, 20);
1346
+ const columnCount = element.header.length;
1347
+ const rows = (element.rows ?? []).map((row) => {
1348
+ const normalized = [...row];
1349
+ while (normalized.length < columnCount) normalized.push({
1350
+ type: "table_cell",
1351
+ content: ""
1352
+ });
1353
+ return normalized.slice(0, columnCount).map((cell) => cell.content || "");
1354
+ });
1461
1355
  const head = [element.header.map((h) => h.content || "")];
1462
- const body = element.rows.map((row) => row.map((cell) => cell.content || ""));
1463
1356
  const userTableOptions = options.table || {};
1357
+ const safeDidDrawPage = (data) => {
1358
+ try {
1359
+ if (userTableOptions.didDrawPage) userTableOptions.didDrawPage(data);
1360
+ } catch (e) {
1361
+ console.warn("[jspdf-md-renderer] table.didDrawPage callback threw:", e);
1362
+ }
1363
+ };
1364
+ const safeDidDrawCell = (data) => {
1365
+ try {
1366
+ if (userTableOptions.didDrawCell) userTableOptions.didDrawCell(data);
1367
+ } catch (e) {
1368
+ console.warn("[jspdf-md-renderer] table.didDrawCell callback threw:", e);
1369
+ }
1370
+ };
1464
1371
  resolveAutoTable()(doc, {
1465
1372
  head,
1466
- body,
1373
+ body: rows,
1467
1374
  startY: store.Y,
1468
1375
  margin: {
1469
1376
  left: marginLeft,
1470
1377
  right: options.page.xmargin
1471
1378
  },
1472
1379
  ...userTableOptions,
1473
- didDrawPage: (data) => {
1474
- if (userTableOptions.didDrawPage) userTableOptions.didDrawPage(data);
1475
- },
1476
- didDrawCell: (data) => {
1477
- if (userTableOptions.didDrawCell) userTableOptions.didDrawCell(data);
1478
- }
1380
+ didDrawPage: safeDidDrawPage,
1381
+ didDrawCell: safeDidDrawCell
1479
1382
  });
1480
1383
  const finalY = doc.lastAutoTable?.finalY;
1481
1384
  if (typeof finalY === "number") {
1482
- store.updateY(finalY + options.page.lineSpace, "set");
1385
+ store.updateY(finalY + (options.spacing?.afterTable ?? 3), "set");
1483
1386
  store.updateX(options.page.xpading, "set");
1484
1387
  store.recordContentY();
1485
- }
1388
+ } else console.warn("[jspdf-md-renderer] autoTable did not return a finalY. Y position may be incorrect.");
1486
1389
  };
1487
1390
  //#endregion
1488
1391
  //#region src/store/renderStore.ts
@@ -1563,65 +1466,222 @@ var RenderStore = class {
1563
1466
  };
1564
1467
  //#endregion
1565
1468
  //#region src/utils/options-validation.ts
1566
- const defaultOptions = {
1567
- page: {
1568
- indent: 10,
1569
- maxContentWidth: 190,
1570
- maxContentHeight: 277,
1571
- lineSpace: 1.5,
1572
- defaultLineHeightFactor: 1.2,
1573
- defaultFontSize: 12,
1574
- defaultTitleFontSize: 14,
1575
- topmargin: 10,
1576
- xpading: 10,
1577
- xmargin: 10,
1578
- format: "a4",
1579
- orientation: "p"
1469
+ const DEFAULT_HEADING_SIZES = {
1470
+ h1: 24,
1471
+ h2: 20,
1472
+ h3: 17,
1473
+ h4: 15,
1474
+ h5: 13,
1475
+ h6: 12,
1476
+ bottomSpacing: 2
1477
+ };
1478
+ const DEFAULT_FONT = {
1479
+ bold: {
1480
+ name: "helvetica",
1481
+ style: "bold"
1580
1482
  },
1581
- font: {
1582
- bold: {
1583
- name: "helvetica",
1584
- style: "bold"
1585
- },
1586
- regular: {
1587
- name: "helvetica",
1588
- style: "normal"
1589
- },
1590
- light: {
1591
- name: "helvetica",
1592
- style: "light"
1593
- },
1594
- code: {
1595
- name: "courier",
1596
- style: "normal"
1597
- }
1483
+ regular: {
1484
+ name: "helvetica",
1485
+ style: "normal"
1486
+ },
1487
+ light: {
1488
+ name: "helvetica",
1489
+ style: "light"
1490
+ },
1491
+ italic: {
1492
+ name: "helvetica",
1493
+ style: "italic"
1598
1494
  },
1599
- image: { defaultAlign: "left" }
1495
+ boldItalic: {
1496
+ name: "helvetica",
1497
+ style: "bolditalic"
1498
+ },
1499
+ code: {
1500
+ name: "courier",
1501
+ style: "normal"
1502
+ }
1503
+ };
1504
+ const DEFAULT_PAGE = {
1505
+ format: "a4",
1506
+ unit: "mm",
1507
+ orientation: "portrait",
1508
+ maxContentWidth: 190,
1509
+ maxContentHeight: 277,
1510
+ lineSpace: 3,
1511
+ defaultLineHeightFactor: 1.4,
1512
+ defaultFontSize: 11,
1513
+ defaultTitleFontSize: 14,
1514
+ topmargin: 10,
1515
+ xpading: 10,
1516
+ xmargin: 10,
1517
+ indent: 8
1600
1518
  };
1601
1519
  const validateOptions = (options) => {
1602
- if (!options) throw new Error("RenderOption is required");
1603
- const mergedPage = {
1604
- ...defaultOptions.page,
1520
+ if (!options) throw new Error("[jspdf-md-renderer] RenderOption is required");
1521
+ const page = {
1522
+ ...DEFAULT_PAGE,
1605
1523
  ...options.page
1606
1524
  };
1607
- const mergedFont = {
1608
- ...defaultOptions.font,
1525
+ if (page.maxContentWidth <= 0) throw new Error("[jspdf-md-renderer] page.maxContentWidth must be > 0");
1526
+ if (page.maxContentHeight <= 0) throw new Error("[jspdf-md-renderer] page.maxContentHeight must be > 0");
1527
+ if (page.indent < 0) throw new Error("[jspdf-md-renderer] page.indent must be >= 0");
1528
+ if (page.defaultFontSize < 1) throw new Error("[jspdf-md-renderer] page.defaultFontSize must be >= 1");
1529
+ if (page.defaultLineHeightFactor < 1) page.defaultLineHeightFactor = 1.4;
1530
+ if (!options.font?.regular?.name) throw new Error("[jspdf-md-renderer] font.regular.name is required");
1531
+ const font = {
1532
+ ...DEFAULT_FONT,
1609
1533
  ...options.font
1610
1534
  };
1611
- const mergedImage = {
1612
- ...defaultOptions.image,
1613
- ...options.image
1535
+ if (!font.bold?.name) font.bold = DEFAULT_FONT.bold;
1536
+ if (!font.italic?.name) font.italic = DEFAULT_FONT.italic;
1537
+ if (!font.boldItalic?.name) font.boldItalic = DEFAULT_FONT.boldItalic;
1538
+ if (!font.code?.name) font.code = DEFAULT_FONT.code;
1539
+ const heading = {
1540
+ ...DEFAULT_HEADING_SIZES,
1541
+ ...options.heading ?? {}
1614
1542
  };
1615
- if (!mergedPage.maxContentWidth) mergedPage.maxContentWidth = 190;
1616
- if (!mergedPage.maxContentHeight) mergedPage.maxContentHeight = 277;
1543
+ [
1544
+ "h1",
1545
+ "h2",
1546
+ "h3",
1547
+ "h4",
1548
+ "h5",
1549
+ "h6"
1550
+ ].forEach((k) => {
1551
+ if ((heading[k] ?? 0) < 6 || (heading[k] ?? 0) > 72) heading[k] = DEFAULT_HEADING_SIZES[k];
1552
+ });
1553
+ const codespan = {
1554
+ backgroundColor: "#EEEEEE",
1555
+ padding: .8,
1556
+ showBackground: true,
1557
+ fontSizeScale: .88,
1558
+ ...options.codespan ?? {}
1559
+ };
1560
+ const blockquote = {
1561
+ barColor: "#AAAAAA",
1562
+ barWidth: 1,
1563
+ paddingLeft: 4,
1564
+ ...options.blockquote ?? {}
1565
+ };
1566
+ const list = {
1567
+ bulletChar: "• ",
1568
+ indentSize: page.indent,
1569
+ itemSpacing: 0,
1570
+ ...options.list ?? {}
1571
+ };
1572
+ const paragraph = {
1573
+ bottomSpacing: page.lineSpace,
1574
+ ...options.paragraph ?? {}
1575
+ };
1576
+ const codeBlock = {
1577
+ backgroundColor: "#F6F8FA",
1578
+ borderColor: "#E1E4E8",
1579
+ borderRadius: 2,
1580
+ padding: 4,
1581
+ fontSizeScale: .9,
1582
+ showLanguageLabel: true,
1583
+ ...options.codeBlock ?? {}
1584
+ };
1585
+ const spacing = {
1586
+ afterHeading: 2,
1587
+ afterParagraph: 3,
1588
+ afterCodeBlock: 3,
1589
+ afterBlockquote: 3,
1590
+ afterImage: 2,
1591
+ afterHR: 2,
1592
+ betweenListItems: 0,
1593
+ afterList: 3,
1594
+ afterTable: 3,
1595
+ ...options.spacing ?? {}
1596
+ };
1597
+ [
1598
+ "afterHeading",
1599
+ "afterParagraph",
1600
+ "afterCodeBlock",
1601
+ "afterBlockquote",
1602
+ "afterImage",
1603
+ "afterHR",
1604
+ "betweenListItems",
1605
+ "afterList",
1606
+ "afterTable"
1607
+ ].forEach((key) => {
1608
+ if ((spacing[key] ?? 0) < 0) spacing[key] = 0;
1609
+ });
1610
+ if ((heading.bottomSpacing ?? 0) < 0) heading.bottomSpacing = 0;
1611
+ if ((paragraph.bottomSpacing ?? 0) < 0) paragraph.bottomSpacing = 0;
1612
+ if ((blockquote.bottomSpacing ?? 0) < 0) blockquote.bottomSpacing = 0;
1613
+ const image = {
1614
+ defaultAlign: "left",
1615
+ ...options.image ?? {}
1616
+ };
1617
+ const endCursorYHandler = options.endCursorYHandler ?? (() => {});
1617
1618
  return {
1618
1619
  ...options,
1619
- page: mergedPage,
1620
- font: mergedFont,
1621
- image: mergedImage
1620
+ page,
1621
+ font,
1622
+ heading,
1623
+ codespan,
1624
+ blockquote,
1625
+ list,
1626
+ paragraph,
1627
+ codeBlock,
1628
+ spacing,
1629
+ image,
1630
+ endCursorYHandler
1622
1631
  };
1623
1632
  };
1624
1633
  //#endregion
1634
+ //#region src/utils/pageDecorations.ts
1635
+ const applyPageDecorations = (doc, options) => {
1636
+ const totalPages = doc.internal.getNumberOfPages();
1637
+ for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
1638
+ doc.setPage(pageNum);
1639
+ applyHeader(doc, options, pageNum, totalPages);
1640
+ applyFooter(doc, options, pageNum, totalPages);
1641
+ }
1642
+ };
1643
+ const applyHeader = (doc, options, pageNum, totalPages) => {
1644
+ const hOpts = options.header;
1645
+ if (!hOpts) return;
1646
+ const text = typeof hOpts.text === "function" ? hOpts.text(pageNum, totalPages) : hOpts.text ?? "";
1647
+ if (!text.trim()) return;
1648
+ withSavedDocState(doc, () => {
1649
+ doc.setFontSize(hOpts.fontSize ?? 9);
1650
+ doc.setTextColor(hOpts.color ?? "#666666");
1651
+ const y = hOpts.y ?? 5;
1652
+ const align = hOpts.align ?? "center";
1653
+ const pageWidth = doc.internal.pageSize.getWidth();
1654
+ let x = pageWidth / 2;
1655
+ if (align === "left") x = options.page.xmargin;
1656
+ if (align === "right") x = pageWidth - options.page.xmargin;
1657
+ doc.text(text, x, y, {
1658
+ align,
1659
+ baseline: "top"
1660
+ });
1661
+ });
1662
+ };
1663
+ const applyFooter = (doc, options, pageNum, totalPages) => {
1664
+ const fOpts = options.footer;
1665
+ if (!fOpts) return;
1666
+ const text = fOpts.showPageNumbers ? `Page ${pageNum} of ${totalPages}` : typeof fOpts.text === "function" ? fOpts.text(pageNum, totalPages) : fOpts.text ?? "";
1667
+ if (!text.trim()) return;
1668
+ withSavedDocState(doc, () => {
1669
+ doc.setFontSize(fOpts.fontSize ?? 9);
1670
+ doc.setTextColor(fOpts.color ?? "#666666");
1671
+ const pageHeight = doc.internal.pageSize.getHeight();
1672
+ const y = fOpts.y ?? pageHeight - 5;
1673
+ const align = fOpts.align ?? "right";
1674
+ const pageWidth = doc.internal.pageSize.getWidth();
1675
+ let x = pageWidth / 2;
1676
+ if (align === "left") x = options.page.xmargin;
1677
+ if (align === "right") x = pageWidth - options.page.xmargin;
1678
+ doc.text(text, x, y, {
1679
+ align,
1680
+ baseline: "bottom"
1681
+ });
1682
+ });
1683
+ };
1684
+ //#endregion
1625
1685
  //#region src/renderer/MdTextRender.ts
1626
1686
  /**
1627
1687
  * Renders parsed markdown text into jsPDF document.
@@ -1639,7 +1699,7 @@ const MdTextRender = async (doc, text, options) => {
1639
1699
  const indent = indentLevel * validOptions.page.indent;
1640
1700
  switch (element.type) {
1641
1701
  case "heading":
1642
- renderHeading(doc, element, indent, store, renderElement);
1702
+ renderHeading(doc, element, indent, store);
1643
1703
  break;
1644
1704
  case "paragraph":
1645
1705
  renderParagraph(doc, element, indent, store, renderElement);
@@ -1659,10 +1719,8 @@ const MdTextRender = async (doc, text, options) => {
1659
1719
  case "strong":
1660
1720
  case "em":
1661
1721
  case "codespan":
1662
- renderInlineText(doc, element, indent, store);
1663
- break;
1664
1722
  case "link":
1665
- renderLink(doc, element, indent, store);
1723
+ renderInlineContent(doc, [element], store.X + indent, store.Y, validOptions.page.maxContentWidth - indent, store);
1666
1724
  break;
1667
1725
  case "blockquote":
1668
1726
  renderBlockquote(doc, element, indentLevel, store, renderElement);
@@ -1694,8 +1752,13 @@ const MdTextRender = async (doc, text, options) => {
1694
1752
  }
1695
1753
  };
1696
1754
  for (const item of parsedElements) renderElement(item, 0, store);
1755
+ applyPageDecorations(doc, validOptions);
1697
1756
  validOptions.endCursorYHandler(store.Y);
1698
1757
  };
1699
1758
  //#endregion
1700
1759
  exports.MdTextParser = MdTextParser;
1701
1760
  exports.MdTextRender = MdTextRender;
1761
+ exports.MdTokenType = MdTokenType;
1762
+ exports.renderInlineContent = renderInlineContent;
1763
+ exports.renderPlainText = renderPlainText;
1764
+ exports.validateOptions = validateOptions;