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