jspdf-md-renderer 4.0.0 → 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();
@@ -418,12 +424,384 @@
418
424
  const getCharHight = (doc) => {
419
425
  return doc.getFontSize() / doc.internal.scaleFactor;
420
426
  };
427
+ /**
428
+ * Saves the current jsPDF font, size, and text color, executes `fn`,
429
+ * then restores those properties — even if `fn` throws.
430
+ */
431
+ const withSavedDocState = (doc, fn) => {
432
+ const savedFont = doc.getFont();
433
+ const savedSize = doc.getFontSize();
434
+ const savedColor = doc.getTextColor();
435
+ try {
436
+ return fn();
437
+ } finally {
438
+ doc.setFont(savedFont.fontName, savedFont.fontStyle);
439
+ doc.setFontSize(savedSize);
440
+ doc.setTextColor(savedColor);
441
+ }
442
+ };
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
+ };
421
780
  //#endregion
422
781
  //#region src/utils/image-utils.ts
423
782
  /**
424
783
  * Standard DPI for web/screen pixels.
425
784
  */
426
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
+ };
427
805
  /**
428
806
  * Converts pixel values to the document's unit system.
429
807
  * Uses 96 DPI as the standard web pixel density.
@@ -441,6 +819,29 @@
441
819
  }
442
820
  };
443
821
  /**
822
+ * Detects the image format from a ParsedElement's data URI and source URL.
823
+ * Returns a format string suitable for jsPDF's addImage (e.g. 'PNG', 'JPEG').
824
+ */
825
+ const detectImageFormat = (element) => {
826
+ if (element.data) {
827
+ if (element.data.startsWith("data:image/png")) return "PNG";
828
+ if (element.data.startsWith("data:image/jpeg") || element.data.startsWith("data:image/jpg")) return "JPEG";
829
+ if (element.data.startsWith("data:image/webp")) return "WEBP";
830
+ if (element.data.startsWith("data:image/gif")) return "GIF";
831
+ }
832
+ if (element.src) {
833
+ const ext = element.src.split("?")[0].split("#")[0].split(".").pop()?.toUpperCase();
834
+ if (ext && [
835
+ "PNG",
836
+ "JPEG",
837
+ "JPG",
838
+ "WEBP",
839
+ "GIF"
840
+ ].includes(ext)) return ext === "JPG" ? "JPEG" : ext;
841
+ }
842
+ return "JPEG";
843
+ };
844
+ /**
444
845
  * Extracts width and height from an SVG data URI if possible.
445
846
  */
