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.mjs CHANGED
@@ -73,7 +73,8 @@ const IMAGE_WITH_ATTRS_REGEX = /(!\[[^\]]*\]\()([^)]+)(\))\s*\{([^}]+)\}/g;
73
73
  * - Quoted values: width="200.5" or width='200'
74
74
  * - Decimal values: width=200.5
75
75
  */
76
- const ATTR_PAIR_REGEX = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
76
+ const ATTR_PAIR_REGEX = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([\w.+-]+))/g;
77
+ const MAX_ATTR_BLOCK_LENGTH = 500;
77
78
  /** Valid alignment values */
78
79
  const VALID_ALIGNMENTS = [
79
80
  "left",
@@ -97,6 +98,11 @@ const encodeAttrsToFragment = (attrs) => {
97
98
  */
98
99
  const parseRawAttributes = (attrString) => {
99
100
  const attrs = {};
101
+ if (attrString.length > MAX_ATTR_BLOCK_LENGTH) {
102
+ console.warn(`[jspdf-md-renderer] Image attribute block too long (${attrString.length} chars), skipping attribute parsing.`);
103
+ return attrs;
104
+ }
105
+ ATTR_PAIR_REGEX.lastIndex = 0;
100
106
  let match;
101
107
  while ((match = ATTR_PAIR_REGEX.exec(attrString)) !== null) {
102
108
  const key = match[1].toLowerCase();
@@ -406,11 +412,367 @@ const withSavedDocState = (doc, fn) => {
406
412
  }
407
413
  };
408
414
  //#endregion
415
+ //#region src/types/security.ts
416
+ var SecurityViolationError = class extends Error {
417
+ constructor(violation) {
418
+ super(violation.message);
419
+ this.name = "SecurityViolationError";
420
+ this.violation = violation;
421
+ }
422
+ };
423
+ //#endregion
424
+ //#region src/security/security-policy.ts
425
+ const DEFAULT_SECURITY = {
426
+ enabled: false,
427
+ allowedLinkProtocols: [
428
+ "https:",
429
+ "http:",
430
+ "mailto:",
431
+ "tel:"
432
+ ],
433
+ disablePdfLinks: false,
434
+ allowRemoteImages: true,
435
+ allowedImageProtocols: ["https:", "http:"],
436
+ allowedImageDomains: void 0,
437
+ allowDataUrls: true,
438
+ allowSvgImages: true,
439
+ blockLocalhost: true,
440
+ blockPrivateIPs: true,
441
+ blockLinkLocalIPs: true,
442
+ blockMetadataIPs: true,
443
+ maxMarkdownLength: 5e5,
444
+ maxImageCount: 200,
445
+ maxImageSizeBytes: 10 * 1024 * 1024,
446
+ maxNestedDepth: 20,
447
+ renderTimeoutMs: 3e4,
448
+ violationMode: "skip",
449
+ placeholderText: "[blocked]",
450
+ placeholderImageText: "[blocked image]"
451
+ };
452
+ const normalizeProtocol = (v) => `${v.trim().toLowerCase().replace(/:$/, "")}:`;
453
+ const normalizeDomain = (v) => v.trim().toLowerCase();
454
+ const clampInteger = (value, min, max) => Math.min(max, Math.max(min, Math.floor(value)));
455
+ const isNodeEnvironment = () => typeof process !== "undefined" && process.versions != null && process.versions.node != null;
456
+ /**
457
+ * Merges user-provided security config with safe defaults and
458
+ * validates/clamps numeric and enum fields.
459
+ */
460
+ const normalizeSecurityOptions = (security) => {
461
+ if (!security) return { ...DEFAULT_SECURITY };
462
+ const merged = {
463
+ ...DEFAULT_SECURITY,
464
+ ...security,
465
+ allowedLinkProtocols: security.allowedLinkProtocols?.map(normalizeProtocol) ?? DEFAULT_SECURITY.allowedLinkProtocols,
466
+ allowedImageProtocols: security.allowedImageProtocols?.map(normalizeProtocol) ?? DEFAULT_SECURITY.allowedImageProtocols,
467
+ allowedImageDomains: security.allowedImageDomains !== void 0 ? security.allowedImageDomains.map(normalizeDomain) : DEFAULT_SECURITY.allowedImageDomains
468
+ };
469
+ if (![
470
+ "skip",
471
+ "throw",
472
+ "placeholder"
473
+ ].includes(merged.violationMode || "skip")) throw new Error("[jspdf-md-renderer] security.violationMode must be skip | throw | placeholder");
474
+ for (const field of [
475
+ "maxMarkdownLength",
476
+ "maxImageCount",
477
+ "maxImageSizeBytes",
478
+ "maxNestedDepth",
479
+ "renderTimeoutMs"
480
+ ]) {
481
+ const value = merged[field];
482
+ if (value !== void 0 && (!Number.isFinite(value) || value < 0)) throw new Error(`[jspdf-md-renderer] security.${field} must be a non-negative number`);
483
+ }
484
+ merged.maxMarkdownLength = clampInteger(merged.maxMarkdownLength || 0, 0, 5e6);
485
+ merged.maxImageCount = clampInteger(merged.maxImageCount || 0, 0, 1e4);
486
+ merged.maxImageSizeBytes = clampInteger(merged.maxImageSizeBytes || 0, 0, 100 * 1024 * 1024);
487
+ merged.maxNestedDepth = clampInteger(merged.maxNestedDepth || 0, 0, 100);
488
+ merged.renderTimeoutMs = clampInteger(merged.renderTimeoutMs || 0, 0, 3e5);
489
+ return merged;
490
+ };
491
+ const metadataHosts = new Set([
492
+ "metadata.google.internal",
493
+ "metadata",
494
+ "instance-data"
495
+ ]);
496
+ const isIPv4InCidr = (ip, cidrBase, cidrMask) => {
497
+ const toNum = (s) => s.split(".").reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0;
498
+ const ipNum = toNum(ip);
499
+ const baseNum = toNum(cidrBase);
500
+ const mask = cidrMask === 0 ? 0 : 4294967295 << 32 - cidrMask >>> 0;
501
+ return (ipNum & mask) === (baseNum & mask);
502
+ };
503
+ const isLocalhostHost = (host) => host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
504
+ const isPrivateIPv4 = (ip) => isIPv4InCidr(ip, "10.0.0.0", 8) || isIPv4InCidr(ip, "172.16.0.0", 12) || isIPv4InCidr(ip, "192.168.0.0", 16);
505
+ const isLinkLocalIPv4 = (ip) => isIPv4InCidr(ip, "169.254.0.0", 16);
506
+ const isMetadataIP = (ip) => ip === "169.254.169.254" || ip === "100.100.100.200";
507
+ /**
508
+ * Parses an IPv6 address string into a BigInt for range comparison.
509
+ * Supports compressed and IPv4-mapped forms.
510
+ */
511
+ const parseIPv6ToBigInt = (ip) => {
512
+ let stripped = ip.replace(/^\[|\]$/g, "").toLowerCase();
513
+ if (stripped.includes(".")) {
514
+ const lastColon = stripped.lastIndexOf(":");
515
+ if (lastColon < 0) return null;
516
+ const ipv4Part = stripped.slice(lastColon + 1);
517
+ const prefix = stripped.slice(0, lastColon);
518
+ const parts = ipv4Part.split(".").map(Number);
519
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return null;
520
+ stripped = `${prefix}:${(parts[0] << 8 | parts[1]).toString(16)}:${(parts[2] << 8 | parts[3]).toString(16)}`;
521
+ }
522
+ let expanded = stripped;
523
+ if (expanded.includes("::")) {
524
+ const parts = expanded.split("::");
525
+ if (parts.length !== 2) return null;
526
+ const leftGroups = parts[0] ? parts[0].split(":") : [];
527
+ const rightGroups = parts[1] ? parts[1].split(":") : [];
528
+ const missing = 8 - leftGroups.length - rightGroups.length;
529
+ if (missing < 0) return null;
530
+ expanded = [
531
+ ...leftGroups,
532
+ ...Array(missing).fill("0"),
533
+ ...rightGroups
534
+ ].join(":");
535
+ }
536
+ const groups = expanded.split(":");
537
+ if (groups.length !== 8) return null;
538
+ try {
539
+ return groups.reduce((acc, g) => {
540
+ const n = parseInt(g || "0", 16);
541
+ if (Number.isNaN(n)) throw new Error("invalid hex");
542
+ return (acc << 16n) + BigInt(n);
543
+ }, 0n);
544
+ } catch {
545
+ return null;
546
+ }
547
+ };
548
+ const MAX_IPV6 = (1n << 128n) - 1n;
549
+ const isIPv6InRange = (ip, prefixBigInt, prefixLength) => {
550
+ const ipNum = parseIPv6ToBigInt(ip);
551
+ if (ipNum === null) return false;
552
+ const mask = prefixLength === 0 ? 0n : MAX_IPV6 << BigInt(128 - prefixLength) & MAX_IPV6;
553
+ return (ipNum & mask) === (prefixBigInt & mask);
554
+ };
555
+ const isLoopbackIPv6 = (ip) => {
556
+ return parseIPv6ToBigInt(ip) === 1n;
557
+ };
558
+ const isUniqueLocalIPv6 = (ip) => isIPv6InRange(ip, 64512n << 112n, 7);
559
+ const isLinkLocalIPv6 = (ip) => isIPv6InRange(ip, 65152n << 112n, 10);
560
+ const extractIPv4Mapped = (ip) => {
561
+ const stripped = ip.replace(/^\[|\]$/g, "");
562
+ const dottedMatch = stripped.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
563
+ if (dottedMatch) return dottedMatch[1];
564
+ const ipNum = parseIPv6ToBigInt(stripped);
565
+ if (ipNum === null) return null;
566
+ if (ipNum >> 32n !== 65535n) return null;
567
+ const low32 = Number(ipNum & 4294967295n);
568
+ return `${low32 >>> 24 & 255}.${low32 >>> 16 & 255}.${low32 >>> 8 & 255}.${low32 & 255}`;
569
+ };
570
+ const isIPv4MappedPrivate = (ip) => {
571
+ const mapped = extractIPv4Mapped(ip);
572
+ return mapped ? isPrivateIPv4(mapped) : false;
573
+ };
574
+ const isIPv4MappedLinkLocal = (ip) => {
575
+ const mapped = extractIPv4Mapped(ip);
576
+ return mapped ? isLinkLocalIPv4(mapped) : false;
577
+ };
578
+ const isIPv4MappedMetadata = (ip) => {
579
+ const mapped = extractIPv4Mapped(ip);
580
+ return mapped ? isMetadataIP(mapped) : false;
581
+ };
582
+ /**
583
+ * Resolves a hostname to IP addresses.
584
+ * Returns null when resolution is unavailable (browser runtime).
585
+ */
586
+ const resolveHostToIPs = async (host) => {
587
+ const stripped = host.replace(/^\[|\]$/g, "");
588
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(stripped)) return [stripped];
589
+ if (stripped.includes(":")) return [stripped];
590
+ if (!isNodeEnvironment()) return null;
591
+ try {
592
+ return (await (await import("node:dns")).promises.lookup(stripped, { all: true })).map((entry) => entry.address);
593
+ } catch {
594
+ return [];
595
+ }
596
+ };
597
+ /**
598
+ * Returns the action the caller should take for the violating element.
599
+ * Throws SecurityViolationError when violationMode is 'throw'.
600
+ */
601
+ const handleSecurityViolation = (security, violation) => {
602
+ const fullViolation = {
603
+ ...violation,
604
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
605
+ };
606
+ try {
607
+ security.onSecurityViolation?.(fullViolation);
608
+ } catch (error) {
609
+ console.warn("[jspdf-md-renderer] security.onSecurityViolation callback failed:", error);
610
+ }
611
+ const mode = security.violationMode || "skip";
612
+ if (mode === "throw") throw new SecurityViolationError(fullViolation);
613
+ if (mode === "placeholder") return "placeholder";
614
+ return "skip";
615
+ };
616
+ const createViolation = (code, type, message, value, context) => ({
617
+ code,
618
+ type,
619
+ message,
620
+ value,
621
+ context
622
+ });
623
+ /**
624
+ * Returns true when:
625
+ * - allowedDomains is undefined (feature not configured, allow all), OR
626
+ * - host matches an entry in the allowlist.
627
+ *
628
+ * An explicitly empty allowedDomains array means no domains are permitted.
629
+ */
630
+ const isAllowedDomain = (host, allowedDomains) => {
631
+ if (allowedDomains === void 0) return true;
632
+ if (allowedDomains.length === 0) return false;
633
+ return allowedDomains.some((domain) => host === domain || host.endsWith(`.${domain}`));
634
+ };
635
+ const classifyUrl = (raw) => {
636
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return "explicitScheme";
637
+ if (raw.startsWith("//")) return "protocolRelative";
638
+ return "relativePath";
639
+ };
640
+ /**
641
+ * Validates link/image URLs against protocol/domain/SSRF rules.
642
+ *
643
+ * URL classification:
644
+ * - Protocol-relative (`//host/path`): treated as external absolute and validated.
645
+ * - Explicit scheme (`https://...`): fully validated.
646
+ * - Relative path (`/x`, `./x`, `../x`, `?x`, `#x`): allowed by default.
647
+ */
648
+ const validateResourceUrl = async (rawValue, type, security, context) => {
649
+ const urlClass = classifyUrl(rawValue);
650
+ if (urlClass === "relativePath") {
651
+ if (security.validateUrl) {
652
+ let relativeUrl;
653
+ try {
654
+ relativeUrl = new URL(rawValue, "https://relative.local");
655
+ } catch {
656
+ handleSecurityViolation(security, createViolation("INVALID_URL", type, "Invalid relative URL", rawValue, context));
657
+ return false;
658
+ }
659
+ if (!await security.validateUrl(relativeUrl, type)) {
660
+ handleSecurityViolation(security, createViolation("CUSTOM_VALIDATOR_BLOCKED", type, "Custom URL validator rejected relative URL", rawValue, context));
661
+ return false;
662
+ }
663
+ }
664
+ return true;
665
+ }
666
+ let canonicalRaw = rawValue;
667
+ if (urlClass === "protocolRelative") canonicalRaw = `https:${rawValue}`;
668
+ let parsed;
669
+ try {
670
+ parsed = new URL(canonicalRaw);
671
+ } catch {
672
+ handleSecurityViolation(security, createViolation("INVALID_URL", type, "Invalid URL", rawValue, context));
673
+ return false;
674
+ }
675
+ if (urlClass !== "protocolRelative") {
676
+ const protocol = normalizeProtocol(parsed.protocol);
677
+ if (!(type === "link" ? security.allowedLinkProtocols || DEFAULT_SECURITY.allowedLinkProtocols : security.allowedImageProtocols || DEFAULT_SECURITY.allowedImageProtocols).includes(protocol)) {
678
+ handleSecurityViolation(security, createViolation(type === "link" ? "LINK_PROTOCOL_BLOCKED" : "IMAGE_PROTOCOL_BLOCKED", type, `${type} protocol is blocked`, rawValue, context));
679
+ return false;
680
+ }
681
+ }
682
+ if (type === "image" && !isAllowedDomain(parsed.hostname.toLowerCase(), security.allowedImageDomains)) {
683
+ handleSecurityViolation(security, createViolation("IMAGE_DOMAIN_BLOCKED", type, "Image domain is blocked", rawValue, context));
684
+ return false;
685
+ }
686
+ const host = parsed.hostname.toLowerCase();
687
+ if (security.blockLocalhost && isLocalhostHost(host)) {
688
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "Localhost URL is blocked", rawValue, context));
689
+ return false;
690
+ }
691
+ if (security.blockMetadataIPs && metadataHosts.has(host)) {
692
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "Metadata host is blocked", rawValue, context));
693
+ return false;
694
+ }
695
+ const ips = await resolveHostToIPs(host);
696
+ if (ips === null) {
697
+ 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.");
698
+ } else for (const ip of ips) {
699
+ if (security.blockLocalhost && isLocalhostHost(ip)) {
700
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "Localhost IP is blocked", rawValue, context));
701
+ return false;
702
+ }
703
+ if (security.blockPrivateIPs && isPrivateIPv4(ip)) {
704
+ handleSecurityViolation(security, createViolation("PRIVATE_IP_BLOCKED", type, "Private IP is blocked", rawValue, context));
705
+ return false;
706
+ }
707
+ if (security.blockLinkLocalIPs && isLinkLocalIPv4(ip)) {
708
+ handleSecurityViolation(security, createViolation("LINK_LOCAL_IP_BLOCKED", type, "Link-local IP is blocked", rawValue, context));
709
+ return false;
710
+ }
711
+ if (security.blockMetadataIPs && isMetadataIP(ip)) {
712
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "Metadata IP is blocked", rawValue, context));
713
+ return false;
714
+ }
715
+ if (security.blockLocalhost && isLoopbackIPv6(ip)) {
716
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "IPv6 loopback is blocked", rawValue, context));
717
+ return false;
718
+ }
719
+ if (security.blockPrivateIPs && (isUniqueLocalIPv6(ip) || isIPv4MappedPrivate(ip))) {
720
+ handleSecurityViolation(security, createViolation("PRIVATE_IP_BLOCKED", type, "IPv6 private address is blocked", rawValue, context));
721
+ return false;
722
+ }
723
+ if (security.blockLinkLocalIPs && (isLinkLocalIPv6(ip) || isIPv4MappedLinkLocal(ip))) {
724
+ handleSecurityViolation(security, createViolation("LINK_LOCAL_IP_BLOCKED", type, "IPv6 link-local address is blocked", rawValue, context));
725
+ return false;
726
+ }
727
+ if (security.blockMetadataIPs && isIPv4MappedMetadata(ip)) {
728
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "IPv4-mapped metadata IP is blocked", rawValue, context));
729
+ return false;
730
+ }
731
+ }
732
+ if (security.validateUrl) {
733
+ if (!await security.validateUrl(parsed, type)) {
734
+ handleSecurityViolation(security, createViolation("CUSTOM_VALIDATOR_BLOCKED", type, "Custom URL validator rejected URL", rawValue, context));
735
+ return false;
736
+ }
737
+ }
738
+ return true;
739
+ };
740
+ /**
741
+ * Returns true when the value is a `data:` URL.
742
+ */
743
+ const isDataUrl = (value) => value.trim().toLowerCase().startsWith("data:");
744
+ /**
745
+ * Returns true when the value is an SVG data URL.
746
+ */
747
+ const isSvgDataUrl = (value) => {
748
+ const normalized = value.trim().toLowerCase();
749
+ return normalized.startsWith("data:image/svg+xml") || normalized.startsWith("data:image/svg");
750
+ };
751
+ //#endregion
409
752
  //#region src/utils/image-utils.ts
