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.js CHANGED
@@ -97,7 +97,8 @@ const IMAGE_WITH_ATTRS_REGEX = /(!\[[^\]]*\]\()([^)]+)(\))\s*\{([^}]+)\}/g;
97
97
  * - Quoted values: width="200.5" or width='200'
98
98
  * - Decimal values: width=200.5
99
99
  */
100
- const ATTR_PAIR_REGEX = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
100
+ const ATTR_PAIR_REGEX = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([\w.+-]+))/g;
101
+ const MAX_ATTR_BLOCK_LENGTH = 500;
101
102
  /** Valid alignment values */
102
103
  const VALID_ALIGNMENTS = [
103
104
  "left",
@@ -121,6 +122,11 @@ const encodeAttrsToFragment = (attrs) => {
121
122
  */
122
123
  const parseRawAttributes = (attrString) => {
123
124
  const attrs = {};
125
+ if (attrString.length > MAX_ATTR_BLOCK_LENGTH) {
126
+ console.warn(`[jspdf-md-renderer] Image attribute block too long (${attrString.length} chars), skipping attribute parsing.`);
127
+ return attrs;
128
+ }
129
+ ATTR_PAIR_REGEX.lastIndex = 0;
124
130
  let match;
125
131
  while ((match = ATTR_PAIR_REGEX.exec(attrString)) !== null) {
126
132
  const key = match[1].toLowerCase();
@@ -413,12 +419,384 @@ const ensureSpace = (doc, store, minHeight) => {
413
419
  const getCharHight = (doc) => {
414
420
  return doc.getFontSize() / doc.internal.scaleFactor;
415
421
  };
422
+ /**
423
+ * Saves the current jsPDF font, size, and text color, executes `fn`,
424
+ * then restores those properties — even if `fn` throws.
425
+ */
426
+ const withSavedDocState = (doc, fn) => {
427
+ const savedFont = doc.getFont();
428
+ const savedSize = doc.getFontSize();
429
+ const savedColor = doc.getTextColor();
430
+ try {
431
+ return fn();
432
+ } finally {
433
+ doc.setFont(savedFont.fontName, savedFont.fontStyle);
434
+ doc.setFontSize(savedSize);
435
+ doc.setTextColor(savedColor);
436
+ }
437
+ };
438
+ //#endregion
439
+ //#region src/types/security.ts
440
+ var SecurityViolationError = class extends Error {
441
+ constructor(violation) {
442
+ super(violation.message);
443
+ this.name = "SecurityViolationError";
444
+ this.violation = violation;
445
+ }
446
+ };
447
+ //#endregion
448
+ //#region src/security/security-policy.ts
449
+ const DEFAULT_SECURITY = {
450
+ enabled: false,
451
+ allowedLinkProtocols: [
452
+ "https:",
453
+ "http:",
454
+ "mailto:",
455
+ "tel:"
456
+ ],
457
+ disablePdfLinks: false,
458
+ allowRemoteImages: true,
459
+ allowedImageProtocols: ["https:", "http:"],
460
+ allowedImageDomains: void 0,
461
+ allowDataUrls: true,
462
+ allowSvgImages: true,
463
+ blockLocalhost: true,
464
+ blockPrivateIPs: true,
465
+ blockLinkLocalIPs: true,
466
+ blockMetadataIPs: true,
467
+ maxMarkdownLength: 5e5,
468
+ maxImageCount: 200,
469
+ maxImageSizeBytes: 10 * 1024 * 1024,
470
+ maxNestedDepth: 20,
471
+ renderTimeoutMs: 3e4,
472
+ violationMode: "skip",
473
+ placeholderText: "[blocked]",
474
+ placeholderImageText: "[blocked image]"
475
+ };
476
+ const normalizeProtocol = (v) => `${v.trim().toLowerCase().replace(/:$/, "")}:`;
477
+ const normalizeDomain = (v) => v.trim().toLowerCase();
478
+ const clampInteger = (value, min, max) => Math.min(max, Math.max(min, Math.floor(value)));
479
+ const isNodeEnvironment = () => typeof process !== "undefined" && process.versions != null && process.versions.node != null;
480
+ /**
481
+ * Merges user-provided security config with safe defaults and
482
+ * validates/clamps numeric and enum fields.
483
+ */
484
+ const normalizeSecurityOptions = (security) => {
485
+ if (!security) return { ...DEFAULT_SECURITY };
486
+ const merged = {
487
+ ...DEFAULT_SECURITY,
488
+ ...security,
489
+ allowedLinkProtocols: security.allowedLinkProtocols?.map(normalizeProtocol) ?? DEFAULT_SECURITY.allowedLinkProtocols,
490
+ allowedImageProtocols: security.allowedImageProtocols?.map(normalizeProtocol) ?? DEFAULT_SECURITY.allowedImageProtocols,
491
+ allowedImageDomains: security.allowedImageDomains !== void 0 ? security.allowedImageDomains.map(normalizeDomain) : DEFAULT_SECURITY.allowedImageDomains
492
+ };
493
+ if (![
494
+ "skip",
495
+ "throw",
496
+ "placeholder"
497
+ ].includes(merged.violationMode || "skip")) throw new Error("[jspdf-md-renderer] security.violationMode must be skip | throw | placeholder");
498
+ for (const field of [
499
+ "maxMarkdownLength",
500
+ "maxImageCount",
501
+ "maxImageSizeBytes",
502
+ "maxNestedDepth",
503
+ "renderTimeoutMs"
504
+ ]) {
505
+ const value = merged[field];
506
+ if (value !== void 0 && (!Number.isFinite(value) || value < 0)) throw new Error(`[jspdf-md-renderer] security.${field} must be a non-negative number`);
507
+ }
508
+ merged.maxMarkdownLength = clampInteger(merged.maxMarkdownLength || 0, 0, 5e6);
509
+ merged.maxImageCount = clampInteger(merged.maxImageCount || 0, 0, 1e4);
510
+ merged.maxImageSizeBytes = clampInteger(merged.maxImageSizeBytes || 0, 0, 100 * 1024 * 1024);
511
+ merged.maxNestedDepth = clampInteger(merged.maxNestedDepth || 0, 0, 100);
512
+ merged.renderTimeoutMs = clampInteger(merged.renderTimeoutMs || 0, 0, 3e5);
513
+ return merged;
514
+ };
515
+ const metadataHosts = new Set([
516
+ "metadata.google.internal",
517
+ "metadata",
518
+ "instance-data"
519
+ ]);
520
+ const isIPv4InCidr = (ip, cidrBase, cidrMask) => {
521
+ const toNum = (s) => s.split(".").reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0;
522
+ const ipNum = toNum(ip);
523
+ const baseNum = toNum(cidrBase);
524
+ const mask = cidrMask === 0 ? 0 : 4294967295 << 32 - cidrMask >>> 0;
525
+ return (ipNum & mask) === (baseNum & mask);
526
+ };
527
+ const isLocalhostHost = (host) => host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
528
+ const isPrivateIPv4 = (ip) => isIPv4InCidr(ip, "10.0.0.0", 8) || isIPv4InCidr(ip, "172.16.0.0", 12) || isIPv4InCidr(ip, "192.168.0.0", 16);
529
+ const isLinkLocalIPv4 = (ip) => isIPv4InCidr(ip, "169.254.0.0", 16);
530
+ const isMetadataIP = (ip) => ip === "169.254.169.254" || ip === "100.100.100.200";
531
+ /**
532
+ * Parses an IPv6 address string into a BigInt for range comparison.
533
+ * Supports compressed and IPv4-mapped forms.
534
+ */
535
+ const parseIPv6ToBigInt = (ip) => {
536
+ let stripped = ip.replace(/^\[|\]$/g, "").toLowerCase();
537
+ if (stripped.includes(".")) {
538
+ const lastColon = stripped.lastIndexOf(":");
539
+ if (lastColon < 0) return null;
540
+ const ipv4Part = stripped.slice(lastColon + 1);
541
+ const prefix = stripped.slice(0, lastColon);
542
+ const parts = ipv4Part.split(".").map(Number);
543
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return null;
544
+ stripped = `${prefix}:${(parts[0] << 8 | parts[1]).toString(16)}:${(parts[2] << 8 | parts[3]).toString(16)}`;
545
+ }
546
+ let expanded = stripped;
547
+ if (expanded.includes("::")) {
548
+ const parts = expanded.split("::");
549
+ if (parts.length !== 2) return null;
550
+ const leftGroups = parts[0] ? parts[0].split(":") : [];
551
+ const rightGroups = parts[1] ? parts[1].split(":") : [];
552
+ const missing = 8 - leftGroups.length - rightGroups.length;
553
+ if (missing < 0) return null;
554
+ expanded = [
555
+ ...leftGroups,
556
+ ...Array(missing).fill("0"),
557
+ ...rightGroups
558
+ ].join(":");
559
+ }
560
+ const groups = expanded.split(":");
561
+ if (groups.length !== 8) return null;
562
+ try {
563
+ return groups.reduce((acc, g) => {
564
+ const n = parseInt(g || "0", 16);
565
+ if (Number.isNaN(n)) throw new Error("invalid hex");
566
+ return (acc << 16n) + BigInt(n);
567
+ }, 0n);
568
+ } catch {
569
+ return null;
570
+ }
571
+ };
572
+ const MAX_IPV6 = (1n << 128n) - 1n;
573
+ const isIPv6InRange = (ip, prefixBigInt, prefixLength) => {
574
+ const ipNum = parseIPv6ToBigInt(ip);
575
+ if (ipNum === null) return false;
576
+ const mask = prefixLength === 0 ? 0n : MAX_IPV6 << BigInt(128 - prefixLength) & MAX_IPV6;
577
+ return (ipNum & mask) === (prefixBigInt & mask);
578
+ };
579
+ const isLoopbackIPv6 = (ip) => {
580
+ return parseIPv6ToBigInt(ip) === 1n;
581
+ };
582
+ const isUniqueLocalIPv6 = (ip) => isIPv6InRange(ip, 64512n << 112n, 7);
583
+ const isLinkLocalIPv6 = (ip) => isIPv6InRange(ip, 65152n << 112n, 10);
584
+ const extractIPv4Mapped = (ip) => {
585
+ const stripped = ip.replace(/^\[|\]$/g, "");
586
+ const dottedMatch = stripped.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
587
+ if (dottedMatch) return dottedMatch[1];
588
+ const ipNum = parseIPv6ToBigInt(stripped);
589
+ if (ipNum === null) return null;
590
+ if (ipNum >> 32n !== 65535n) return null;
591
+ const low32 = Number(ipNum & 4294967295n);
592
+ return `${low32 >>> 24 & 255}.${low32 >>> 16 & 255}.${low32 >>> 8 & 255}.${low32 & 255}`;
593
+ };
594
+ const isIPv4MappedPrivate = (ip) => {
595
+ const mapped = extractIPv4Mapped(ip);
596
+ return mapped ? isPrivateIPv4(mapped) : false;
597
+ };
598
+ const isIPv4MappedLinkLocal = (ip) => {
599
+ const mapped = extractIPv4Mapped(ip);
600
+ return mapped ? isLinkLocalIPv4(mapped) : false;
601
+ };
602
+ const isIPv4MappedMetadata = (ip) => {
603
+ const mapped = extractIPv4Mapped(ip);
604
+ return mapped ? isMetadataIP(mapped) : false;
605
+ };
606
+ /**
607
+ * Resolves a hostname to IP addresses.
608
+ * Returns null when resolution is unavailable (browser runtime).
609
+ */
610
+ const resolveHostToIPs = async (host) => {
611
+ const stripped = host.replace(/^\[|\]$/g, "");
612
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(stripped)) return [stripped];
613
+ if (stripped.includes(":")) return [stripped];
614
+ if (!isNodeEnvironment()) return null;
615
+ try {
616
+ return (await (await import("node:dns")).promises.lookup(stripped, { all: true })).map((entry) => entry.address);
617
+ } catch {
618
+ return [];
619
+ }
620
+ };
621
+ /**
622
+ * Returns the action the caller should take for the violating element.
623
+ * Throws SecurityViolationError when violationMode is 'throw'.
624
+ */
625
+ const handleSecurityViolation = (security, violation) => {
626
+ const fullViolation = {
627
+ ...violation,
628
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
629
+ };
630
+ try {
631
+ security.onSecurityViolation?.(fullViolation);
632
+ } catch (error) {
633
+ console.warn("[jspdf-md-renderer] security.onSecurityViolation callback failed:", error);
634
+ }
635
+ const mode = security.violationMode || "skip";
636
+ if (mode === "throw") throw new SecurityViolationError(fullViolation);
637
+ if (mode === "placeholder") return "placeholder";
638
+ return "skip";
639
+ };
640
+ const createViolation = (code, type, message, value, context) => ({
641
+ code,
642
+ type,
643
+ message,
644
+ value,
645
+ context
646
+ });
647
+ /**
648
+ * Returns true when:
649
+ * - allowedDomains is undefined (feature not configured, allow all), OR
650
+ * - host matches an entry in the allowlist.
651
+ *
652
+ * An explicitly empty allowedDomains array means no domains are permitted.
653
+ */
654
+ const isAllowedDomain = (host, allowedDomains) => {
655
+ if (allowedDomains === void 0) return true;
656
+ if (allowedDomains.length === 0) return false;
657
+ return allowedDomains.some((domain) => host === domain || host.endsWith(`.${domain}`));
658
+ };
659
+ const classifyUrl = (raw) => {
660
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return "explicitScheme";
661
+ if (raw.startsWith("//")) return "protocolRelative";
662
+ return "relativePath";
663
+ };
664
+ /**
665
+ * Validates link/image URLs against protocol/domain/SSRF rules.
666
+ *
667
+ * URL classification:
668
+ * - Protocol-relative (`//host/path`): treated as external absolute and validated.
669
+ * - Explicit scheme (`https://...`): fully validated.
670
+ * - Relative path (`/x`, `./x`, `../x`, `?x`, `#x`): allowed by default.
671
+ */
672
+ const validateResourceUrl = async (rawValue, type, security, context) => {
673
+ const urlClass = classifyUrl(rawValue);
674
+ if (urlClass === "relativePath") {
675
+ if (security.validateUrl) {
676
+ let relativeUrl;
677
+ try {
678
+ relativeUrl = new URL(rawValue, "https://relative.local");
679
+ } catch {
680
+ handleSecurityViolation(security, createViolation("INVALID_URL", type, "Invalid relative URL", rawValue, context));
681
+ return false;
682
+ }
683
+ if (!await security.validateUrl(relativeUrl, type)) {
684
+ handleSecurityViolation(security, createViolation("CUSTOM_VALIDATOR_BLOCKED", type, "Custom URL validator rejected relative URL", rawValue, context));
685
+ return false;
686
+ }
687
+ }
688
+ return true;
689
+ }
690
+ let canonicalRaw = rawValue;
691
+ if (urlClass === "protocolRelative") canonicalRaw = `https:${rawValue}`;
692
+ let parsed;
693
+ try {
694
+ parsed = new URL(canonicalRaw);
695
+ } catch {
696
+ handleSecurityViolation(security, createViolation("INVALID_URL", type, "Invalid URL", rawValue, context));
697
+ return false;
698
+ }
699
+ if (urlClass !== "protocolRelative") {
700
+ const protocol = normalizeProtocol(parsed.protocol);
701
+ if (!(type === "link" ? security.allowedLinkProtocols || DEFAULT_SECURITY.allowedLinkProtocols : security.allowedImageProtocols || DEFAULT_SECURITY.allowedImageProtocols).includes(protocol)) {
702
+ handleSecurityViolation(security, createViolation(type === "link" ? "LINK_PROTOCOL_BLOCKED" : "IMAGE_PROTOCOL_BLOCKED", type, `${type} protocol is blocked`, rawValue, context));
703
+ return false;
704
+ }
705
+ }
706
+ if (type === "image" && !isAllowedDomain(parsed.hostname.toLowerCase(), security.allowedImageDomains)) {
707
+ handleSecurityViolation(security, createViolation("IMAGE_DOMAIN_BLOCKED", type, "Image domain is blocked", rawValue, context));
708
+ return false;
709
+ }
710
+ const host = parsed.hostname.toLowerCase();
711
+ if (security.blockLocalhost && isLocalhostHost(host)) {
712
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "Localhost URL is blocked", rawValue, context));
713
+ return false;
714
+ }
715
+ if (security.blockMetadataIPs && metadataHosts.has(host)) {
716
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "Metadata host is blocked", rawValue, context));
717
+ return false;
718
+ }
719
+ const ips = await resolveHostToIPs(host);
720
+ if (ips === null) {
721
+ if (type === "image") console.warn("[jspdf-md-renderer] Security warning: IP-based SSRF checks (blockPrivateIPs, blockLinkLocalIPs, blockMetadataIPs) cannot be fully enforced in browser environments. Route image fetching through a trusted server-side proxy.");
722
+ } else for (const ip of ips) {
723
+ if (security.blockLocalhost && isLocalhostHost(ip)) {
724
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "Localhost IP is blocked", rawValue, context));
725
+ return false;
726
+ }
727
+ if (security.blockPrivateIPs && isPrivateIPv4(ip)) {
728
+ handleSecurityViolation(security, createViolation("PRIVATE_IP_BLOCKED", type, "Private IP is blocked", rawValue, context));
729
+ return false;
730
+ }
731
+ if (security.blockLinkLocalIPs && isLinkLocalIPv4(ip)) {
732
+ handleSecurityViolation(security, createViolation("LINK_LOCAL_IP_BLOCKED", type, "Link-local IP is blocked", rawValue, context));
733
+ return false;
734
+ }
735
+ if (security.blockMetadataIPs && isMetadataIP(ip)) {
736
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "Metadata IP is blocked", rawValue, context));
737
+ return false;
738
+ }
739
+ if (security.blockLocalhost && isLoopbackIPv6(ip)) {
740
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "IPv6 loopback is blocked", rawValue, context));
741
+ return false;
742
+ }
743
+ if (security.blockPrivateIPs && (isUniqueLocalIPv6(ip) || isIPv4MappedPrivate(ip))) {
744
+ handleSecurityViolation(security, createViolation("PRIVATE_IP_BLOCKED", type, "IPv6 private address is blocked", rawValue, context));
745
+ return false;
746
+ }
747
+ if (security.blockLinkLocalIPs && (isLinkLocalIPv6(ip) || isIPv4MappedLinkLocal(ip))) {
748
+ handleSecurityViolation(security, createViolation("LINK_LOCAL_IP_BLOCKED", type, "IPv6 link-local address is blocked", rawValue, context));
749
+ return false;
750
+ }
751
+ if (security.blockMetadataIPs && isIPv4MappedMetadata(ip)) {
752
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "IPv4-mapped metadata IP is blocked", rawValue, context));
753
+ return false;
754
+ }
755
+ }
756
+ if (security.validateUrl) {
757
+ if (!await security.validateUrl(parsed, type)) {
758
+ handleSecurityViolation(security, createViolation("CUSTOM_VALIDATOR_BLOCKED", type, "Custom URL validator rejected URL", rawValue, context));
759
+ return false;
760
+ }
761
+ }
762
+ return true;
763
+ };
764
+ /**
765
+ * Returns true when the value is a `data:` URL.
766
+ */
767
+ const isDataUrl = (value) => value.trim().toLowerCase().startsWith("data:");
768
+ /**
769
+ * Returns true when the value is an SVG data URL.
770
+ */
771
+ const isSvgDataUrl = (value) => {
772
+ const normalized = value.trim().toLowerCase();
773
+ return normalized.startsWith("data:image/svg+xml") || normalized.startsWith("data:image/svg");
774
+ };
416
775
  //#endregion
