jspdf-md-renderer 4.0.1 → 4.1.0

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
@@ -97,7 +97,8 @@ const IMAGE_WITH_ATTRS_REGEX = /(!\[[^\]]*\]\()([^)]+)(\))\s*\{([^}]+)\}/g;
97
97
  * - Quoted values: width="200.5" or width='200'
98
98
  * - Decimal values: width=200.5
99
99
  */
100
- const ATTR_PAIR_REGEX = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
100
+ const ATTR_PAIR_REGEX = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([\w.+-]+))/g;
101
+ const MAX_ATTR_BLOCK_LENGTH = 500;
101
102
  /** Valid alignment values */
102
103
  const VALID_ALIGNMENTS = [
103
104
  "left",
@@ -121,6 +122,11 @@ const encodeAttrsToFragment = (attrs) => {
121
122
  */
122
123
  const parseRawAttributes = (attrString) => {
123
124
  const attrs = {};
125
+ if (attrString.length > MAX_ATTR_BLOCK_LENGTH) {
126
+ console.warn(`[jspdf-md-renderer] Image attribute block too long (${attrString.length} chars), skipping attribute parsing.`);
127
+ return attrs;
128
+ }
129
+ ATTR_PAIR_REGEX.lastIndex = 0;
124
130
  let match;
125
131
  while ((match = ATTR_PAIR_REGEX.exec(attrString)) !== null) {
126
132
  const key = match[1].toLowerCase();
@@ -430,11 +436,367 @@ const withSavedDocState = (doc, fn) => {
430
436
  }
431
437
  };
432
438
  //#endregion
439
+ //#region src/types/security.ts
440
+ var SecurityViolationError = class extends Error {
441
+ constructor(violation) {
442
+ super(violation.message);
443
+ this.name = "SecurityViolationError";
444
+ this.violation = violation;
445
+ }
446
+ };
447
+ //#endregion
448
+ //#region src/security/security-policy.ts
449
+ const DEFAULT_SECURITY = {
450
+ enabled: false,
451
+ allowedLinkProtocols: [
452
+ "https:",
453
+ "http:",
454
+ "mailto:",
455
+ "tel:"
456
+ ],
457
+ disablePdfLinks: false,
458
+ allowRemoteImages: true,
459
+ allowedImageProtocols: ["https:", "http:"],
460
+ allowedImageDomains: void 0,
461
+ allowDataUrls: true,
462
+ allowSvgImages: true,
463
+ blockLocalhost: true,
464
+ blockPrivateIPs: true,
465
+ blockLinkLocalIPs: true,
466
+ blockMetadataIPs: true,
467
+ maxMarkdownLength: 5e5,
468
+ maxImageCount: 200,
469
+ maxImageSizeBytes: 10 * 1024 * 1024,
470
+ maxNestedDepth: 20,
471
+ renderTimeoutMs: 3e4,
472
+ violationMode: "skip",
473
+ placeholderText: "[blocked]",
474
+ placeholderImageText: "[blocked image]"
475
+ };
476
+ const normalizeProtocol = (v) => `${v.trim().toLowerCase().replace(/:$/, "")}:`;
477
+ const normalizeDomain = (v) => v.trim().toLowerCase();
478
+ const clampInteger = (value, min, max) => Math.min(max, Math.max(min, Math.floor(value)));
479
+ const isNodeEnvironment = () => typeof process !== "undefined" && process.versions != null && process.versions.node != null;
480
+ /**
481
+ * Merges user-provided security config with safe defaults and
482
+ * validates/clamps numeric and enum fields.
483
+ */
484
+ const normalizeSecurityOptions = (security) => {
485
+ if (!security) return { ...DEFAULT_SECURITY };
486
+ const merged = {
487
+ ...DEFAULT_SECURITY,
488
+ ...security,
489
+ allowedLinkProtocols: security.allowedLinkProtocols?.map(normalizeProtocol) ?? DEFAULT_SECURITY.allowedLinkProtocols,
490
+ allowedImageProtocols: security.allowedImageProtocols?.map(normalizeProtocol) ?? DEFAULT_SECURITY.allowedImageProtocols,
491
+ allowedImageDomains: security.allowedImageDomains !== void 0 ? security.allowedImageDomains.map(normalizeDomain) : DEFAULT_SECURITY.allowedImageDomains
492
+ };
493
+ if (![
494
+ "skip",
495
+ "throw",
496
+ "placeholder"
497
+ ].includes(merged.violationMode || "skip")) throw new Error("[jspdf-md-renderer] security.violationMode must be skip | throw | placeholder");
498
+ for (const field of [
499
+ "maxMarkdownLength",
500
+ "maxImageCount",
501
+ "maxImageSizeBytes",
502
+ "maxNestedDepth",
503
+ "renderTimeoutMs"
504
+ ]) {
505
+ const value = merged[field];
506
+ if (value !== void 0 && (!Number.isFinite(value) || value < 0)) throw new Error(`[jspdf-md-renderer] security.${field} must be a non-negative number`);
507
+ }
508
+ merged.maxMarkdownLength = clampInteger(merged.maxMarkdownLength || 0, 0, 5e6);
509
+ merged.maxImageCount = clampInteger(merged.maxImageCount || 0, 0, 1e4);
510
+ merged.maxImageSizeBytes = clampInteger(merged.maxImageSizeBytes || 0, 0, 100 * 1024 * 1024);
511
+ merged.maxNestedDepth = clampInteger(merged.maxNestedDepth || 0, 0, 100);
512
+ merged.renderTimeoutMs = clampInteger(merged.renderTimeoutMs || 0, 0, 3e5);
513
+ return merged;
514
+ };
515
+ const metadataHosts = new Set([
516
+ "metadata.google.internal",
517
+ "metadata",
518
+ "instance-data"
519
+ ]);
520
+ const isIPv4InCidr = (ip, cidrBase, cidrMask) => {
521
+ const toNum = (s) => s.split(".").reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0;
522
+ const ipNum = toNum(ip);
523
+ const baseNum = toNum(cidrBase);
524
+ const mask = cidrMask === 0 ? 0 : 4294967295 << 32 - cidrMask >>> 0;
525
+ return (ipNum & mask) === (baseNum & mask);
526
+ };
527
+ const isLocalhostHost = (host) => host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
528
+ const isPrivateIPv4 = (ip) => isIPv4InCidr(ip, "10.0.0.0", 8) || isIPv4InCidr(ip, "172.16.0.0", 12) || isIPv4InCidr(ip, "192.168.0.0", 16);
529
+ const isLinkLocalIPv4 = (ip) => isIPv4InCidr(ip, "169.254.0.0", 16);
530
+ const isMetadataIP = (ip) => ip === "169.254.169.254" || ip === "100.100.100.200";
531
+ /**
532
+ * Parses an IPv6 address string into a BigInt for range comparison.
533
+ * Supports compressed and IPv4-mapped forms.
534
+ */
535
+ const parseIPv6ToBigInt = (ip) => {
536
+ let stripped = ip.replace(/^\[|\]$/g, "").toLowerCase();
537
+ if (stripped.includes(".")) {
538
+ const lastColon = stripped.lastIndexOf(":");
539
+ if (lastColon < 0) return null;
540
+ const ipv4Part = stripped.slice(lastColon + 1);
541
+ const prefix = stripped.slice(0, lastColon);
542
+ const parts = ipv4Part.split(".").map(Number);
543
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return null;
544
+ stripped = `${prefix}:${(parts[0] << 8 | parts[1]).toString(16)}:${(parts[2] << 8 | parts[3]).toString(16)}`;
545
+ }
546
+ let expanded = stripped;
547
+ if (expanded.includes("::")) {
548
+ const parts = expanded.split("::");
549
+ if (parts.length !== 2) return null;
550
+ const leftGroups = parts[0] ? parts[0].split(":") : [];
551
+ const rightGroups = parts[1] ? parts[1].split(":") : [];
552
+ const missing = 8 - leftGroups.length - rightGroups.length;
553
+ if (missing < 0) return null;
554
+ expanded = [
555
+ ...leftGroups,
556
+ ...Array(missing).fill("0"),
557
+ ...rightGroups
558
+ ].join(":");
559
+ }
560
+ const groups = expanded.split(":");
561
+ if (groups.length !== 8) return null;
562
+ try {
563
+ return groups.reduce((acc, g) => {
564
+ const n = parseInt(g || "0", 16);
565
+ if (Number.isNaN(n)) throw new Error("invalid hex");
566
+ return (acc << 16n) + BigInt(n);
567
+ }, 0n);
568
+ } catch {
569
+ return null;
570
+ }
571
+ };
572
+ const MAX_IPV6 = (1n << 128n) - 1n;
573
+ const isIPv6InRange = (ip, prefixBigInt, prefixLength) => {
574
+ const ipNum = parseIPv6ToBigInt(ip);
575
+ if (ipNum === null) return false;
576
+ const mask = prefixLength === 0 ? 0n : MAX_IPV6 << BigInt(128 - prefixLength) & MAX_IPV6;
577
+ return (ipNum & mask) === (prefixBigInt & mask);
578
+ };
579
+ const isLoopbackIPv6 = (ip) => {
580
+ return parseIPv6ToBigInt(ip) === 1n;
581
+ };
582
+ const isUniqueLocalIPv6 = (ip) => isIPv6InRange(ip, 64512n << 112n, 7);
583
+ const isLinkLocalIPv6 = (ip) => isIPv6InRange(ip, 65152n << 112n, 10);
584
+ const extractIPv4Mapped = (ip) => {
585
+ const stripped = ip.replace(/^\[|\]$/g, "");
586
+ const dottedMatch = stripped.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
587
+ if (dottedMatch) return dottedMatch[1];
588
+ const ipNum = parseIPv6ToBigInt(stripped);
589
+ if (ipNum === null) return null;
590
+ if (ipNum >> 32n !== 65535n) return null;
591
+ const low32 = Number(ipNum & 4294967295n);
592
+ return `${low32 >>> 24 & 255}.${low32 >>> 16 & 255}.${low32 >>> 8 & 255}.${low32 & 255}`;
593
+ };
594
+ const isIPv4MappedPrivate = (ip) => {
595
+ const mapped = extractIPv4Mapped(ip);
596
+ return mapped ? isPrivateIPv4(mapped) : false;
597
+ };
598
+ const isIPv4MappedLinkLocal = (ip) => {
599
+ const mapped = extractIPv4Mapped(ip);
600
+ return mapped ? isLinkLocalIPv4(mapped) : false;
601
+ };
602
+ const isIPv4MappedMetadata = (ip) => {
603
+ const mapped = extractIPv4Mapped(ip);
604
+ return mapped ? isMetadataIP(mapped) : false;
605
+ };
606
+ /**
607
+ * Resolves a hostname to IP addresses.
608
+ * Returns null when resolution is unavailable (browser runtime).
609
+ */
610
+ const resolveHostToIPs = async (host) => {
611
+ const stripped = host.replace(/^\[|\]$/g, "");
612
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(stripped)) return [stripped];
613
+ if (stripped.includes(":")) return [stripped];
614
+ if (!isNodeEnvironment()) return null;
615
+ try {
616
+ return (await (await import("node:dns")).promises.lookup(stripped, { all: true })).map((entry) => entry.address);
617
+ } catch {
618
+ return [];
619
+ }
620
+ };
621
+ /**
622
+ * Returns the action the caller should take for the violating element.
623
+ * Throws SecurityViolationError when violationMode is 'throw'.
624
+ */
625
+ const handleSecurityViolation = (security, violation) => {
626
+ const fullViolation = {
627
+ ...violation,
628
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
629
+ };
630
+ try {
631
+ security.onSecurityViolation?.(fullViolation);
632
+ } catch (error) {
633
+ console.warn("[jspdf-md-renderer] security.onSecurityViolation callback failed:", error);
634
+ }
635
+ const mode = security.violationMode || "skip";
636
+ if (mode === "throw") throw new SecurityViolationError(fullViolation);
637
+ if (mode === "placeholder") return "placeholder";
638
+ return "skip";
639
+ };
640
+ const createViolation = (code, type, message, value, context) => ({
641
+ code,
642
+ type,
643
+ message,
644
+ value,
645
+ context
646
+ });
647
+ /**
648
+ * Returns true when:
649
+ * - allowedDomains is undefined (feature not configured, allow all), OR
650
+ * - host matches an entry in the allowlist.
651
+ *
652
+ * An explicitly empty allowedDomains array means no domains are permitted.
653
+ */
654
+ const isAllowedDomain = (host, allowedDomains) => {
655
+ if (allowedDomains === void 0) return true;
656
+ if (allowedDomains.length === 0) return false;
657
+ return allowedDomains.some((domain) => host === domain || host.endsWith(`.${domain}`));
658
+ };
659
+ const classifyUrl = (raw) => {
660
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return "explicitScheme";
661
+ if (raw.startsWith("//")) return "protocolRelative";
662
+ return "relativePath";
663
+ };
664
+ /**
665
+ * Validates link/image URLs against protocol/domain/SSRF rules.
666
+ *
667
+ * URL classification:
668
+ * - Protocol-relative (`//host/path`): treated as external absolute and validated.
669
+ * - Explicit scheme (`https://...`): fully validated.
670
+ * - Relative path (`/x`, `./x`, `../x`, `?x`, `#x`): allowed by default.
671
+ */
672
+ const validateResourceUrl = async (rawValue, type, security, context) => {
673
+ const urlClass = classifyUrl(rawValue);
674
+ if (urlClass === "relativePath") {
675
+ if (security.validateUrl) {
676
+ let relativeUrl;
677
+ try {
678
+ relativeUrl = new URL(rawValue, "https://relative.local");
679
+ } catch {
680
+ handleSecurityViolation(security, createViolation("INVALID_URL", type, "Invalid relative URL", rawValue, context));
681
+ return false;
682
+ }
683
+ if (!await security.validateUrl(relativeUrl, type)) {
684
+ handleSecurityViolation(security, createViolation("CUSTOM_VALIDATOR_BLOCKED", type, "Custom URL validator rejected relative URL", rawValue, context));
685
+ return false;
686
+ }
687
+ }
688
+ return true;
689
+ }
690
+ let canonicalRaw = rawValue;
691
+ if (urlClass === "protocolRelative") canonicalRaw = `https:${rawValue}`;
692
+ let parsed;
693
+ try {
694
+ parsed = new URL(canonicalRaw);
695
+ } catch {
696
+ handleSecurityViolation(security, createViolation("INVALID_URL", type, "Invalid URL", rawValue, context));
697
+ return false;
698
+ }
699
+ if (urlClass !== "protocolRelative") {
700
+ const protocol = normalizeProtocol(parsed.protocol);
701
+ if (!(type === "link" ? security.allowedLinkProtocols || DEFAULT_SECURITY.allowedLinkProtocols : security.allowedImageProtocols || DEFAULT_SECURITY.allowedImageProtocols).includes(protocol)) {
702
+ handleSecurityViolation(security, createViolation(type === "link" ? "LINK_PROTOCOL_BLOCKED" : "IMAGE_PROTOCOL_BLOCKED", type, `${type} protocol is blocked`, rawValue, context));
703
+ return false;
704
+ }
705
+ }
706
+ if (type === "image" && !isAllowedDomain(parsed.hostname.toLowerCase(), security.allowedImageDomains)) {
707
+ handleSecurityViolation(security, createViolation("IMAGE_DOMAIN_BLOCKED", type, "Image domain is blocked", rawValue, context));
708
+ return false;
709
+ }
710
+ const host = parsed.hostname.toLowerCase();
711
+ if (security.blockLocalhost && isLocalhostHost(host)) {
712
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "Localhost URL is blocked", rawValue, context));
713
+ return false;
714
+ }
715
+ if (security.blockMetadataIPs && metadataHosts.has(host)) {
716
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "Metadata host is blocked", rawValue, context));
717
+ return false;
718
+ }
719
+ const ips = await resolveHostToIPs(host);
720
+ if (ips === null) {
721
+ if (type === "image") console.warn("[jspdf-md-renderer] Security warning: IP-based SSRF checks (blockPrivateIPs, blockLinkLocalIPs, blockMetadataIPs) cannot be fully enforced in browser environments. Route image fetching through a trusted server-side proxy.");
722
+ } else for (const ip of ips) {
723
+ if (security.blockLocalhost && isLocalhostHost(ip)) {
724
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "Localhost IP is blocked", rawValue, context));
725
+ return false;
726
+ }
727
+ if (security.blockPrivateIPs && isPrivateIPv4(ip)) {
728
+ handleSecurityViolation(security, createViolation("PRIVATE_IP_BLOCKED", type, "Private IP is blocked", rawValue, context));
729
+ return false;
730
+ }
731
+ if (security.blockLinkLocalIPs && isLinkLocalIPv4(ip)) {
732
+ handleSecurityViolation(security, createViolation("LINK_LOCAL_IP_BLOCKED", type, "Link-local IP is blocked", rawValue, context));
733
+ return false;
734
+ }
735
+ if (security.blockMetadataIPs && isMetadataIP(ip)) {
736
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "Metadata IP is blocked", rawValue, context));
737
+ return false;
738
+ }
739
+ if (security.blockLocalhost && isLoopbackIPv6(ip)) {
740
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "IPv6 loopback is blocked", rawValue, context));
741
+ return false;
742
+ }
743
+ if (security.blockPrivateIPs && (isUniqueLocalIPv6(ip) || isIPv4MappedPrivate(ip))) {
744
+ handleSecurityViolation(security, createViolation("PRIVATE_IP_BLOCKED", type, "IPv6 private address is blocked", rawValue, context));
745
+ return false;
746
+ }
747
+ if (security.blockLinkLocalIPs && (isLinkLocalIPv6(ip) || isIPv4MappedLinkLocal(ip))) {
748
+ handleSecurityViolation(security, createViolation("LINK_LOCAL_IP_BLOCKED", type, "IPv6 link-local address is blocked", rawValue, context));
749
+ return false;
750
+ }
751
+ if (security.blockMetadataIPs && isIPv4MappedMetadata(ip)) {
752
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "IPv4-mapped metadata IP is blocked", rawValue, context));
753
+ return false;
754
+ }
755
+ }
756
+ if (security.validateUrl) {
757
+ if (!await security.validateUrl(parsed, type)) {
758
+ handleSecurityViolation(security, createViolation("CUSTOM_VALIDATOR_BLOCKED", type, "Custom URL validator rejected URL", rawValue, context));
759
+ return false;
760
+ }
761
+ }
762
+ return true;
763
+ };
764
+ /**
765
+ * Returns true when the value is a `data:` URL.
766
+ */
767
+ const isDataUrl = (value) => value.trim().toLowerCase().startsWith("data:");
768
+ /**
769
+ * Returns true when the value is an SVG data URL.
770
+ */
771
+ const isSvgDataUrl = (value) => {
772
+ const normalized = value.trim().toLowerCase();
773
+ return normalized.startsWith("data:image/svg+xml") || normalized.startsWith("data:image/svg");
774
+ };
775
+ //#endregion
433
776
  //#region src/utils/image-utils.ts