410
753
  /**
411
754
  * Standard DPI for web/screen pixels.
412
755
  */
413
756
  const DEFAULT_DPI = 96;
757
+ const getDataUrlPayloadByteSize = (dataUrl) => {
758
+ const commaIndex = dataUrl.indexOf(",");
759
+ if (commaIndex < 0) return null;
760
+ const metadata = dataUrl.slice(0, commaIndex).toLowerCase();
761
+ const payload = dataUrl.slice(commaIndex + 1);
762
+ if (metadata.includes(";base64")) {
763
+ const normalized = payload.replace(/\s/g, "");
764
+ const padding = normalized.match(/=*$/)?.[0].length ?? 0;
765
+ return Math.floor(normalized.length * 3 / 4) - padding;
766
+ }
767
+ try {
768
+ const decoded = decodeURIComponent(payload);
769
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(decoded).length;
770
+ if (typeof Buffer !== "undefined") return Buffer.from(decoded, "utf-8").byteLength;
771
+ return decoded.length;
772
+ } catch {
773
+ return null;
774
+ }
775
+ };
414
776
  /**
415
777
  * Converts pixel values to the document's unit system.
416
778
  * Uses 96 DPI as the standard web pixel density.
@@ -546,14 +908,84 @@ const calculateImageDimensions = (doc, element, maxWidth, maxHeight, docUnit = "
546
908
  * Recursively traverses parsed elements and loads image data for Image tokens.
547
909
  * @param elements - The parsed elements to process.
548
910
  */