417
776
  //#region src/utils/image-utils.ts
418
777
  /**
419
778
  * Standard DPI for web/screen pixels.
420
779
  */
421
780
  const DEFAULT_DPI = 96;
781
+ const getDataUrlPayloadByteSize = (dataUrl) => {
782
+ const commaIndex = dataUrl.indexOf(",");
783
+ if (commaIndex < 0) return null;
784
+ const metadata = dataUrl.slice(0, commaIndex).toLowerCase();
785
+ const payload = dataUrl.slice(commaIndex + 1);
786
+ if (metadata.includes(";base64")) {
787
+ const normalized = payload.replace(/\s/g, "");
788
+ const padding = normalized.match(/=*$/)?.[0].length ?? 0;
789
+ return Math.floor(normalized.length * 3 / 4) - padding;
790
+ }
791
+ try {
792
+ const decoded = decodeURIComponent(payload);
793
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(decoded).length;
794
+ if (typeof Buffer !== "undefined") return Buffer.from(decoded, "utf-8").byteLength;
795
+ return decoded.length;
796
+ } catch {
797
+ return null;
798
+ }
799
+ };
422
800
  /**
423
801
  * Converts pixel values to the document's unit system.
424
802
  * Uses 96 DPI as the standard web pixel density.
@@ -436,6 +814,29 @@ const pxToDocUnit = (px, unit = "mm") => {
436
814
  }
437
815
  };
438
816
  /**
817
+ * Detects the image format from a ParsedElement's data URI and source URL.
818
+ * Returns a format string suitable for jsPDF's addImage (e.g. 'PNG', 'JPEG').
819
+ */
820
+ const detectImageFormat = (element) => {
821
+ if (element.data) {
822
+ if (element.data.startsWith("data:image/png")) return "PNG";
823
+ if (element.data.startsWith("data:image/jpeg") || element.data.startsWith("data:image/jpg")) return "JPEG";
824
+ if (element.data.startsWith("data:image/webp")) return "WEBP";
825
+ if (element.data.startsWith("data:image/gif")) return "GIF";
826
+ }
827
+ if (element.src) {
828
+ const ext = element.src.split("?")[0].split("#")[0].split(".").pop()?.toUpperCase();
829
+ if (ext && [
830
+ "PNG",
831
+ "JPEG",
832
+ "JPG",
833
+ "WEBP",
834
+ "GIF"
835
+ ].includes(ext)) return ext === "JPG" ? "JPEG" : ext;
836
+ }
837
+ return "JPEG";
838
+ };
839
+ /**
439
840
  * Extracts width and height from an SVG data URI if possible.
440
841
  */