434
777
  /**
435
778
  * Standard DPI for web/screen pixels.
436
779
  */
437
780
  const DEFAULT_DPI = 96;
781
+ const getDataUrlPayloadByteSize = (dataUrl) => {
782
+ const commaIndex = dataUrl.indexOf(",");
783
+ if (commaIndex < 0) return null;
784
+ const metadata = dataUrl.slice(0, commaIndex).toLowerCase();
785
+ const payload = dataUrl.slice(commaIndex + 1);
786
+ if (metadata.includes(";base64")) {
787
+ const normalized = payload.replace(/\s/g, "");
788
+ const padding = normalized.match(/=*$/)?.[0].length ?? 0;
789
+ return Math.floor(normalized.length * 3 / 4) - padding;
790
+ }
791
+ try {
792
+ const decoded = decodeURIComponent(payload);
793
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(decoded).length;
794
+ if (typeof Buffer !== "undefined") return Buffer.from(decoded, "utf-8").byteLength;
795
+ return decoded.length;
796
+ } catch {
797
+ return null;
798
+ }
799
+ };
438
800
  /**
439
801
  * Converts pixel values to the document's unit system.
440
802
  * Uses 96 DPI as the standard web pixel density.
@@ -570,14 +932,84 @@ const calculateImageDimensions = (doc, element, maxWidth, maxHeight, docUnit = "
570
932
  * Recursively traverses parsed elements and loads image data for Image tokens.
571
933
  * @param elements - The parsed elements to process.
572
934
  */