446
847
  const extractSvgDimensions = (dataUri) => {
@@ -536,14 +937,84 @@
536
937
  * Recursively traverses parsed elements and loads image data for Image tokens.
537
938
  * @param elements - The parsed elements to process.
538
939
  */
539
- const prefetchImages = async (elements) => {
940
+ const prefetchImages = async (elements, security) => {
540
941
  for (const element of elements) {
541
942
  if (element.type === "image" && element.src) try {
542
- if (element.src.startsWith("data:")) element.data = element.src;
543
- else {
544
- 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);
545
1004
  if (!response.ok) throw new Error(`Failed to fetch image: ${response.statusText}`);
546
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
+ }
547
1018
  element.data = await new Promise((resolve, reject) => {
548
1019
  const reader = new FileReader();
549
1020
  reader.onloadend = () => {
@@ -579,11 +1050,23 @@
579
1050
  });
580
1051
  }
581
1052
  } catch (error) {
1053
+ if (error instanceof SecurityViolationError) throw error;
582
1054
  console.warn(`[jspdf-md-renderer] Warning: Failed to load image at ${element.src}. It will be skipped.`, error);
583
1055
  }
584
- if (element.items && element.items.length > 0) await prefetchImages(element.items);
1056
+ if (element.items && element.items.length > 0) await prefetchImages(element.items, security);
585
1057
  }
586
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}`);
1067
+ }
1068
+ return fetch(url);
1069
+ };
587
1070
  //#endregion
588
1071
  //#region src/layout/wordSplitter.ts
589
1072
  /**
@@ -618,6 +1101,14 @@
618
1101
  const curSize = doc.getFontSize();
619
1102
  const boldFont = store.options.font.bold?.name || curFont;
620
1103
  const regularFont = store.options.font.regular?.name || curFont;
1104
+ const italicFont = store.options.font.italic || {
1105
+ name: regularFont,
1106
+ style: "italic"
1107
+ };
1108
+ const boldItalicFont = store.options.font.boldItalic || {
1109
+ name: italicFont.name,
1110
+ style: "bolditalic"
1111
+ };
621
1112
  const codeFont = store.options.font.code || {
622
1113
  name: "courier",
623
1114
  style: "normal"
@@ -627,10 +1118,10 @@
627
1118
  doc.setFont(boldFont, store.options.font.bold?.style || "bold");
628
1119
  break;
629
1120
  case "italic":
630
- doc.setFont(regularFont, "italic");
1121
+ doc.setFont(italicFont.name, italicFont.style);
631
1122
  break;
632
1123
  case "bolditalic":
633
- doc.setFont(boldFont, "bolditalic");
1124
+ doc.setFont(boldItalicFont.name, boldItalicFont.style);
634
1125
  break;
635
1126
  case "codespan":
636
1127
  doc.setFont(codeFont.name, codeFont.style);
@@ -836,23 +1327,19 @@
836
1327
  }
837
1328
  };
838
1329
  const renderSingleWord = (doc, word, x, y, store) => {
839
- const savedFont = doc.getFont();
840
- const savedSize = doc.getFontSize();
841
- const savedColor = doc.getTextColor();
842
- applyStyleToDoc(doc, word.style, store);
843
- if (word.isLink && word.linkColor) doc.setTextColor(...word.linkColor);
844
- if (word.isImage && word.imageElement?.data) renderInlineImage(doc, word, x, y);
845
- else {
846
- if (word.style === "codespan") renderCodespanBackground(doc, word, x, y, store);
847
- doc.text(word.text, x, y, { baseline: "top" });
848
- }
849
- if (word.isLink && word.href) {
850
- const h = word.isImage && word.imageHeight ? word.imageHeight : doc.getTextDimensions("H").h;
851
- doc.link(x, y, word.width, h, { url: word.href });
852
- }
853
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
854
- doc.setFontSize(savedSize);
855
- doc.setTextColor(savedColor);
1330
+ withSavedDocState(doc, () => {
1331
+ applyStyleToDoc(doc, word.style, store);
1332
+ if (word.isLink && word.linkColor) doc.setTextColor(...word.linkColor);
1333
+ if (word.isImage && word.imageElement?.data) renderInlineImage(doc, word, x, y);
1334
+ else {
1335
+ if (word.style === "codespan") renderCodespanBackground(doc, word, x, y, store);
1336
+ doc.text(word.text, x, y, { baseline: "top" });
1337
+ }
1338
+ if (word.isLink && word.href) {
1339
+ const h = word.isImage && word.imageHeight ? word.imageHeight : doc.getTextDimensions("H").h;
1340
+ doc.link(x, y, word.width, h, { url: word.href });
1341
+ }
1342
+ });
856
1343
  };
857
1344
  const renderCodespanBackground = (doc, word, x, y, store) => {
858
1345
  const opts = store.options.codespan ?? {};
@@ -866,10 +1353,7 @@
866
1353
  };
867
1354
  const renderInlineImage = (doc, word, x, y) => {
868
1355
  const el = word.imageElement;
869
- let fmt = "JPEG";
870
- if (el.data.startsWith("data:image/png")) fmt = "PNG";
871
- else if (el.data.startsWith("data:image/webp")) fmt = "WEBP";
872
- else if (el.data.startsWith("data:image/gif")) fmt = "GIF";
1356
+ const fmt = detectImageFormat(el);
873
1357
  if (word.width > 0 && (word.imageHeight || 0) > 0) doc.addImage(el.data, fmt, x, y, word.width, word.imageHeight);
874
1358
  };
875
1359
  //#endregion
@@ -922,29 +1406,28 @@
922
1406
  //#endregion
923
1407
  //#region src/renderer/components/heading.ts
924
1408
  const renderHeading = (doc, element, indent, store) => {
925
- const savedColor = doc.getTextColor();
926
- const headingKey = `h${element?.depth ?? 1}`;
927
- const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultFontSize;
928
- const headingColor = store.options.heading?.[`${headingKey}Color`] ?? store.options.heading?.color ?? "#000000";
929
- const savedSize = doc.getFontSize();
930
- doc.setFontSize(fontSize);
931
- doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
932
- doc.setTextColor(headingColor);
933
- breakIfOverflow(doc, store, getCharHight(doc) * 1.8);
934
- const maxWidth = store.options.page.maxContentWidth - indent;
935
- if (element.items && element.items.length > 0) renderInlineContent(doc, element.items, store.X + indent, store.Y, maxWidth, store, {
936
- alignment: "left",
937
- trimLastLine: true
938
- });
939
- else renderPlainText(doc, element?.content ?? "", store.X + indent, store.Y, maxWidth, store, {
940
- alignment: "left",
941
- trimLastLine: true
1409
+ withSavedDocState(doc, () => {
1410
+ const headingKey = `h${element?.depth ?? 1}`;
1411
+ const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultTitleFontSize;
1412
+ const headingColor = store.options.heading?.[`${headingKey}Color`] ?? store.options.heading?.color ?? "#000000";
1413
+ const useBold = store.options.heading?.bold ?? true;
1414
+ doc.setFontSize(fontSize);
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");
1417
+ doc.setTextColor(headingColor);
1418
+ breakIfOverflow(doc, store, getCharHight(doc) * 1.8);
1419
+ const maxWidth = store.options.page.maxContentWidth - indent;
1420
+ if (element.items && element.items.length > 0) renderInlineContent(doc, element.items, store.X + indent, store.Y, maxWidth, store, {
1421
+ alignment: "left",
1422
+ trimLastLine: true
1423
+ });
1424
+ else renderPlainText(doc, element?.content ?? "", store.X + indent, store.Y, maxWidth, store, {
1425
+ alignment: "left",
1426
+ trimLastLine: true
1427
+ });
1428
+ const bottomSpacing = store.options.heading?.bottomSpacing ?? store.options.spacing?.afterHeading ?? 2;
1429
+ store.updateY(bottomSpacing, "add");
942
1430
  });
943
- const bottomSpacing = store.options.heading?.bottomSpacing ?? store.options.spacing?.afterHeading ?? 2;
944
- store.updateY(bottomSpacing, "add");
945
- doc.setFontSize(savedSize);
946
- doc.setFont(store.options.font.regular.name, store.options.font.regular.style);
947
- doc.setTextColor(savedColor);
948
1431
  store.updateX(store.options.page.xpading, "set");
949
1432
  };
950
1433
  //#endregion
@@ -1000,10 +1483,11 @@
1000
1483
  //#region src/renderer/components/list.ts
1001
1484
  const renderList = (doc, element, indentLevel, store, parentElementRenderer) => {
1002
1485
  doc.setFontSize(store.options.page.defaultFontSize);
1486
+ const listItemGap = store.options.spacing?.betweenListItems ?? 0;
1003
1487
  for (const [i, point] of element?.items?.entries() ?? []) {
1004
1488
  const _start = element.ordered ? (element.start ?? 1) + i : void 0;
1005
1489
  parentElementRenderer(point, indentLevel + 1, store, true, _start, element.ordered);
1006
- 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");
1007
1491
  }
1008
1492
  store.updateY(store.options.spacing?.afterList ?? 3, "add");
1009
1493
  };
@@ -1013,9 +1497,9 @@
1013
1497
  * Render a single list item, including bullets/numbering, inline text, and any nested lists.
1014
1498
  */
1015
1499
  const renderListItem = (doc, element, indentLevel, store, parentElementRenderer, start, ordered) => {
1016
- breakIfOverflow(doc, store, getCharHight(doc));
1017
1500
  const options = store.options;
1018
1501
  const listOpts = store.options.list ?? {};
1502
+ breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
1019
1503
  const baseIndent = indentLevel * (listOpts.indentSize ?? options.page.indent);
1020
1504
  const bullet = ordered ? `${start}. ` : listOpts.bulletChar ?? "• ";
1021
1505
  const xLeft = options.page.xpading;
@@ -1092,6 +1576,7 @@
1092
1576
  }
1093
1577
  store.updateX(xLeft, "set");
1094
1578
  if (hasRawBullet && bullet) {
1579
+ breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
1095
1580
  const bulletWidth = doc.getTextWidth(bullet);
1096
1581
  const textMaxWidth = options.page.maxContentWidth - indent - bulletWidth;
1097
1582
  doc.setFont(options.font.regular.name, options.font.regular.style);
@@ -1210,7 +1695,9 @@
1210
1695
  //#region src/renderer/components/blockquote.ts
1211
1696
  const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1212
1697
  const options = store.options;
1698
+ const bqOpts = store.options.blockquote ?? {};
1213
1699
  const savedDrawColor = doc.getDrawColor();
1700
+ const savedFillColor = doc.getFillColor();
1214
1701
  const savedLineWidth = doc.getLineWidth();
1215
1702
  const blockquoteIndent = indentLevel + 1;
1216
1703
  const currentX = store.X + indentLevel * options.page.indent;
@@ -1223,17 +1710,27 @@
1223
1710
  });
1224
1711
  const endY = store.lastContentY || store.Y;
1225
1712
  const endPage = doc.internal.getCurrentPageInfo().pageNumber;
1226
- const bqOpts = store.options.blockquote ?? {};
1227
1713
  const barColor = bqOpts.barColor ?? "#AAAAAA";
1228
1714
  const barWidth = bqOpts.barWidth ?? 1;
1229
1715
  doc.setDrawColor(barColor);
1230
1716
  doc.setLineWidth(barWidth);
1717
+ const bgColor = bqOpts.backgroundColor;
1231
1718
  for (let p = startPage; p <= endPage; p++) {
1232
1719
  doc.setPage(p);
1233
1720
  const isStart = p === startPage;
1234
1721
  const isEnd = p === endPage;
1235
1722
  const lineTop = isStart ? startY : options.page.topmargin;
1236
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
+ }
1237
1734
  doc.line(barX, lineTop, barX, lineBottom);
1238
1735
  }
1239
1736
  store.recordContentY();
@@ -1241,34 +1738,12 @@
1241
1738
  const bqBottomSpacing = bqOpts.bottomSpacing ?? options.spacing?.afterBlockquote ?? options.page.lineSpace;
1242
1739
  store.updateY(bqBottomSpacing, "add");
1243
1740
  doc.setDrawColor(savedDrawColor);
1741
+ doc.setFillColor(savedFillColor);
1244
1742
  doc.setLineWidth(savedLineWidth);
1245
1743
  };
1246
1744
  //#endregion
1247
1745
  //#region src/renderer/components/image.ts
1248
1746
  /**
1249
- * Detects the image format from element data and source.
1250
- */
1251
- const detectImageFormat = (element) => {
1252
- if (element.data) {
1253
- if (element.data.startsWith("data:image/png")) return "PNG";
1254
- if (element.data.startsWith("data:image/jpeg") || element.data.startsWith("data:image/jpg")) return "JPEG";
1255
- if (element.data.startsWith("data:image/webp")) return "WEBP";
1256
- if (element.data.startsWith("data:image/webp")) return "WEBP";
1257
- if (element.data.startsWith("data:image/gif")) return "GIF";
1258
- }
1259
- if (element.src) {
1260
- const ext = element.src.split("?")[0].split("#")[0].split(".").pop()?.toUpperCase();
1261
- if (ext && [
1262
- "PNG",
1263
- "JPEG",
1264
- "JPG",
1265
- "WEBP",
1266
- "GIF"
1267
- ].includes(ext)) return ext === "JPG" ? "JPEG" : ext;
1268
- }
1269
- return "JPEG";
1270
- };
1271
- /**
1272
1747
  * Renders an image element into the jsPDF document with smart sizing and alignment.
1273
1748
  *
1274
1749
  * Sizing logic (in order of priority):
@@ -1332,7 +1807,9 @@
1332
1807
  return;
1333
1808
  }
1334
1809
  const options = store.options;
1335
- 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);
1336
1813
  ensureSpace(doc, store, 20);
1337
1814
  const columnCount = element.header.length;
1338
1815
  const rows = (element.rows ?? []).map((row) => {
@@ -1365,8 +1842,9 @@
1365
1842
  startY: store.Y,
1366
1843
  margin: {
1367
1844
  left: marginLeft,
1368
- right: options.page.xmargin
1845
+ right: Math.max(0, doc.internal.pageSize.getWidth() - (marginLeft + availableWidth))
1369
1846
  },
1847
+ tableWidth: availableWidth,
1370
1848
  ...userTableOptions,
1371
1849
  didDrawPage: safeDidDrawPage,
1372
1850
  didDrawCell: safeDidDrawCell
@@ -1458,6 +1936,7 @@
1458
1936
  //#endregion
1459
1937
  //#region src/utils/options-validation.ts
1460
1938
  const DEFAULT_HEADING_SIZES = {
1939
+ bold: true,
1461
1940
  h1: 24,
1462
1941
  h2: 20,
1463
1942
  h3: 17,
@@ -1479,6 +1958,14 @@
1479
1958
  name: "helvetica",
1480
1959
  style: "light"
1481
1960
  },
1961
+ italic: {
1962
+ name: "helvetica",
1963
+ style: "italic"
1964
+ },
1965
+ boldItalic: {
1966
+ name: "helvetica",
1967
+ style: "bolditalic"
1968
+ },
1482
1969
  code: {
1483
1970
  name: "courier",
1484
1971
  style: "normal"
@@ -1516,6 +2003,8 @@
1516
2003
  ...options.font
1517
2004
  };
1518
2005
  if (!font.bold?.name) font.bold = DEFAULT_FONT.bold;
2006
+ if (!font.italic?.name) font.italic = DEFAULT_FONT.italic;
2007
+ if (!font.boldItalic?.name) font.boldItalic = DEFAULT_FONT.boldItalic;
1519
2008
  if (!font.code?.name) font.code = DEFAULT_FONT.code;
1520
2009
  const heading = {
1521
2010
  ...DEFAULT_HEADING_SIZES,
@@ -1529,7 +2018,7 @@
1529
2018
  "h5",
1530
2019
  "h6"
1531
2020
  ].forEach((k) => {
1532
- if (heading[k] < 6 || heading[k] > 72) heading[k] = DEFAULT_HEADING_SIZES[k];
2021
+ if ((heading[k] ?? 0) < 6 || (heading[k] ?? 0) > 72) heading[k] = DEFAULT_HEADING_SIZES[k];
1533
2022
  });
1534
2023
  const codespan = {
1535
2024
  backgroundColor: "#EEEEEE",
@@ -1575,6 +2064,7 @@
1575
2064
  afterTable: 3,
1576
2065
  ...options.spacing ?? {}
1577
2066
  };
2067
+ if (options.spacing?.betweenListItems === void 0) spacing.betweenListItems = list.itemSpacing ?? spacing.betweenListItems;
1578
2068
  [
1579
2069
  "afterHeading",
1580
2070
  "afterParagraph",
@@ -1608,6 +2098,7 @@
1608
2098
  codeBlock,
1609
2099
  spacing,
1610
2100
  image,
2101
+ security: normalizeSecurityOptions(options.security),
1611
2102
  endCursorYHandler
1612
2103
  };
1613
2104
  };
@@ -1626,49 +2117,175 @@
1626
2117
  if (!hOpts) return;
1627
2118
  const text = typeof hOpts.text === "function" ? hOpts.text(pageNum, totalPages) : hOpts.text ?? "";
1628
2119
  if (!text.trim()) return;
1629
- const savedFont = doc.getFont();
1630
- const savedSize = doc.getFontSize();
1631
- const savedColor = doc.getTextColor();
1632
- doc.setFontSize(hOpts.fontSize ?? 9);
1633
- doc.setTextColor(hOpts.color ?? "#666666");
1634
- const y = hOpts.y ?? 5;
1635
- const align = hOpts.align ?? "center";
1636
- const pageWidth = doc.internal.pageSize.getWidth();
1637
- let x = pageWidth / 2;
1638
- if (align === "left") x = options.page.xmargin;
1639
- if (align === "right") x = pageWidth - options.page.xmargin;
1640
- doc.text(text, x, y, {
1641
- align,
1642
- baseline: "top"
2120
+ withSavedDocState(doc, () => {
2121
+ doc.setFontSize(hOpts.fontSize ?? 9);
2122
+ doc.setTextColor(hOpts.color ?? "#666666");
2123
+ const y = hOpts.y ?? 5;
2124
+ const align = hOpts.align ?? "center";
2125
+ const pageWidth = doc.internal.pageSize.getWidth();
2126
+ let x = pageWidth / 2;
2127
+ if (align === "left") x = options.page.xmargin;
2128
+ if (align === "right") x = pageWidth - options.page.xmargin;
2129
+ doc.text(text, x, y, {
2130
+ align,
2131
+ baseline: "top"
2132
+ });
1643
2133
  });
1644
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
1645
- doc.setFontSize(savedSize);
1646
- doc.setTextColor(savedColor);
1647
2134
  };
1648
2135
  const applyFooter = (doc, options, pageNum, totalPages) => {
1649
2136
  const fOpts = options.footer;
1650
2137
  if (!fOpts) return;
1651
2138
  const text = fOpts.showPageNumbers ? `Page ${pageNum} of ${totalPages}` : typeof fOpts.text === "function" ? fOpts.text(pageNum, totalPages) : fOpts.text ?? "";
1652
2139
  if (!text.trim()) return;
1653
- const savedFont = doc.getFont();
1654
- const savedSize = doc.getFontSize();
1655
- const savedColor = doc.getTextColor();
1656
- doc.setFontSize(fOpts.fontSize ?? 9);
1657
- doc.setTextColor(fOpts.color ?? "#666666");
1658
- const pageHeight = doc.internal.pageSize.getHeight();
1659
- const y = fOpts.y ?? pageHeight - 5;
1660
- const align = fOpts.align ?? "right";
1661
- const pageWidth = doc.internal.pageSize.getWidth();
1662
- let x = pageWidth / 2;
1663
- if (align === "left") x = options.page.xmargin;
1664
- if (align === "right") x = pageWidth - options.page.xmargin;
1665
- doc.text(text, x, y, {
1666
- align,
1667
- baseline: "bottom"
2140
+ withSavedDocState(doc, () => {
2141
+ doc.setFontSize(fOpts.fontSize ?? 9);
2142
+ doc.setTextColor(fOpts.color ?? "#666666");
2143
+ const pageHeight = doc.internal.pageSize.getHeight();
2144
+ const y = fOpts.y ?? pageHeight - 5;
2145
+ const align = fOpts.align ?? "right";
2146
+ const pageWidth = doc.internal.pageSize.getWidth();
2147
+ let x = pageWidth / 2;
2148
+ if (align === "left") x = options.page.xmargin;
2149
+ if (align === "right") x = pageWidth - options.page.xmargin;
2150
+ doc.text(text, x, y, {
2151
+ align,
2152
+ baseline: "bottom"
2153
+ });
1668
2154
  });
1669
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
1670
- doc.setFontSize(savedSize);
1671
- doc.setTextColor(savedColor);
2155
+ };
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);
1672
2289
  };
1673
2290
  //#endregion
1674
2291
  //#region src/renderer/MdTextRender.ts
@@ -1681,9 +2298,18 @@
1681
2298
  */
1682
2299
  const MdTextRender = async (doc, text, options) => {
1683
2300
  const validOptions = validateOptions(options);
2301
+ const security = validOptions.security || {};
2302
+ const guardTimeout = createTimeoutGuard(security);
2303
+ enforceMarkdownLimits(text, security);
2304
+ guardTimeout();
1684
2305
  const store = new RenderStore(validOptions);
1685
2306
  const parsedElements = await MdTextParser(text);
1686
- 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);
1687
2313
  const renderElement = (element, indentLevel = 0, store, hasRawBullet = false, start = 0, ordered = false) => {
1688
2314
  const indent = indentLevel * validOptions.page.indent;
1689
2315
  switch (element.type) {
@@ -1740,7 +2366,10 @@
1740
2366
  break;
1741
2367
  }
1742
2368
  };
1743
- for (const item of parsedElements) renderElement(item, 0, store);
2369
+ for (const item of parsedElements) {
2370
+ guardTimeout();
2371
+ renderElement(item, 0, store);
2372
+ }
1744
2373
  applyPageDecorations(doc, validOptions);
1745
2374
  validOptions.endCursorYHandler(store.Y);
1746
2375
  };
@@ -1748,6 +2377,7 @@
1748
2377
  exports.MdTextParser = MdTextParser;
1749
2378
  exports.MdTextRender = MdTextRender;
1750
2379
  exports.MdTokenType = MdTokenType;
2380
+ exports.SecurityViolationError = SecurityViolationError;
1751
2381
  exports.renderInlineContent = renderInlineContent;
1752
2382
  exports.renderPlainText = renderPlainText;
1753
2383
  exports.validateOptions = validateOptions;