441
842
  const extractSvgDimensions = (dataUri) => {
@@ -531,14 +932,84 @@ const calculateImageDimensions = (doc, element, maxWidth, maxHeight, docUnit = "
531
932
  * Recursively traverses parsed elements and loads image data for Image tokens.
532
933
  * @param elements - The parsed elements to process.
533
934
  */
534
- const prefetchImages = async (elements) => {
935
+ const prefetchImages = async (elements, security) => {
535
936
  for (const element of elements) {
536
937
  if (element.type === "image" && element.src) try {
537
- if (element.src.startsWith("data:")) element.data = element.src;
538
- else {
539
- const response = await fetch(element.src);
938
+ if (security?.enabled) if (isDataUrl(element.src)) {
939
+ if (isSvgDataUrl(element.src) && !security.allowSvgImages) {
940
+ handleSecurityViolation(security, {
941
+ code: "SVG_BLOCKED",
942
+ type: "image",
943
+ message: "SVG images are blocked",
944
+ value: element.src,
945
+ context: "image-src"
946
+ });
947
+ element.data = void 0;
948
+ element.src = void 0;
949
+ continue;
950
+ }
951
+ if (!security.allowDataUrls) {
952
+ handleSecurityViolation(security, {
953
+ code: "DATA_URL_BLOCKED",
954
+ type: "image",
955
+ message: "Data URLs are blocked for images",
956
+ value: element.src,
957
+ context: "image-src"
958
+ });
959
+ element.data = void 0;
960
+ element.src = void 0;
961
+ continue;
962
+ }
963
+ } else {
964
+ if (!security.allowRemoteImages) {
965
+ handleSecurityViolation(security, {
966
+ code: "IMAGE_PROTOCOL_BLOCKED",
967
+ type: "image",
968
+ message: "Remote images are disabled",
969
+ value: element.src,
970
+ context: "image-src"
971
+ });
972
+ element.data = void 0;
973
+ element.src = void 0;
974
+ continue;
975
+ }
976
+ if (!await validateResourceUrl(element.src, "image", security, "image-src")) {
977
+ element.data = void 0;
978
+ element.src = void 0;
979
+ continue;
980
+ }
981
+ }
982
+ if (element.src.startsWith("data:")) {
983
+ element.data = element.src;
984
+ const dataUrlBytes = getDataUrlPayloadByteSize(element.data);
985
+ if (security?.enabled && security.maxImageSizeBytes && dataUrlBytes !== null && dataUrlBytes > security.maxImageSizeBytes) {
986
+ handleSecurityViolation(security, {
987
+ code: "IMAGE_SIZE_EXCEEDED",
988
+ type: "image",
989
+ message: "Data URL image exceeds maxImageSizeBytes",
990
+ value: String(dataUrlBytes),
991
+ context: "data-url-size"
992
+ });
993
+ element.data = void 0;
994
+ element.src = void 0;
995
+ continue;
996
+ }
997
+ } else {
998
+ const response = await secureImageFetch(element.src, security);
540
999
  if (!response.ok) throw new Error(`Failed to fetch image: ${response.statusText}`);
541
1000
  const blob = await response.blob();
1001
+ if (security?.enabled && security.maxImageSizeBytes && blob.size > security.maxImageSizeBytes) {
1002
+ handleSecurityViolation(security, {
1003
+ code: "IMAGE_SIZE_EXCEEDED",
1004
+ type: "image",
1005
+ message: "Fetched image exceeds maxImageSizeBytes",
1006
+ value: String(blob.size),
1007
+ context: "blob-size"
1008
+ });
1009
+ element.data = void 0;
1010
+ element.src = void 0;
1011
+ continue;
1012
+ }
542
1013
  element.data = await new Promise((resolve, reject) => {
543
1014
  const reader = new FileReader();
544
1015
  reader.onloadend = () => {
@@ -574,11 +1045,23 @@ const prefetchImages = async (elements) => {
574
1045
  });
575
1046
  }
576
1047
  } catch (error) {
1048
+ if (error instanceof SecurityViolationError) throw error;
577
1049
  console.warn(`[jspdf-md-renderer] Warning: Failed to load image at ${element.src}. It will be skipped.`, error);
578
1050
  }
579
- if (element.items && element.items.length > 0) await prefetchImages(element.items);
1051
+ if (element.items && element.items.length > 0) await prefetchImages(element.items, security);
580
1052
  }
581
1053
  };
1054
+ /**
1055
+ * Best-effort remote image fetch hardening.
1056
+ * In Node, re-validates URL immediately before fetch to reduce DNS rebind window.
1057
+ * In browser runtimes, or when security is undefined/disabled, delegates to normal fetch.
1058
+ */
1059
+ const secureImageFetch = async (url, security) => {
1060
+ if (security?.enabled && isNodeEnvironment()) {
1061
+ if (!await validateResourceUrl(url, "image", security, "pre-fetch-recheck")) throw new Error(`[jspdf-md-renderer] URL blocked on pre-fetch recheck: ${url}`);
1062
+ }
1063
+ return fetch(url);
1064
+ };
582
1065
  //#endregion
583
1066
  //#region src/layout/wordSplitter.ts
584
1067
  /**
@@ -613,6 +1096,14 @@ const applyStyleToDoc = (doc, style, store) => {
613
1096
  const curSize = doc.getFontSize();
614
1097
  const boldFont = store.options.font.bold?.name || curFont;
615
1098
  const regularFont = store.options.font.regular?.name || curFont;
1099
+ const italicFont = store.options.font.italic || {
1100
+ name: regularFont,
1101
+ style: "italic"
1102
+ };
1103
+ const boldItalicFont = store.options.font.boldItalic || {
1104
+ name: italicFont.name,
1105
+ style: "bolditalic"
1106
+ };
616
1107
  const codeFont = store.options.font.code || {
617
1108
  name: "courier",
618
1109
  style: "normal"
@@ -622,10 +1113,10 @@ const applyStyleToDoc = (doc, style, store) => {
622
1113
  doc.setFont(boldFont, store.options.font.bold?.style || "bold");
623
1114
  break;
624
1115
  case "italic":
625
- doc.setFont(regularFont, "italic");
1116
+ doc.setFont(italicFont.name, italicFont.style);
626
1117
  break;
627
1118
  case "bolditalic":
628
- doc.setFont(boldFont, "bolditalic");
1119
+ doc.setFont(boldItalicFont.name, boldItalicFont.style);
629
1120
  break;
630
1121
  case "codespan":
631
1122
  doc.setFont(codeFont.name, codeFont.style);
@@ -831,23 +1322,19 @@ const renderLine = (doc, line, x, y, maxWidth, store, alignment = "left") => {
831
1322
  }
832
1323
  };
833
1324
  const renderSingleWord = (doc, word, x, y, store) => {
834
- const savedFont = doc.getFont();
835
- const savedSize = doc.getFontSize();
836
- const savedColor = doc.getTextColor();
837
- applyStyleToDoc(doc, word.style, store);
838
- if (word.isLink && word.linkColor) doc.setTextColor(...word.linkColor);
839
- if (word.isImage && word.imageElement?.data) renderInlineImage(doc, word, x, y);
840
- else {
841
- if (word.style === "codespan") renderCodespanBackground(doc, word, x, y, store);
842
- doc.text(word.text, x, y, { baseline: "top" });
843
- }
844
- if (word.isLink && word.href) {
845
- const h = word.isImage && word.imageHeight ? word.imageHeight : doc.getTextDimensions("H").h;
846
- doc.link(x, y, word.width, h, { url: word.href });
847
- }
848
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
849
- doc.setFontSize(savedSize);
850
- doc.setTextColor(savedColor);
1325
+ withSavedDocState(doc, () => {
1326
+ applyStyleToDoc(doc, word.style, store);
1327
+ if (word.isLink && word.linkColor) doc.setTextColor(...word.linkColor);
1328
+ if (word.isImage && word.imageElement?.data) renderInlineImage(doc, word, x, y);
1329
+ else {
1330
+ if (word.style === "codespan") renderCodespanBackground(doc, word, x, y, store);
1331
+ doc.text(word.text, x, y, { baseline: "top" });
1332
+ }
1333
+ if (word.isLink && word.href) {
1334
+ const h = word.isImage && word.imageHeight ? word.imageHeight : doc.getTextDimensions("H").h;
1335
+ doc.link(x, y, word.width, h, { url: word.href });
1336
+ }
1337
+ });
851
1338
  };
852
1339
  const renderCodespanBackground = (doc, word, x, y, store) => {
853
1340
  const opts = store.options.codespan ?? {};
@@ -861,10 +1348,7 @@ const renderCodespanBackground = (doc, word, x, y, store) => {
861
1348
  };
862
1349
  const renderInlineImage = (doc, word, x, y) => {
863
1350
  const el = word.imageElement;
864
- let fmt = "JPEG";
865
- if (el.data.startsWith("data:image/png")) fmt = "PNG";
866
- else if (el.data.startsWith("data:image/webp")) fmt = "WEBP";
867
- else if (el.data.startsWith("data:image/gif")) fmt = "GIF";
1351
+ const fmt = detectImageFormat(el);
868
1352
  if (word.width > 0 && (word.imageHeight || 0) > 0) doc.addImage(el.data, fmt, x, y, word.width, word.imageHeight);
869
1353
  };
870
1354
  //#endregion
@@ -917,29 +1401,28 @@ const renderPlainText = (doc, text, x, y, maxWidth, store, opts = {}) => {
917
1401
  //#endregion
918
1402
  //#region src/renderer/components/heading.ts
919
1403
  const renderHeading = (doc, element, indent, store) => {
920
- const savedColor = doc.getTextColor();
921
- const headingKey = `h${element?.depth ?? 1}`;
922
- const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultFontSize;
923
- const headingColor = store.options.heading?.[`${headingKey}Color`] ?? store.options.heading?.color ?? "#000000";
924
- const savedSize = doc.getFontSize();
925
- doc.setFontSize(fontSize);
926
- doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
927
- doc.setTextColor(headingColor);
928
- breakIfOverflow(doc, store, getCharHight(doc) * 1.8);
929
- const maxWidth = store.options.page.maxContentWidth - indent;
930
- if (element.items && element.items.length > 0) renderInlineContent(doc, element.items, store.X + indent, store.Y, maxWidth, store, {
931
- alignment: "left",
932
- trimLastLine: true
933
- });
934
- else renderPlainText(doc, element?.content ?? "", store.X + indent, store.Y, maxWidth, store, {
935
- alignment: "left",
936
- trimLastLine: true
1404
+ withSavedDocState(doc, () => {
1405
+ const headingKey = `h${element?.depth ?? 1}`;
1406
+ const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultTitleFontSize;
1407
+ const headingColor = store.options.heading?.[`${headingKey}Color`] ?? store.options.heading?.color ?? "#000000";
1408
+ const useBold = store.options.heading?.bold ?? true;
1409
+ doc.setFontSize(fontSize);
1410
+ if (useBold) doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
1411
+ else doc.setFont(store.options.font.regular.name, store.options.font.regular.style || "normal");
1412
+ doc.setTextColor(headingColor);
1413
+ breakIfOverflow(doc, store, getCharHight(doc) * 1.8);
1414
+ const maxWidth = store.options.page.maxContentWidth - indent;
1415
+ if (element.items && element.items.length > 0) renderInlineContent(doc, element.items, store.X + indent, store.Y, maxWidth, store, {
1416
+ alignment: "left",
1417
+ trimLastLine: true
1418
+ });
1419
+ else renderPlainText(doc, element?.content ?? "", store.X + indent, store.Y, maxWidth, store, {
1420
+ alignment: "left",
1421
+ trimLastLine: true
1422
+ });
1423
+ const bottomSpacing = store.options.heading?.bottomSpacing ?? store.options.spacing?.afterHeading ?? 2;
1424
+ store.updateY(bottomSpacing, "add");
937
1425
  });
938
- const bottomSpacing = store.options.heading?.bottomSpacing ?? store.options.spacing?.afterHeading ?? 2;
939
- store.updateY(bottomSpacing, "add");
940
- doc.setFontSize(savedSize);
941
- doc.setFont(store.options.font.regular.name, store.options.font.regular.style);
942
- doc.setTextColor(savedColor);
943
1426
  store.updateX(store.options.page.xpading, "set");
944
1427
  };
945
1428
  //#endregion
@@ -995,10 +1478,11 @@ const renderParagraph = (doc, element, indent, store, parentElementRenderer) =>
995
1478
  //#region src/renderer/components/list.ts
996
1479
  const renderList = (doc, element, indentLevel, store, parentElementRenderer) => {
997
1480
  doc.setFontSize(store.options.page.defaultFontSize);
1481
+ const listItemGap = store.options.spacing?.betweenListItems ?? 0;
998
1482
  for (const [i, point] of element?.items?.entries() ?? []) {
999
1483
  const _start = element.ordered ? (element.start ?? 1) + i : void 0;
1000
1484
  parentElementRenderer(point, indentLevel + 1, store, true, _start, element.ordered);
1001
- if (i < (element.items?.length ?? 0) - 1) store.updateY(store.options.spacing?.betweenListItems ?? 0, "add");
1485
+ if (i < (element.items?.length ?? 0) - 1) store.updateY(listItemGap, "add");
1002
1486
  }
1003
1487
  store.updateY(store.options.spacing?.afterList ?? 3, "add");
1004
1488
  };
@@ -1008,9 +1492,9 @@ const renderList = (doc, element, indentLevel, store, parentElementRenderer) =>
1008
1492
  * Render a single list item, including bullets/numbering, inline text, and any nested lists.
1009
1493
  */
1010
1494
  const renderListItem = (doc, element, indentLevel, store, parentElementRenderer, start, ordered) => {
1011
- breakIfOverflow(doc, store, getCharHight(doc));
1012
1495
  const options = store.options;
1013
1496
  const listOpts = store.options.list ?? {};
1497
+ breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
1014
1498
  const baseIndent = indentLevel * (listOpts.indentSize ?? options.page.indent);
1015
1499
  const bullet = ordered ? `${start}. ` : listOpts.bulletChar ?? "• ";
1016
1500
  const xLeft = options.page.xpading;
@@ -1087,6 +1571,7 @@ const renderRawItem = (doc, element, indentLevel, store, hasRawBullet, parentEle
1087
1571
  }
1088
1572
  store.updateX(xLeft, "set");
1089
1573
  if (hasRawBullet && bullet) {
1574
+ breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
1090
1575
  const bulletWidth = doc.getTextWidth(bullet);
1091
1576
  const textMaxWidth = options.page.maxContentWidth - indent - bulletWidth;
1092
1577
  doc.setFont(options.font.regular.name, options.font.regular.style);
@@ -1205,7 +1690,9 @@ const renderCodeBlock = (doc, element, indentLevel, store) => {
1205
1690
  //#region src/renderer/components/blockquote.ts
1206
1691
  const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1207
1692
  const options = store.options;
1693
+ const bqOpts = store.options.blockquote ?? {};
1208
1694
  const savedDrawColor = doc.getDrawColor();
1695
+ const savedFillColor = doc.getFillColor();
1209
1696
  const savedLineWidth = doc.getLineWidth();
1210
1697
  const blockquoteIndent = indentLevel + 1;
1211
1698
  const currentX = store.X + indentLevel * options.page.indent;
@@ -1218,17 +1705,27 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1218
1705
  });
1219
1706
  const endY = store.lastContentY || store.Y;
1220
1707
  const endPage = doc.internal.getCurrentPageInfo().pageNumber;
1221
- const bqOpts = store.options.blockquote ?? {};
1222
1708
  const barColor = bqOpts.barColor ?? "#AAAAAA";
1223
1709
  const barWidth = bqOpts.barWidth ?? 1;
1224
1710
  doc.setDrawColor(barColor);
1225
1711
  doc.setLineWidth(barWidth);
1712
+ const bgColor = bqOpts.backgroundColor;
1226
1713
  for (let p = startPage; p <= endPage; p++) {
1227
1714
  doc.setPage(p);
1228
1715
  const isStart = p === startPage;
1229
1716
  const isEnd = p === endPage;
1230
1717
  const lineTop = isStart ? startY : options.page.topmargin;
1231
1718
  const lineBottom = isEnd ? endY : options.page.maxContentHeight;
1719
+ const lineHeight = Math.max(0, lineBottom - lineTop);
1720
+ if (bgColor && lineHeight > 0) {
1721
+ const bgX = barX + barWidth / 2;
1722
+ const bgW = options.page.maxContentWidth - (bgX - options.page.xpading);
1723
+ if (bgW > 0) {
1724
+ doc.setFillColor(bgColor);
1725
+ doc.rect(bgX, lineTop, bgW, lineHeight, "F");
1726
+ doc.setDrawColor(barColor);
1727
+ }
1728
+ }
1232
1729
  doc.line(barX, lineTop, barX, lineBottom);
1233
1730
  }
1234
1731
  store.recordContentY();
@@ -1236,34 +1733,12 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1236
1733
  const bqBottomSpacing = bqOpts.bottomSpacing ?? options.spacing?.afterBlockquote ?? options.page.lineSpace;
1237
1734
  store.updateY(bqBottomSpacing, "add");
1238
1735
  doc.setDrawColor(savedDrawColor);
1736
+ doc.setFillColor(savedFillColor);
1239
1737
  doc.setLineWidth(savedLineWidth);
1240
1738
  };
1241
1739
  //#endregion
1242
1740
  //#region src/renderer/components/image.ts
1243
1741
  /**
1244
- * Detects the image format from element data and source.
1245
- */
1246
- const detectImageFormat = (element) => {
1247
- if (element.data) {
1248
- if (element.data.startsWith("data:image/png")) return "PNG";
1249
- if (element.data.startsWith("data:image/jpeg") || element.data.startsWith("data:image/jpg")) return "JPEG";
1250
- if (element.data.startsWith("data:image/webp")) return "WEBP";
1251
- if (element.data.startsWith("data:image/webp")) return "WEBP";
1252
- if (element.data.startsWith("data:image/gif")) return "GIF";
1253
- }
1254
- if (element.src) {
1255
- const ext = element.src.split("?")[0].split("#")[0].split(".").pop()?.toUpperCase();
1256
- if (ext && [
1257
- "PNG",
1258
- "JPEG",
1259
- "JPG",
1260
- "WEBP",
1261
- "GIF"
1262
- ].includes(ext)) return ext === "JPG" ? "JPEG" : ext;
1263
- }
1264
- return "JPEG";
1265
- };
1266
- /**
1267
1742
  * Renders an image element into the jsPDF document with smart sizing and alignment.
1268
1743
  *
1269
1744
  * Sizing logic (in order of priority):
@@ -1327,7 +1802,9 @@ const renderTable = (doc, element, indentLevel, store) => {
1327
1802
  return;
1328
1803
  }
1329
1804
  const options = store.options;
1330
- const marginLeft = options.page.xmargin + indentLevel * options.page.indent;
1805
+ const indent = indentLevel * options.page.indent;
1806
+ const marginLeft = options.page.xpading + indent;
1807
+ const availableWidth = Math.max(10, options.page.maxContentWidth - indent);
1331
1808
  ensureSpace(doc, store, 20);
1332
1809
  const columnCount = element.header.length;
1333
1810
  const rows = (element.rows ?? []).map((row) => {
@@ -1360,8 +1837,9 @@ const renderTable = (doc, element, indentLevel, store) => {
1360
1837
  startY: store.Y,
1361
1838
  margin: {
1362
1839
  left: marginLeft,
1363
- right: options.page.xmargin
1840
+ right: Math.max(0, doc.internal.pageSize.getWidth() - (marginLeft + availableWidth))
1364
1841
  },
1842
+ tableWidth: availableWidth,
1365
1843
  ...userTableOptions,
1366
1844
  didDrawPage: safeDidDrawPage,
1367
1845
  didDrawCell: safeDidDrawCell
@@ -1453,6 +1931,7 @@ var RenderStore = class {
1453
1931
  //#endregion
1454
1932
  //#region src/utils/options-validation.ts
1455
1933
  const DEFAULT_HEADING_SIZES = {
1934
+ bold: true,
1456
1935
  h1: 24,
1457
1936
  h2: 20,
1458
1937
  h3: 17,
@@ -1474,6 +1953,14 @@ const DEFAULT_FONT = {
1474
1953
  name: "helvetica",
1475
1954
  style: "light"
1476
1955
  },
1956
+ italic: {
1957
+ name: "helvetica",
1958
+ style: "italic"
1959
+ },
1960
+ boldItalic: {
1961
+ name: "helvetica",
1962
+ style: "bolditalic"
1963
+ },
1477
1964
  code: {
1478
1965
  name: "courier",
1479
1966
  style: "normal"
@@ -1511,6 +1998,8 @@ const validateOptions = (options) => {
1511
1998
  ...options.font
1512
1999
  };
1513
2000
  if (!font.bold?.name) font.bold = DEFAULT_FONT.bold;
2001
+ if (!font.italic?.name) font.italic = DEFAULT_FONT.italic;
2002
+ if (!font.boldItalic?.name) font.boldItalic = DEFAULT_FONT.boldItalic;
1514
2003
  if (!font.code?.name) font.code = DEFAULT_FONT.code;
1515
2004
  const heading = {
1516
2005
  ...DEFAULT_HEADING_SIZES,
@@ -1524,7 +2013,7 @@ const validateOptions = (options) => {
1524
2013
  "h5",
1525
2014
  "h6"
1526
2015
  ].forEach((k) => {
1527
- if (heading[k] < 6 || heading[k] > 72) heading[k] = DEFAULT_HEADING_SIZES[k];
2016
+ if ((heading[k] ?? 0) < 6 || (heading[k] ?? 0) > 72) heading[k] = DEFAULT_HEADING_SIZES[k];
1528
2017
  });
1529
2018
  const codespan = {
1530
2019
  backgroundColor: "#EEEEEE",
@@ -1570,6 +2059,7 @@ const validateOptions = (options) => {
1570
2059
  afterTable: 3,
1571
2060
  ...options.spacing ?? {}
1572
2061
  };
2062
+ if (options.spacing?.betweenListItems === void 0) spacing.betweenListItems = list.itemSpacing ?? spacing.betweenListItems;
1573
2063
  [
1574
2064
  "afterHeading",
1575
2065
  "afterParagraph",
@@ -1603,6 +2093,7 @@ const validateOptions = (options) => {
1603
2093
  codeBlock,
1604
2094
  spacing,
1605
2095
  image,
2096
+ security: normalizeSecurityOptions(options.security),
1606
2097
  endCursorYHandler
1607
2098
  };
1608
2099
  };
@@ -1621,49 +2112,175 @@ const applyHeader = (doc, options, pageNum, totalPages) => {
1621
2112
  if (!hOpts) return;
1622
2113
  const text = typeof hOpts.text === "function" ? hOpts.text(pageNum, totalPages) : hOpts.text ?? "";
1623
2114
  if (!text.trim()) return;
1624
- const savedFont = doc.getFont();
1625
- const savedSize = doc.getFontSize();
1626
- const savedColor = doc.getTextColor();
1627
- doc.setFontSize(hOpts.fontSize ?? 9);
1628
- doc.setTextColor(hOpts.color ?? "#666666");
1629
- const y = hOpts.y ?? 5;
1630
- const align = hOpts.align ?? "center";
1631
- const pageWidth = doc.internal.pageSize.getWidth();
1632
- let x = pageWidth / 2;
1633
- if (align === "left") x = options.page.xmargin;
1634
- if (align === "right") x = pageWidth - options.page.xmargin;
1635
- doc.text(text, x, y, {
1636
- align,
1637
- baseline: "top"
2115
+ withSavedDocState(doc, () => {
2116
+ doc.setFontSize(hOpts.fontSize ?? 9);
2117
+ doc.setTextColor(hOpts.color ?? "#666666");
2118
+ const y = hOpts.y ?? 5;
2119
+ const align = hOpts.align ?? "center";
2120
+ const pageWidth = doc.internal.pageSize.getWidth();
2121
+ let x = pageWidth / 2;
2122
+ if (align === "left") x = options.page.xmargin;
2123
+ if (align === "right") x = pageWidth - options.page.xmargin;
2124
+ doc.text(text, x, y, {
2125
+ align,
2126
+ baseline: "top"
2127
+ });
1638
2128
  });
1639
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
1640
- doc.setFontSize(savedSize);
1641
- doc.setTextColor(savedColor);
1642
2129
  };
1643
2130
  const applyFooter = (doc, options, pageNum, totalPages) => {
1644
2131
  const fOpts = options.footer;
1645
2132
  if (!fOpts) return;
1646
2133
  const text = fOpts.showPageNumbers ? `Page ${pageNum} of ${totalPages}` : typeof fOpts.text === "function" ? fOpts.text(pageNum, totalPages) : fOpts.text ?? "";
1647
2134
  if (!text.trim()) return;
1648
- const savedFont = doc.getFont();
1649
- const savedSize = doc.getFontSize();
1650
- const savedColor = doc.getTextColor();
1651
- doc.setFontSize(fOpts.fontSize ?? 9);
1652
- doc.setTextColor(fOpts.color ?? "#666666");
1653
- const pageHeight = doc.internal.pageSize.getHeight();
1654
- const y = fOpts.y ?? pageHeight - 5;
1655
- const align = fOpts.align ?? "right";
1656
- const pageWidth = doc.internal.pageSize.getWidth();
1657
- let x = pageWidth / 2;
1658
- if (align === "left") x = options.page.xmargin;
1659
- if (align === "right") x = pageWidth - options.page.xmargin;
1660
- doc.text(text, x, y, {
1661
- align,
1662
- baseline: "bottom"
2135
+ withSavedDocState(doc, () => {
2136
+ doc.setFontSize(fOpts.fontSize ?? 9);
2137
+ doc.setTextColor(fOpts.color ?? "#666666");
2138
+ const pageHeight = doc.internal.pageSize.getHeight();
2139
+ const y = fOpts.y ?? pageHeight - 5;
2140
+ const align = fOpts.align ?? "right";
2141
+ const pageWidth = doc.internal.pageSize.getWidth();
2142
+ let x = pageWidth / 2;
2143
+ if (align === "left") x = options.page.xmargin;
2144
+ if (align === "right") x = pageWidth - options.page.xmargin;
2145
+ doc.text(text, x, y, {
2146
+ align,
2147
+ baseline: "bottom"
2148
+ });
1663
2149
  });
1664
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
1665
- doc.setFontSize(savedSize);
1666
- doc.setTextColor(savedColor);
2150
+ };
2151
+ //#endregion
2152
+ //#region src/security/security-guards.ts
2153
+ /**
2154
+ * Enforces input-size limits before tokenization/rendering starts.
2155
+ * Violations are delegated to the configured violation handler.
2156
+ */
2157
+ const enforceMarkdownLimits = (text, security) => {
2158
+ if (!security.enabled) return;
2159
+ if ((security.maxMarkdownLength || 0) > 0 && text.length > (security.maxMarkdownLength || 0)) {
2160
+ const action = handleSecurityViolation(security, {
2161
+ code: "MARKDOWN_TOO_LARGE",
2162
+ type: "markdown",
2163
+ message: "Markdown length exceeds configured limit",
2164
+ value: String(text.length)
2165
+ });
2166
+ if (action === "skip" || action === "placeholder") throw new Error(`[jspdf-md-renderer] Markdown input rejected: length ${text.length} exceeds maxMarkdownLength ${security.maxMarkdownLength}.`);
2167
+ }
2168
+ };
2169
+ /**
2170
+ * Walks the parsed markdown tree and enforces structural limits:
2171
+ * nesting depth and image count.
2172
+ */
2173
+ const enforceNestedDepthAndImageCount = (elements, security) => {
2174
+ if (!security.enabled) return;
2175
+ let imageCount = 0;
2176
+ const maxDepth = security.maxNestedDepth || 0;
2177
+ const maxImageCount = security.maxImageCount || 0;
2178
+ const placeholderText = security.placeholderImageText || "[blocked image]";
2179
+ let imageLimitViolated = false;
2180
+ const sanitizeNodes = (nodes, depth) => {
2181
+ if (maxDepth > 0 && depth > maxDepth) {
2182
+ handleSecurityViolation(security, {
2183
+ code: "MAX_NESTED_DEPTH_EXCEEDED",
2184
+ type: "markdown",
2185
+ message: "Markdown nesting depth exceeds configured limit",
2186
+ value: String(depth)
2187
+ });
2188
+ return [];
2189
+ }
2190
+ const sanitized = [];
2191
+ for (const node of nodes) {
2192
+ if (node.type === "image") {
2193
+ imageCount++;
2194
+ if (maxImageCount > 0 && imageCount > maxImageCount) {
2195
+ if (!imageLimitViolated) {
2196
+ imageLimitViolated = true;
2197
+ handleSecurityViolation(security, {
2198
+ code: "MAX_IMAGE_COUNT_EXCEEDED",
2199
+ type: "image",
2200
+ message: "Image count exceeds configured limit",
2201
+ value: String(imageCount)
2202
+ });
2203
+ }
2204
+ if (security.violationMode === "placeholder") sanitized.push({
2205
+ type: "raw",
2206
+ content: placeholderText
2207
+ });
2208
+ continue;
2209
+ }
2210
+ }
2211
+ if (node.items?.length) node.items = sanitizeNodes(node.items, depth + 1);
2212
+ sanitized.push(node);
2213
+ }
2214
+ return sanitized;
2215
+ };
2216
+ const sanitizedRoot = sanitizeNodes(elements, 1);
2217
+ elements.length = 0;
2218
+ elements.push(...sanitizedRoot);
2219
+ };
2220
+ /**
2221
+ * Creates a lightweight timeout guard function for long render flows.
2222
+ * Call the returned function at checkpoints (parse, prefetch, render loop).
2223
+ */
2224
+ const createTimeoutGuard = (security) => {
2225
+ const timeoutAt = security.enabled && (security.renderTimeoutMs || 0) > 0 ? Date.now() + (security.renderTimeoutMs || 0) : 0;
2226
+ return () => {
2227
+ if (timeoutAt > 0 && Date.now() > timeoutAt) {
2228
+ const action = handleSecurityViolation(security, {
2229
+ code: "RENDER_TIMEOUT_EXCEEDED",
2230
+ type: "render",
2231
+ message: "Render time exceeded configured timeout",
2232
+ value: String(security.renderTimeoutMs)
2233
+ });
2234
+ if (action === "skip" || action === "placeholder") throw new Error(`[jspdf-md-renderer] Render aborted: exceeded renderTimeoutMs (${security.renderTimeoutMs}ms).`);
2235
+ }
2236
+ };
2237
+ };
2238
+ //#endregion
2239
+ //#region src/security/security-transforms.ts
2240
+ /**
2241
+ * Applies link security rules to parsed markdown elements.
2242
+ * Rejected links are downgraded to plain text by clearing `href`.
2243
+ * In placeholder mode, blocked links render `security.placeholderText`.
2244
+ */
2245
+ const applyLinkPolicy = async (elements, security) => {
2246
+ if (!security.enabled) return;
2247
+ const walk = async (nodes) => {
2248
+ for (const node of nodes) {
2249
+ if (node.type === "link" && node.href) {
2250
+ if (security.disablePdfLinks) node.href = void 0;
2251
+ else if (!await validateResourceUrl(node.href, "link", security, "markdown-link")) {
2252
+ node.href = void 0;
2253
+ if (security.violationMode === "placeholder") {
2254
+ node.text = security.placeholderText || "[blocked]";
2255
+ node.items = [{
2256
+ type: "text",
2257
+ content: node.text
2258
+ }];
2259
+ }
2260
+ }
2261
+ }
2262
+ if (node.items?.length) await walk(node.items);
2263
+ }
2264
+ };
2265
+ await walk(elements);
2266
+ };
2267
+ /**
2268
+ * Replaces blocked image nodes with plain raw-text placeholders.
2269
+ * Used by `violationMode: 'placeholder'` to preserve layout continuity.
2270
+ */
2271
+ const convertBlockedImagesToPlaceholder = (elements, security) => {
2272
+ const placeholder = security.placeholderImageText || "[blocked image]";
2273
+ const walk = (nodes) => {
2274
+ for (const node of nodes) {
2275
+ if (node.type === "image" && !node.data) {
2276
+ node.type = "raw";
2277
+ node.content = placeholder;
2278
+ node.src = void 0;
2279
+ }
2280
+ if (node.items?.length) walk(node.items);
2281
+ }
2282
+ };
2283
+ walk(elements);
1667
2284
  };
1668
2285
  //#endregion
1669
2286
  //#region src/renderer/MdTextRender.ts
@@ -1676,9 +2293,18 @@ const applyFooter = (doc, options, pageNum, totalPages) => {
1676
2293
  */
1677
2294
  const MdTextRender = async (doc, text, options) => {
1678
2295
  const validOptions = validateOptions(options);
2296
+ const security = validOptions.security || {};
2297
+ const guardTimeout = createTimeoutGuard(security);
2298
+ enforceMarkdownLimits(text, security);
2299
+ guardTimeout();
1679
2300
  const store = new RenderStore(validOptions);
1680
2301
  const parsedElements = await MdTextParser(text);
1681
- await prefetchImages(parsedElements);
2302
+ guardTimeout();
2303
+ enforceNestedDepthAndImageCount(parsedElements, security);
2304
+ await applyLinkPolicy(parsedElements, security);
2305
+ await prefetchImages(parsedElements, security);
2306
+ guardTimeout();
2307
+ if (security.enabled && security.violationMode === "placeholder") convertBlockedImagesToPlaceholder(parsedElements, security);
1682
2308
  const renderElement = (element, indentLevel = 0, store, hasRawBullet = false, start = 0, ordered = false) => {
1683
2309
  const indent = indentLevel * validOptions.page.indent;
1684
2310
  switch (element.type) {
@@ -1735,7 +2361,10 @@ const MdTextRender = async (doc, text, options) => {
1735
2361
  break;
1736
2362
  }
1737
2363
  };
1738
- for (const item of parsedElements) renderElement(item, 0, store);
2364
+ for (const item of parsedElements) {
2365
+ guardTimeout();
2366
+ renderElement(item, 0, store);
2367
+ }
1739
2368
  applyPageDecorations(doc, validOptions);
1740
2369
  validOptions.endCursorYHandler(store.Y);
1741
2370
  };
@@ -1743,6 +2372,7 @@ const MdTextRender = async (doc, text, options) => {
1743
2372
  exports.MdTextParser = MdTextParser;
1744
2373
  exports.MdTextRender = MdTextRender;
1745
2374
  exports.MdTokenType = MdTokenType;
2375
+ exports.SecurityViolationError = SecurityViolationError;
1746
2376
  exports.renderInlineContent = renderInlineContent;
1747
2377
  exports.renderPlainText = renderPlainText;
1748
2378
  exports.validateOptions = validateOptions;