573
- const prefetchImages = async (elements) => {
935
+ const prefetchImages = async (elements, security) => {
574
936
  for (const element of elements) {
575
937
  if (element.type === "image" && element.src) try {
576
- if (element.src.startsWith("data:")) element.data = element.src;
577
- else {
578
- const response = await fetch(element.src);
938
+ if (security?.enabled) if (isDataUrl(element.src)) {
939
+ if (isSvgDataUrl(element.src) && !security.allowSvgImages) {
940
+ handleSecurityViolation(security, {
941
+ code: "SVG_BLOCKED",
942
+ type: "image",
943
+ message: "SVG images are blocked",
944
+ value: element.src,
945
+ context: "image-src"
946
+ });
947
+ element.data = void 0;
948
+ element.src = void 0;
949
+ continue;
950
+ }
951
+ if (!security.allowDataUrls) {
952
+ handleSecurityViolation(security, {
953
+ code: "DATA_URL_BLOCKED",
954
+ type: "image",
955
+ message: "Data URLs are blocked for images",
956
+ value: element.src,
957
+ context: "image-src"
958
+ });
959
+ element.data = void 0;
960
+ element.src = void 0;
961
+ continue;
962
+ }
963
+ } else {
964
+ if (!security.allowRemoteImages) {
965
+ handleSecurityViolation(security, {
966
+ code: "IMAGE_PROTOCOL_BLOCKED",
967
+ type: "image",
968
+ message: "Remote images are disabled",
969
+ value: element.src,
970
+ context: "image-src"
971
+ });
972
+ element.data = void 0;
973
+ element.src = void 0;
974
+ continue;
975
+ }
976
+ if (!await validateResourceUrl(element.src, "image", security, "image-src")) {
977
+ element.data = void 0;
978
+ element.src = void 0;
979
+ continue;
980
+ }
981
+ }
982
+ if (element.src.startsWith("data:")) {
983
+ element.data = element.src;
984
+ const dataUrlBytes = getDataUrlPayloadByteSize(element.data);
985
+ if (security?.enabled && security.maxImageSizeBytes && dataUrlBytes !== null && dataUrlBytes > security.maxImageSizeBytes) {
986
+ handleSecurityViolation(security, {
987
+ code: "IMAGE_SIZE_EXCEEDED",
988
+ type: "image",
989
+ message: "Data URL image exceeds maxImageSizeBytes",
990
+ value: String(dataUrlBytes),
991
+ context: "data-url-size"
992
+ });
993
+ element.data = void 0;
994
+ element.src = void 0;
995
+ continue;
996
+ }
997
+ } else {
998
+ const response = await secureImageFetch(element.src, security);
579
999
  if (!response.ok) throw new Error(`Failed to fetch image: ${response.statusText}`);
580
1000
  const blob = await response.blob();
1001
+ if (security?.enabled && security.maxImageSizeBytes && blob.size > security.maxImageSizeBytes) {
1002
+ handleSecurityViolation(security, {
1003
+ code: "IMAGE_SIZE_EXCEEDED",
1004
+ type: "image",
1005
+ message: "Fetched image exceeds maxImageSizeBytes",
1006
+ value: String(blob.size),
1007
+ context: "blob-size"
1008
+ });
1009
+ element.data = void 0;
1010
+ element.src = void 0;
1011
+ continue;
1012
+ }
581
1013
  element.data = await new Promise((resolve, reject) => {
582
1014
  const reader = new FileReader();
583
1015
  reader.onloadend = () => {
@@ -613,10 +1045,22 @@ const prefetchImages = async (elements) => {
613
1045
  });
614
1046
  }
615
1047
  } catch (error) {
1048
+ if (error instanceof SecurityViolationError) throw error;
616
1049
  console.warn(`[jspdf-md-renderer] Warning: Failed to load image at ${element.src}. It will be skipped.`, error);
617
1050
  }
618
- if (element.items && element.items.length > 0) await prefetchImages(element.items);
1051
+ if (element.items && element.items.length > 0) await prefetchImages(element.items, security);
1052
+ }
1053
+ };
1054
+ /**
1055
+ * Best-effort remote image fetch hardening.
1056
+ * In Node, re-validates URL immediately before fetch to reduce DNS rebind window.
1057
+ * In browser runtimes, or when security is undefined/disabled, delegates to normal fetch.
1058
+ */
1059
+ const secureImageFetch = async (url, security) => {
1060
+ if (security?.enabled && isNodeEnvironment()) {
1061
+ if (!await validateResourceUrl(url, "image", security, "pre-fetch-recheck")) throw new Error(`[jspdf-md-renderer] URL blocked on pre-fetch recheck: ${url}`);
619
1062
  }
1063
+ return fetch(url);
620
1064
  };
621
1065
  //#endregion
622
1066
  //#region src/layout/wordSplitter.ts
@@ -959,10 +1403,12 @@ const renderPlainText = (doc, text, x, y, maxWidth, store, opts = {}) => {
959
1403
  const renderHeading = (doc, element, indent, store) => {
960
1404
  withSavedDocState(doc, () => {
961
1405
  const headingKey = `h${element?.depth ?? 1}`;
962
- const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultFontSize;
1406
+ const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultTitleFontSize;
963
1407
  const headingColor = store.options.heading?.[`${headingKey}Color`] ?? store.options.heading?.color ?? "#000000";
1408
+ const useBold = store.options.heading?.bold ?? true;
964
1409
  doc.setFontSize(fontSize);
965
- doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
1410
+ if (useBold) doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
1411
+ else doc.setFont(store.options.font.regular.name, store.options.font.regular.style || "normal");
966
1412
  doc.setTextColor(headingColor);
967
1413
  breakIfOverflow(doc, store, getCharHight(doc) * 1.8);
968
1414
  const maxWidth = store.options.page.maxContentWidth - indent;
@@ -1032,10 +1478,11 @@ const renderParagraph = (doc, element, indent, store, parentElementRenderer) =>
1032
1478
  //#region src/renderer/components/list.ts
1033
1479
  const renderList = (doc, element, indentLevel, store, parentElementRenderer) => {
1034
1480
  doc.setFontSize(store.options.page.defaultFontSize);
1481
+ const listItemGap = store.options.spacing?.betweenListItems ?? 0;
1035
1482
  for (const [i, point] of element?.items?.entries() ?? []) {
1036
1483
  const _start = element.ordered ? (element.start ?? 1) + i : void 0;
1037
1484
  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");
1485
+ if (i < (element.items?.length ?? 0) - 1) store.updateY(listItemGap, "add");
1039
1486
  }
1040
1487
  store.updateY(store.options.spacing?.afterList ?? 3, "add");
1041
1488
  };
@@ -1045,9 +1492,9 @@ const renderList = (doc, element, indentLevel, store, parentElementRenderer) =>
1045
1492
  * Render a single list item, including bullets/numbering, inline text, and any nested lists.
1046
1493
  */
1047
1494
  const renderListItem = (doc, element, indentLevel, store, parentElementRenderer, start, ordered) => {
1048
- breakIfOverflow(doc, store, getCharHight(doc));
1049
1495
  const options = store.options;
1050
1496
  const listOpts = store.options.list ?? {};
1497
+ breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
1051
1498
  const baseIndent = indentLevel * (listOpts.indentSize ?? options.page.indent);
1052
1499
  const bullet = ordered ? `${start}. ` : listOpts.bulletChar ?? "• ";
1053
1500
  const xLeft = options.page.xpading;
@@ -1124,6 +1571,7 @@ const renderRawItem = (doc, element, indentLevel, store, hasRawBullet, parentEle
1124
1571
  }
1125
1572
  store.updateX(xLeft, "set");
1126
1573
  if (hasRawBullet && bullet) {
1574
+ breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
1127
1575
  const bulletWidth = doc.getTextWidth(bullet);
1128
1576
  const textMaxWidth = options.page.maxContentWidth - indent - bulletWidth;
1129
1577
  doc.setFont(options.font.regular.name, options.font.regular.style);
@@ -1242,7 +1690,9 @@ const renderCodeBlock = (doc, element, indentLevel, store) => {
1242
1690
  //#region src/renderer/components/blockquote.ts
1243
1691
  const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1244
1692
  const options = store.options;
1693
+ const bqOpts = store.options.blockquote ?? {};
1245
1694
  const savedDrawColor = doc.getDrawColor();
1695
+ const savedFillColor = doc.getFillColor();
1246
1696
  const savedLineWidth = doc.getLineWidth();
1247
1697
  const blockquoteIndent = indentLevel + 1;
1248
1698
  const currentX = store.X + indentLevel * options.page.indent;
@@ -1255,17 +1705,27 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1255
1705
  });
1256
1706
  const endY = store.lastContentY || store.Y;
1257
1707
  const endPage = doc.internal.getCurrentPageInfo().pageNumber;
1258
- const bqOpts = store.options.blockquote ?? {};
1259
1708
  const barColor = bqOpts.barColor ?? "#AAAAAA";
1260
1709
  const barWidth = bqOpts.barWidth ?? 1;
1261
1710
  doc.setDrawColor(barColor);
1262
1711
  doc.setLineWidth(barWidth);
1712
+ const bgColor = bqOpts.backgroundColor;
1263
1713
  for (let p = startPage; p <= endPage; p++) {
1264
1714
  doc.setPage(p);
1265
1715
  const isStart = p === startPage;
1266
1716
  const isEnd = p === endPage;
1267
1717
  const lineTop = isStart ? startY : options.page.topmargin;
1268
1718
  const lineBottom = isEnd ? endY : options.page.maxContentHeight;
1719
+ const lineHeight = Math.max(0, lineBottom - lineTop);
1720
+ if (bgColor && lineHeight > 0) {
1721
+ const bgX = barX + barWidth / 2;
1722
+ const bgW = options.page.maxContentWidth - (bgX - options.page.xpading);
1723
+ if (bgW > 0) {
1724
+ doc.setFillColor(bgColor);
1725
+ doc.rect(bgX, lineTop, bgW, lineHeight, "F");
1726
+ doc.setDrawColor(barColor);
1727
+ }
1728
+ }
1269
1729
  doc.line(barX, lineTop, barX, lineBottom);
1270
1730
  }
1271
1731
  store.recordContentY();
@@ -1273,6 +1733,7 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1273
1733
  const bqBottomSpacing = bqOpts.bottomSpacing ?? options.spacing?.afterBlockquote ?? options.page.lineSpace;
1274
1734
  store.updateY(bqBottomSpacing, "add");
1275
1735
  doc.setDrawColor(savedDrawColor);
1736
+ doc.setFillColor(savedFillColor);
1276
1737
  doc.setLineWidth(savedLineWidth);
1277
1738
  };
1278
1739
  //#endregion
@@ -1341,7 +1802,9 @@ const renderTable = (doc, element, indentLevel, store) => {
1341
1802
  return;
1342
1803
  }
1343
1804
  const options = store.options;
1344
- const marginLeft = options.page.xmargin + indentLevel * options.page.indent;
1805
+ const indent = indentLevel * options.page.indent;
1806
+ const marginLeft = options.page.xpading + indent;
1807
+ const availableWidth = Math.max(10, options.page.maxContentWidth - indent);
1345
1808
  ensureSpace(doc, store, 20);
1346
1809
  const columnCount = element.header.length;
1347
1810
  const rows = (element.rows ?? []).map((row) => {
@@ -1374,8 +1837,9 @@ const renderTable = (doc, element, indentLevel, store) => {
1374
1837
  startY: store.Y,
1375
1838
  margin: {
1376
1839
  left: marginLeft,
1377
- right: options.page.xmargin
1840
+ right: Math.max(0, doc.internal.pageSize.getWidth() - (marginLeft + availableWidth))
1378
1841
  },
1842
+ tableWidth: availableWidth,
1379
1843
  ...userTableOptions,
1380
1844
  didDrawPage: safeDidDrawPage,
1381
1845
  didDrawCell: safeDidDrawCell
@@ -1467,6 +1931,7 @@ var RenderStore = class {
1467
1931
  //#endregion
1468
1932
  //#region src/utils/options-validation.ts
1469
1933
  const DEFAULT_HEADING_SIZES = {
1934
+ bold: true,
1470
1935
  h1: 24,
1471
1936
  h2: 20,
1472
1937
  h3: 17,
@@ -1594,6 +2059,7 @@ const validateOptions = (options) => {
1594
2059
  afterTable: 3,
1595
2060
  ...options.spacing ?? {}
1596
2061
  };
2062
+ if (options.spacing?.betweenListItems === void 0) spacing.betweenListItems = list.itemSpacing ?? spacing.betweenListItems;
1597
2063
  [
1598
2064
  "afterHeading",
1599
2065
  "afterParagraph",
@@ -1627,6 +2093,7 @@ const validateOptions = (options) => {
1627
2093
  codeBlock,
1628
2094
  spacing,
1629
2095
  image,
2096
+ security: normalizeSecurityOptions(options.security),
1630
2097
  endCursorYHandler
1631
2098
  };
1632
2099
  };
@@ -1682,6 +2149,140 @@ const applyFooter = (doc, options, pageNum, totalPages) => {
1682
2149
  });
1683
2150
  };
1684
2151
  //#endregion
2152
+ //#region src/security/security-guards.ts
2153
+ /**
2154
+ * Enforces input-size limits before tokenization/rendering starts.
2155
+ * Violations are delegated to the configured violation handler.
2156
+ */
2157
+ const enforceMarkdownLimits = (text, security) => {
2158
+ if (!security.enabled) return;
2159
+ if ((security.maxMarkdownLength || 0) > 0 && text.length > (security.maxMarkdownLength || 0)) {
2160
+ const action = handleSecurityViolation(security, {
2161
+ code: "MARKDOWN_TOO_LARGE",
2162
+ type: "markdown",
2163
+ message: "Markdown length exceeds configured limit",
2164
+ value: String(text.length)
2165
+ });
2166
+ if (action === "skip" || action === "placeholder") throw new Error(`[jspdf-md-renderer] Markdown input rejected: length ${text.length} exceeds maxMarkdownLength ${security.maxMarkdownLength}.`);
2167
+ }
2168
+ };
2169
+ /**
2170
+ * Walks the parsed markdown tree and enforces structural limits:
2171
+ * nesting depth and image count.
2172
+ */
2173
+ const enforceNestedDepthAndImageCount = (elements, security) => {
2174
+ if (!security.enabled) return;
2175
+ let imageCount = 0;
2176
+ const maxDepth = security.maxNestedDepth || 0;
2177
+ const maxImageCount = security.maxImageCount || 0;
2178
+ const placeholderText = security.placeholderImageText || "[blocked image]";
2179
+ let imageLimitViolated = false;
2180
+ const sanitizeNodes = (nodes, depth) => {
2181
+ if (maxDepth > 0 && depth > maxDepth) {
2182
+ handleSecurityViolation(security, {
2183
+ code: "MAX_NESTED_DEPTH_EXCEEDED",
2184
+ type: "markdown",
2185
+ message: "Markdown nesting depth exceeds configured limit",
2186
+ value: String(depth)
2187
+ });
2188
+ return [];
2189
+ }
2190
+ const sanitized = [];
2191
+ for (const node of nodes) {
2192
+ if (node.type === "image") {
2193
+ imageCount++;
2194
+ if (maxImageCount > 0 && imageCount > maxImageCount) {
2195
+ if (!imageLimitViolated) {
2196
+ imageLimitViolated = true;
2197
+ handleSecurityViolation(security, {
2198
+ code: "MAX_IMAGE_COUNT_EXCEEDED",
2199
+ type: "image",
2200
+ message: "Image count exceeds configured limit",
2201
+ value: String(imageCount)
2202
+ });
2203
+ }
2204
+ if (security.violationMode === "placeholder") sanitized.push({
2205
+ type: "raw",
2206
+ content: placeholderText
2207
+ });
2208
+ continue;
2209
+ }
2210
+ }
2211
+ if (node.items?.length) node.items = sanitizeNodes(node.items, depth + 1);
2212
+ sanitized.push(node);
2213
+ }
2214
+ return sanitized;
2215
+ };
2216
+ const sanitizedRoot = sanitizeNodes(elements, 1);
2217
+ elements.length = 0;
2218
+ elements.push(...sanitizedRoot);
2219
+ };
2220
+ /**
2221
+ * Creates a lightweight timeout guard function for long render flows.
2222
+ * Call the returned function at checkpoints (parse, prefetch, render loop).
2223
+ */
2224
+ const createTimeoutGuard = (security) => {
2225
+ const timeoutAt = security.enabled && (security.renderTimeoutMs || 0) > 0 ? Date.now() + (security.renderTimeoutMs || 0) : 0;
2226
+ return () => {
2227
+ if (timeoutAt > 0 && Date.now() > timeoutAt) {
2228
+ const action = handleSecurityViolation(security, {
2229
+ code: "RENDER_TIMEOUT_EXCEEDED",
2230
+ type: "render",
2231
+ message: "Render time exceeded configured timeout",
2232
+ value: String(security.renderTimeoutMs)
2233
+ });
2234
+ if (action === "skip" || action === "placeholder") throw new Error(`[jspdf-md-renderer] Render aborted: exceeded renderTimeoutMs (${security.renderTimeoutMs}ms).`);
2235
+ }
2236
+ };
2237
+ };
2238
+ //#endregion
2239
+ //#region src/security/security-transforms.ts
2240
+ /**
2241
+ * Applies link security rules to parsed markdown elements.
2242
+ * Rejected links are downgraded to plain text by clearing `href`.
2243
+ * In placeholder mode, blocked links render `security.placeholderText`.
2244
+ */
2245
+ const applyLinkPolicy = async (elements, security) => {
2246
+ if (!security.enabled) return;
2247
+ const walk = async (nodes) => {
2248
+ for (const node of nodes) {
2249
+ if (node.type === "link" && node.href) {
2250
+ if (security.disablePdfLinks) node.href = void 0;
2251
+ else if (!await validateResourceUrl(node.href, "link", security, "markdown-link")) {
2252
+ node.href = void 0;
2253
+ if (security.violationMode === "placeholder") {
2254
+ node.text = security.placeholderText || "[blocked]";
2255
+ node.items = [{
2256
+ type: "text",
2257
+ content: node.text
2258
+ }];
2259
+ }
2260
+ }
2261
+ }
2262
+ if (node.items?.length) await walk(node.items);
2263
+ }
2264
+ };
2265
+ await walk(elements);
2266
+ };
2267
+ /**
2268
+ * Replaces blocked image nodes with plain raw-text placeholders.
2269
+ * Used by `violationMode: 'placeholder'` to preserve layout continuity.
2270
+ */
2271
+ const convertBlockedImagesToPlaceholder = (elements, security) => {
2272
+ const placeholder = security.placeholderImageText || "[blocked image]";
2273
+ const walk = (nodes) => {
2274
+ for (const node of nodes) {
2275
+ if (node.type === "image" && !node.data) {
2276
+ node.type = "raw";
2277
+ node.content = placeholder;
2278
+ node.src = void 0;
2279
+ }
2280
+ if (node.items?.length) walk(node.items);
2281
+ }
2282
+ };
2283
+ walk(elements);
2284
+ };
2285
+ //#endregion
1685
2286
  //#region src/renderer/MdTextRender.ts
1686
2287
  /**
1687
2288
  * Renders parsed markdown text into jsPDF document.
@@ -1692,9 +2293,18 @@ const applyFooter = (doc, options, pageNum, totalPages) => {
1692
2293
  */
1693
2294
  const MdTextRender = async (doc, text, options) => {
1694
2295
  const validOptions = validateOptions(options);
2296
+ const security = validOptions.security || {};
2297
+ const guardTimeout = createTimeoutGuard(security);
2298
+ enforceMarkdownLimits(text, security);
2299
+ guardTimeout();
1695
2300
  const store = new RenderStore(validOptions);
1696
2301
  const parsedElements = await MdTextParser(text);
1697
- await prefetchImages(parsedElements);
2302
+ guardTimeout();
2303
+ enforceNestedDepthAndImageCount(parsedElements, security);
2304
+ await applyLinkPolicy(parsedElements, security);
2305
+ await prefetchImages(parsedElements, security);
2306
+ guardTimeout();
2307
+ if (security.enabled && security.violationMode === "placeholder") convertBlockedImagesToPlaceholder(parsedElements, security);
1698
2308
  const renderElement = (element, indentLevel = 0, store, hasRawBullet = false, start = 0, ordered = false) => {
1699
2309
  const indent = indentLevel * validOptions.page.indent;
1700
2310
  switch (element.type) {
@@ -1751,7 +2361,10 @@ const MdTextRender = async (doc, text, options) => {
1751
2361
  break;
1752
2362
  }
1753
2363
  };
1754
- for (const item of parsedElements) renderElement(item, 0, store);
2364
+ for (const item of parsedElements) {
2365
+ guardTimeout();
2366
+ renderElement(item, 0, store);
2367
+ }
1755
2368
  applyPageDecorations(doc, validOptions);
1756
2369
  validOptions.endCursorYHandler(store.Y);
1757
2370
  };
@@ -1759,6 +2372,7 @@ const MdTextRender = async (doc, text, options) => {
1759
2372
  exports.MdTextParser = MdTextParser;
1760
2373
  exports.MdTextRender = MdTextRender;
1761
2374
  exports.MdTokenType = MdTokenType;
2375
+ exports.SecurityViolationError = SecurityViolationError;
1762
2376
  exports.renderInlineContent = renderInlineContent;
1763
2377
  exports.renderPlainText = renderPlainText;
1764
2378
  exports.validateOptions = validateOptions;