549
- const prefetchImages = async (elements) => {
911
+ const prefetchImages = async (elements, security) => {
550
912
  for (const element of elements) {
551
913
  if (element.type === "image" && element.src) try {
552
- if (element.src.startsWith("data:")) element.data = element.src;
553
- else {
554
- const response = await fetch(element.src);
914
+ if (security?.enabled) if (isDataUrl(element.src)) {
915
+ if (isSvgDataUrl(element.src) && !security.allowSvgImages) {
916
+ handleSecurityViolation(security, {
917
+ code: "SVG_BLOCKED",
918
+ type: "image",
919
+ message: "SVG images are blocked",
920
+ value: element.src,
921
+ context: "image-src"
922
+ });
923
+ element.data = void 0;
924
+ element.src = void 0;
925
+ continue;
926
+ }
927
+ if (!security.allowDataUrls) {
928
+ handleSecurityViolation(security, {
929
+ code: "DATA_URL_BLOCKED",
930
+ type: "image",
931
+ message: "Data URLs are blocked for images",
932
+ value: element.src,
933
+ context: "image-src"
934
+ });
935
+ element.data = void 0;
936
+ element.src = void 0;
937
+ continue;
938
+ }
939
+ } else {
940
+ if (!security.allowRemoteImages) {
941
+ handleSecurityViolation(security, {
942
+ code: "IMAGE_PROTOCOL_BLOCKED",
943
+ type: "image",
944
+ message: "Remote images are disabled",
945
+ value: element.src,
946
+ context: "image-src"
947
+ });
948
+ element.data = void 0;
949
+ element.src = void 0;
950
+ continue;
951
+ }
952
+ if (!await validateResourceUrl(element.src, "image", security, "image-src")) {
953
+ element.data = void 0;
954
+ element.src = void 0;
955
+ continue;
956
+ }
957
+ }
958
+ if (element.src.startsWith("data:")) {
959
+ element.data = element.src;
960
+ const dataUrlBytes = getDataUrlPayloadByteSize(element.data);
961
+ if (security?.enabled && security.maxImageSizeBytes && dataUrlBytes !== null && dataUrlBytes > security.maxImageSizeBytes) {
962
+ handleSecurityViolation(security, {
963
+ code: "IMAGE_SIZE_EXCEEDED",
964
+ type: "image",
965
+ message: "Data URL image exceeds maxImageSizeBytes",
966
+ value: String(dataUrlBytes),
967
+ context: "data-url-size"
968
+ });
969
+ element.data = void 0;
970
+ element.src = void 0;
971
+ continue;
972
+ }
973
+ } else {
974
+ const response = await secureImageFetch(element.src, security);
555
975
  if (!response.ok) throw new Error(`Failed to fetch image: ${response.statusText}`);
556
976
  const blob = await response.blob();
977
+ if (security?.enabled && security.maxImageSizeBytes && blob.size > security.maxImageSizeBytes) {
978
+ handleSecurityViolation(security, {
979
+ code: "IMAGE_SIZE_EXCEEDED",
980
+ type: "image",
981
+ message: "Fetched image exceeds maxImageSizeBytes",
982
+ value: String(blob.size),
983
+ context: "blob-size"
984
+ });
985
+ element.data = void 0;
986
+ element.src = void 0;
987
+ continue;
988
+ }
557
989
  element.data = await new Promise((resolve, reject) => {
558
990
  const reader = new FileReader();
559
991
  reader.onloadend = () => {
@@ -589,10 +1021,22 @@ const prefetchImages = async (elements) => {
589
1021
  });
590
1022
  }
591
1023
  } catch (error) {
1024
+ if (error instanceof SecurityViolationError) throw error;
592
1025
  console.warn(`[jspdf-md-renderer] Warning: Failed to load image at ${element.src}. It will be skipped.`, error);
593
1026
  }
594
- if (element.items && element.items.length > 0) await prefetchImages(element.items);
1027
+ if (element.items && element.items.length > 0) await prefetchImages(element.items, security);
1028
+ }
1029
+ };
1030
+ /**
1031
+ * Best-effort remote image fetch hardening.
1032
+ * In Node, re-validates URL immediately before fetch to reduce DNS rebind window.
1033
+ * In browser runtimes, or when security is undefined/disabled, delegates to normal fetch.
1034
+ */
1035
+ const secureImageFetch = async (url, security) => {
1036
+ if (security?.enabled && isNodeEnvironment()) {
1037
+ if (!await validateResourceUrl(url, "image", security, "pre-fetch-recheck")) throw new Error(`[jspdf-md-renderer] URL blocked on pre-fetch recheck: ${url}`);
595
1038
  }
1039
+ return fetch(url);
596
1040
  };
597
1041
  //#endregion
598
1042
  //#region src/layout/wordSplitter.ts
@@ -935,10 +1379,12 @@ const renderPlainText = (doc, text, x, y, maxWidth, store, opts = {}) => {
935
1379
  const renderHeading = (doc, element, indent, store) => {
936
1380
  withSavedDocState(doc, () => {
937
1381
  const headingKey = `h${element?.depth ?? 1}`;
938
- const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultFontSize;
1382
+ const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultTitleFontSize;
939
1383
  const headingColor = store.options.heading?.[`${headingKey}Color`] ?? store.options.heading?.color ?? "#000000";
1384
+ const useBold = store.options.heading?.bold ?? true;
940
1385
  doc.setFontSize(fontSize);
941
- doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
1386
+ if (useBold) doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
1387
+ else doc.setFont(store.options.font.regular.name, store.options.font.regular.style || "normal");
942
1388
  doc.setTextColor(headingColor);
943
1389
  breakIfOverflow(doc, store, getCharHight(doc) * 1.8);
944
1390
  const maxWidth = store.options.page.maxContentWidth - indent;
@@ -1008,10 +1454,11 @@ const renderParagraph = (doc, element, indent, store, parentElementRenderer) =>
1008
1454
  //#region src/renderer/components/list.ts
1009
1455
  const renderList = (doc, element, indentLevel, store, parentElementRenderer) => {
1010
1456
  doc.setFontSize(store.options.page.defaultFontSize);
1457
+ const listItemGap = store.options.spacing?.betweenListItems ?? 0;
1011
1458
  for (const [i, point] of element?.items?.entries() ?? []) {
1012
1459
  const _start = element.ordered ? (element.start ?? 1) + i : void 0;
1013
1460
  parentElementRenderer(point, indentLevel + 1, store, true, _start, element.ordered);
1014
- if (i < (element.items?.length ?? 0) - 1) store.updateY(store.options.spacing?.betweenListItems ?? 0, "add");
1461
+ if (i < (element.items?.length ?? 0) - 1) store.updateY(listItemGap, "add");
1015
1462
  }
1016
1463
  store.updateY(store.options.spacing?.afterList ?? 3, "add");
1017
1464
  };
@@ -1021,9 +1468,9 @@ const renderList = (doc, element, indentLevel, store, parentElementRenderer) =>
1021
1468
  * Render a single list item, including bullets/numbering, inline text, and any nested lists.
1022
1469
  */
1023
1470
  const renderListItem = (doc, element, indentLevel, store, parentElementRenderer, start, ordered) => {
1024
- breakIfOverflow(doc, store, getCharHight(doc));
1025
1471
  const options = store.options;
1026
1472
  const listOpts = store.options.list ?? {};
1473
+ breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
1027
1474
  const baseIndent = indentLevel * (listOpts.indentSize ?? options.page.indent);
1028
1475
  const bullet = ordered ? `${start}. ` : listOpts.bulletChar ?? "• ";
1029
1476
  const xLeft = options.page.xpading;
@@ -1100,6 +1547,7 @@ const renderRawItem = (doc, element, indentLevel, store, hasRawBullet, parentEle
1100
1547
  }
1101
1548
  store.updateX(xLeft, "set");
1102
1549
  if (hasRawBullet && bullet) {
1550
+ breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
1103
1551
  const bulletWidth = doc.getTextWidth(bullet);
1104
1552
  const textMaxWidth = options.page.maxContentWidth - indent - bulletWidth;
1105
1553
  doc.setFont(options.font.regular.name, options.font.regular.style);
@@ -1218,7 +1666,9 @@ const renderCodeBlock = (doc, element, indentLevel, store) => {
1218
1666
  //#region src/renderer/components/blockquote.ts
1219
1667
  const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1220
1668
  const options = store.options;
1669
+ const bqOpts = store.options.blockquote ?? {};
1221
1670
  const savedDrawColor = doc.getDrawColor();
1671
+ const savedFillColor = doc.getFillColor();
1222
1672
  const savedLineWidth = doc.getLineWidth();
1223
1673
  const blockquoteIndent = indentLevel + 1;
1224
1674
  const currentX = store.X + indentLevel * options.page.indent;
@@ -1231,17 +1681,27 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1231
1681
  });
1232
1682
  const endY = store.lastContentY || store.Y;
1233
1683
  const endPage = doc.internal.getCurrentPageInfo().pageNumber;
1234
- const bqOpts = store.options.blockquote ?? {};
1235
1684
  const barColor = bqOpts.barColor ?? "#AAAAAA";
1236
1685
  const barWidth = bqOpts.barWidth ?? 1;
1237
1686
  doc.setDrawColor(barColor);
1238
1687
  doc.setLineWidth(barWidth);
1688
+ const bgColor = bqOpts.backgroundColor;
1239
1689
  for (let p = startPage; p <= endPage; p++) {
1240
1690
  doc.setPage(p);
1241
1691
  const isStart = p === startPage;
1242
1692
  const isEnd = p === endPage;
1243
1693
  const lineTop = isStart ? startY : options.page.topmargin;
1244
1694
  const lineBottom = isEnd ? endY : options.page.maxContentHeight;
1695
+ const lineHeight = Math.max(0, lineBottom - lineTop);
1696
+ if (bgColor && lineHeight > 0) {
1697
+ const bgX = barX + barWidth / 2;
1698
+ const bgW = options.page.maxContentWidth - (bgX - options.page.xpading);
1699
+ if (bgW > 0) {
1700
+ doc.setFillColor(bgColor);
1701
+ doc.rect(bgX, lineTop, bgW, lineHeight, "F");
1702
+ doc.setDrawColor(barColor);
1703
+ }
1704
+ }
1245
1705
  doc.line(barX, lineTop, barX, lineBottom);
1246
1706
  }
1247
1707
  store.recordContentY();
@@ -1249,6 +1709,7 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1249
1709
  const bqBottomSpacing = bqOpts.bottomSpacing ?? options.spacing?.afterBlockquote ?? options.page.lineSpace;
1250
1710
  store.updateY(bqBottomSpacing, "add");
1251
1711
  doc.setDrawColor(savedDrawColor);
1712
+ doc.setFillColor(savedFillColor);
1252
1713
  doc.setLineWidth(savedLineWidth);
1253
1714
  };
1254
1715
  //#endregion
@@ -1317,7 +1778,9 @@ const renderTable = (doc, element, indentLevel, store) => {
1317
1778
  return;
1318
1779
  }
1319
1780
  const options = store.options;
1320
- const marginLeft = options.page.xmargin + indentLevel * options.page.indent;
1781
+ const indent = indentLevel * options.page.indent;
1782
+ const marginLeft = options.page.xpading + indent;
1783
+ const availableWidth = Math.max(10, options.page.maxContentWidth - indent);
1321
1784
  ensureSpace(doc, store, 20);
1322
1785
  const columnCount = element.header.length;
1323
1786
  const rows = (element.rows ?? []).map((row) => {
@@ -1350,8 +1813,9 @@ const renderTable = (doc, element, indentLevel, store) => {
1350
1813
  startY: store.Y,
1351
1814
  margin: {
1352
1815
  left: marginLeft,
1353
- right: options.page.xmargin
1816
+ right: Math.max(0, doc.internal.pageSize.getWidth() - (marginLeft + availableWidth))
1354
1817
  },
1818
+ tableWidth: availableWidth,
1355
1819
  ...userTableOptions,
1356
1820
  didDrawPage: safeDidDrawPage,
1357
1821
  didDrawCell: safeDidDrawCell
@@ -1443,6 +1907,7 @@ var RenderStore = class {
1443
1907
  //#endregion
1444
1908
  //#region src/utils/options-validation.ts
1445
1909
  const DEFAULT_HEADING_SIZES = {
1910
+ bold: true,
1446
1911
  h1: 24,
1447
1912
  h2: 20,
1448
1913
  h3: 17,
@@ -1570,6 +2035,7 @@ const validateOptions = (options) => {
1570
2035
  afterTable: 3,
1571
2036
  ...options.spacing ?? {}
1572
2037
  };
2038
+ if (options.spacing?.betweenListItems === void 0) spacing.betweenListItems = list.itemSpacing ?? spacing.betweenListItems;
1573
2039
  [
1574
2040
  "afterHeading",
1575
2041
  "afterParagraph",
@@ -1603,6 +2069,7 @@ const validateOptions = (options) => {
1603
2069
  codeBlock,
1604
2070
  spacing,
1605
2071
  image,
2072
+ security: normalizeSecurityOptions(options.security),
1606
2073
  endCursorYHandler
1607
2074
  };
1608
2075
  };
@@ -1658,6 +2125,140 @@ const applyFooter = (doc, options, pageNum, totalPages) => {
1658
2125
  });
1659
2126
  };
1660
2127
  //#endregion
2128
+ //#region src/security/security-guards.ts
2129
+ /**
2130
+ * Enforces input-size limits before tokenization/rendering starts.
2131
+ * Violations are delegated to the configured violation handler.
2132
+ */
2133
+ const enforceMarkdownLimits = (text, security) => {
2134
+ if (!security.enabled) return;
2135
+ if ((security.maxMarkdownLength || 0) > 0 && text.length > (security.maxMarkdownLength || 0)) {
2136
+ const action = handleSecurityViolation(security, {
2137
+ code: "MARKDOWN_TOO_LARGE",
2138
+ type: "markdown",
2139
+ message: "Markdown length exceeds configured limit",
2140
+ value: String(text.length)
2141
+ });
2142
+ if (action === "skip" || action === "placeholder") throw new Error(`[jspdf-md-renderer] Markdown input rejected: length ${text.length} exceeds maxMarkdownLength ${security.maxMarkdownLength}.`);
2143
+ }
2144
+ };
2145
+ /**
2146
+ * Walks the parsed markdown tree and enforces structural limits:
2147
+ * nesting depth and image count.
2148
+ */
2149
+ const enforceNestedDepthAndImageCount = (elements, security) => {
2150
+ if (!security.enabled) return;
2151
+ let imageCount = 0;
2152
+ const maxDepth = security.maxNestedDepth || 0;
2153
+ const maxImageCount = security.maxImageCount || 0;
2154
+ const placeholderText = security.placeholderImageText || "[blocked image]";
2155
+ let imageLimitViolated = false;
2156
+ const sanitizeNodes = (nodes, depth) => {
2157
+ if (maxDepth > 0 && depth > maxDepth) {
2158
+ handleSecurityViolation(security, {
2159
+ code: "MAX_NESTED_DEPTH_EXCEEDED",
2160
+ type: "markdown",
2161
+ message: "Markdown nesting depth exceeds configured limit",
2162
+ value: String(depth)
2163
+ });
2164
+ return [];
2165
+ }
2166
+ const sanitized = [];
2167
+ for (const node of nodes) {
2168
+ if (node.type === "image") {
2169
+ imageCount++;
2170
+ if (maxImageCount > 0 && imageCount > maxImageCount) {
2171
+ if (!imageLimitViolated) {
2172
+ imageLimitViolated = true;
2173
+ handleSecurityViolation(security, {
2174
+ code: "MAX_IMAGE_COUNT_EXCEEDED",
2175
+ type: "image",
2176
+ message: "Image count exceeds configured limit",
2177
+ value: String(imageCount)
2178
+ });
2179
+ }
2180
+ if (security.violationMode === "placeholder") sanitized.push({
2181
+ type: "raw",
2182
+ content: placeholderText
2183
+ });
2184
+ continue;
2185
+ }
2186
+ }
2187
+ if (node.items?.length) node.items = sanitizeNodes(node.items, depth + 1);
2188
+ sanitized.push(node);
2189
+ }
2190
+ return sanitized;
2191
+ };
2192
+ const sanitizedRoot = sanitizeNodes(elements, 1);
2193
+ elements.length = 0;
2194
+ elements.push(...sanitizedRoot);
2195
+ };
2196
+ /**
2197
+ * Creates a lightweight timeout guard function for long render flows.
2198
+ * Call the returned function at checkpoints (parse, prefetch, render loop).
2199
+ */
2200
+ const createTimeoutGuard = (security) => {
2201
+ const timeoutAt = security.enabled && (security.renderTimeoutMs || 0) > 0 ? Date.now() + (security.renderTimeoutMs || 0) : 0;
2202
+ return () => {
2203
+ if (timeoutAt > 0 && Date.now() > timeoutAt) {
2204
+ const action = handleSecurityViolation(security, {
2205
+ code: "RENDER_TIMEOUT_EXCEEDED",
2206
+ type: "render",
2207
+ message: "Render time exceeded configured timeout",
2208
+ value: String(security.renderTimeoutMs)
2209
+ });
2210
+ if (action === "skip" || action === "placeholder") throw new Error(`[jspdf-md-renderer] Render aborted: exceeded renderTimeoutMs (${security.renderTimeoutMs}ms).`);
2211
+ }
2212
+ };
2213
+ };
2214
+ //#endregion
2215
+ //#region src/security/security-transforms.ts
2216
+ /**
2217
+ * Applies link security rules to parsed markdown elements.
2218
+ * Rejected links are downgraded to plain text by clearing `href`.
2219
+ * In placeholder mode, blocked links render `security.placeholderText`.
2220
+ */
2221
+ const applyLinkPolicy = async (elements, security) => {
2222
+ if (!security.enabled) return;
2223
+ const walk = async (nodes) => {
2224
+ for (const node of nodes) {
2225
+ if (node.type === "link" && node.href) {
2226
+ if (security.disablePdfLinks) node.href = void 0;
2227
+ else if (!await validateResourceUrl(node.href, "link", security, "markdown-link")) {
2228
+ node.href = void 0;
2229
+ if (security.violationMode === "placeholder") {
2230
+ node.text = security.placeholderText || "[blocked]";
2231
+ node.items = [{
2232
+ type: "text",
2233
+ content: node.text
2234
+ }];
2235
+ }
2236
+ }
2237
+ }
2238
+ if (node.items?.length) await walk(node.items);
2239
+ }
2240
+ };
2241
+ await walk(elements);
2242
+ };
2243
+ /**
2244
+ * Replaces blocked image nodes with plain raw-text placeholders.
2245
+ * Used by `violationMode: 'placeholder'` to preserve layout continuity.
2246
+ */
2247
+ const convertBlockedImagesToPlaceholder = (elements, security) => {
2248
+ const placeholder = security.placeholderImageText || "[blocked image]";
2249
+ const walk = (nodes) => {
2250
+ for (const node of nodes) {
2251
+ if (node.type === "image" && !node.data) {
2252
+ node.type = "raw";
2253
+ node.content = placeholder;
2254
+ node.src = void 0;
2255
+ }
2256
+ if (node.items?.length) walk(node.items);
2257
+ }
2258
+ };
2259
+ walk(elements);
2260
+ };
2261
+ //#endregion
1661
2262
  //#region src/renderer/MdTextRender.ts
1662
2263
  /**
1663
2264
  * Renders parsed markdown text into jsPDF document.
@@ -1668,9 +2269,18 @@ const applyFooter = (doc, options, pageNum, totalPages) => {
1668
2269
  */
1669
2270
  const MdTextRender = async (doc, text, options) => {
1670
2271
  const validOptions = validateOptions(options);
2272
+ const security = validOptions.security || {};
2273
+ const guardTimeout = createTimeoutGuard(security);
2274
+ enforceMarkdownLimits(text, security);
2275
+ guardTimeout();
1671
2276
  const store = new RenderStore(validOptions);
1672
2277
  const parsedElements = await MdTextParser(text);
1673
- await prefetchImages(parsedElements);
2278
+ guardTimeout();
2279
+ enforceNestedDepthAndImageCount(parsedElements, security);
2280
+ await applyLinkPolicy(parsedElements, security);
2281
+ await prefetchImages(parsedElements, security);
2282
+ guardTimeout();
2283
+ if (security.enabled && security.violationMode === "placeholder") convertBlockedImagesToPlaceholder(parsedElements, security);
1674
2284
  const renderElement = (element, indentLevel = 0, store, hasRawBullet = false, start = 0, ordered = false) => {
1675
2285
  const indent = indentLevel * validOptions.page.indent;
1676
2286
  switch (element.type) {
@@ -1727,9 +2337,12 @@ const MdTextRender = async (doc, text, options) => {
1727
2337
  break;
1728
2338
  }
1729
2339
  };
1730
- for (const item of parsedElements) renderElement(item, 0, store);
2340
+ for (const item of parsedElements) {
2341
+ guardTimeout();
2342
+ renderElement(item, 0, store);
2343
+ }
1731
2344
  applyPageDecorations(doc, validOptions);
1732
2345
  validOptions.endCursorYHandler(store.Y);
1733
2346
  };
1734
2347
  //#endregion
1735
- export { MdTextParser, MdTextRender, MdTokenType, renderInlineContent, renderPlainText, validateOptions };
2348
+ export { MdTextParser, MdTextRender, MdTokenType, SecurityViolationError, renderInlineContent, renderPlainText, validateOptions };