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.mjs CHANGED
@@ -73,7 +73,8 @@ const IMAGE_WITH_ATTRS_REGEX = /(!\[[^\]]*\]\()([^)]+)(\))\s*\{([^}]+)\}/g;
73
73
  * - Quoted values: width="200.5" or width='200'
74
74
  * - Decimal values: width=200.5
75
75
  */
76
- const ATTR_PAIR_REGEX = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
76
+ const ATTR_PAIR_REGEX = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([\w.+-]+))/g;
77
+ const MAX_ATTR_BLOCK_LENGTH = 500;
77
78
  /** Valid alignment values */
78
79
  const VALID_ALIGNMENTS = [
79
80
  "left",
@@ -97,6 +98,11 @@ const encodeAttrsToFragment = (attrs) => {
97
98
  */
98
99
  const parseRawAttributes = (attrString) => {
99
100
  const attrs = {};
101
+ if (attrString.length > MAX_ATTR_BLOCK_LENGTH) {
102
+ console.warn(`[jspdf-md-renderer] Image attribute block too long (${attrString.length} chars), skipping attribute parsing.`);
103
+ return attrs;
104
+ }
105
+ ATTR_PAIR_REGEX.lastIndex = 0;
100
106
  let match;
101
107
  while ((match = ATTR_PAIR_REGEX.exec(attrString)) !== null) {
102
108
  const key = match[1].toLowerCase();
@@ -389,12 +395,384 @@ const ensureSpace = (doc, store, minHeight) => {
389
395
  const getCharHight = (doc) => {
390
396
  return doc.getFontSize() / doc.internal.scaleFactor;
391
397
  };
398
+ /**
399
+ * Saves the current jsPDF font, size, and text color, executes `fn`,
400
+ * then restores those properties — even if `fn` throws.
401
+ */
402
+ const withSavedDocState = (doc, fn) => {
403
+ const savedFont = doc.getFont();
404
+ const savedSize = doc.getFontSize();
405
+ const savedColor = doc.getTextColor();
406
+ try {
407
+ return fn();
408
+ } finally {
409
+ doc.setFont(savedFont.fontName, savedFont.fontStyle);
410
+ doc.setFontSize(savedSize);
411
+ doc.setTextColor(savedColor);
412
+ }
413
+ };
414
+ //#endregion
415
+ //#region src/types/security.ts
416
+ var SecurityViolationError = class extends Error {
417
+ constructor(violation) {
418
+ super(violation.message);
419
+ this.name = "SecurityViolationError";
420
+ this.violation = violation;
421
+ }
422
+ };
423
+ //#endregion
424
+ //#region src/security/security-policy.ts
425
+ const DEFAULT_SECURITY = {
426
+ enabled: false,
427
+ allowedLinkProtocols: [
428
+ "https:",
429
+ "http:",
430
+ "mailto:",
431
+ "tel:"
432
+ ],
433
+ disablePdfLinks: false,
434
+ allowRemoteImages: true,
435
+ allowedImageProtocols: ["https:", "http:"],
436
+ allowedImageDomains: void 0,
437
+ allowDataUrls: true,
438
+ allowSvgImages: true,
439
+ blockLocalhost: true,
440
+ blockPrivateIPs: true,
441
+ blockLinkLocalIPs: true,
442
+ blockMetadataIPs: true,
443
+ maxMarkdownLength: 5e5,
444
+ maxImageCount: 200,
445
+ maxImageSizeBytes: 10 * 1024 * 1024,
446
+ maxNestedDepth: 20,
447
+ renderTimeoutMs: 3e4,
448
+ violationMode: "skip",
449
+ placeholderText: "[blocked]",
450
+ placeholderImageText: "[blocked image]"
451
+ };
452
+ const normalizeProtocol = (v) => `${v.trim().toLowerCase().replace(/:$/, "")}:`;
453
+ const normalizeDomain = (v) => v.trim().toLowerCase();
454
+ const clampInteger = (value, min, max) => Math.min(max, Math.max(min, Math.floor(value)));
455
+ const isNodeEnvironment = () => typeof process !== "undefined" && process.versions != null && process.versions.node != null;
456
+ /**
457
+ * Merges user-provided security config with safe defaults and
458
+ * validates/clamps numeric and enum fields.
459
+ */
460
+ const normalizeSecurityOptions = (security) => {
461
+ if (!security) return { ...DEFAULT_SECURITY };
462
+ const merged = {
463
+ ...DEFAULT_SECURITY,
464
+ ...security,
465
+ allowedLinkProtocols: security.allowedLinkProtocols?.map(normalizeProtocol) ?? DEFAULT_SECURITY.allowedLinkProtocols,
466
+ allowedImageProtocols: security.allowedImageProtocols?.map(normalizeProtocol) ?? DEFAULT_SECURITY.allowedImageProtocols,
467
+ allowedImageDomains: security.allowedImageDomains !== void 0 ? security.allowedImageDomains.map(normalizeDomain) : DEFAULT_SECURITY.allowedImageDomains
468
+ };
469
+ if (![
470
+ "skip",
471
+ "throw",
472
+ "placeholder"
473
+ ].includes(merged.violationMode || "skip")) throw new Error("[jspdf-md-renderer] security.violationMode must be skip | throw | placeholder");
474
+ for (const field of [
475
+ "maxMarkdownLength",
476
+ "maxImageCount",
477
+ "maxImageSizeBytes",
478
+ "maxNestedDepth",
479
+ "renderTimeoutMs"
480
+ ]) {
481
+ const value = merged[field];
482
+ if (value !== void 0 && (!Number.isFinite(value) || value < 0)) throw new Error(`[jspdf-md-renderer] security.${field} must be a non-negative number`);
483
+ }
484
+ merged.maxMarkdownLength = clampInteger(merged.maxMarkdownLength || 0, 0, 5e6);
485
+ merged.maxImageCount = clampInteger(merged.maxImageCount || 0, 0, 1e4);
486
+ merged.maxImageSizeBytes = clampInteger(merged.maxImageSizeBytes || 0, 0, 100 * 1024 * 1024);
487
+ merged.maxNestedDepth = clampInteger(merged.maxNestedDepth || 0, 0, 100);
488
+ merged.renderTimeoutMs = clampInteger(merged.renderTimeoutMs || 0, 0, 3e5);
489
+ return merged;
490
+ };
491
+ const metadataHosts = new Set([
492
+ "metadata.google.internal",
493
+ "metadata",
494
+ "instance-data"
495
+ ]);
496
+ const isIPv4InCidr = (ip, cidrBase, cidrMask) => {
497
+ const toNum = (s) => s.split(".").reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0;
498
+ const ipNum = toNum(ip);
499
+ const baseNum = toNum(cidrBase);
500
+ const mask = cidrMask === 0 ? 0 : 4294967295 << 32 - cidrMask >>> 0;
501
+ return (ipNum & mask) === (baseNum & mask);
502
+ };
503
+ const isLocalhostHost = (host) => host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
504
+ const isPrivateIPv4 = (ip) => isIPv4InCidr(ip, "10.0.0.0", 8) || isIPv4InCidr(ip, "172.16.0.0", 12) || isIPv4InCidr(ip, "192.168.0.0", 16);
505
+ const isLinkLocalIPv4 = (ip) => isIPv4InCidr(ip, "169.254.0.0", 16);
506
+ const isMetadataIP = (ip) => ip === "169.254.169.254" || ip === "100.100.100.200";
507
+ /**
508
+ * Parses an IPv6 address string into a BigInt for range comparison.
509
+ * Supports compressed and IPv4-mapped forms.
510
+ */
511
+ const parseIPv6ToBigInt = (ip) => {
512
+ let stripped = ip.replace(/^\[|\]$/g, "").toLowerCase();
513
+ if (stripped.includes(".")) {
514
+ const lastColon = stripped.lastIndexOf(":");
515
+ if (lastColon < 0) return null;
516
+ const ipv4Part = stripped.slice(lastColon + 1);
517
+ const prefix = stripped.slice(0, lastColon);
518
+ const parts = ipv4Part.split(".").map(Number);
519
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return null;
520
+ stripped = `${prefix}:${(parts[0] << 8 | parts[1]).toString(16)}:${(parts[2] << 8 | parts[3]).toString(16)}`;
521
+ }
522
+ let expanded = stripped;
523
+ if (expanded.includes("::")) {
524
+ const parts = expanded.split("::");
525
+ if (parts.length !== 2) return null;
526
+ const leftGroups = parts[0] ? parts[0].split(":") : [];
527
+ const rightGroups = parts[1] ? parts[1].split(":") : [];
528
+ const missing = 8 - leftGroups.length - rightGroups.length;
529
+ if (missing < 0) return null;
530
+ expanded = [
531
+ ...leftGroups,
532
+ ...Array(missing).fill("0"),
533
+ ...rightGroups
534
+ ].join(":");
535
+ }
536
+ const groups = expanded.split(":");
537
+ if (groups.length !== 8) return null;
538
+ try {
539
+ return groups.reduce((acc, g) => {
540
+ const n = parseInt(g || "0", 16);
541
+ if (Number.isNaN(n)) throw new Error("invalid hex");
542
+ return (acc << 16n) + BigInt(n);
543
+ }, 0n);
544
+ } catch {
545
+ return null;
546
+ }
547
+ };
548
+ const MAX_IPV6 = (1n << 128n) - 1n;
549
+ const isIPv6InRange = (ip, prefixBigInt, prefixLength) => {
550
+ const ipNum = parseIPv6ToBigInt(ip);
551
+ if (ipNum === null) return false;
552
+ const mask = prefixLength === 0 ? 0n : MAX_IPV6 << BigInt(128 - prefixLength) & MAX_IPV6;
553
+ return (ipNum & mask) === (prefixBigInt & mask);
554
+ };
555
+ const isLoopbackIPv6 = (ip) => {
556
+ return parseIPv6ToBigInt(ip) === 1n;
557
+ };
558
+ const isUniqueLocalIPv6 = (ip) => isIPv6InRange(ip, 64512n << 112n, 7);
559
+ const isLinkLocalIPv6 = (ip) => isIPv6InRange(ip, 65152n << 112n, 10);
560
+ const extractIPv4Mapped = (ip) => {
561
+ const stripped = ip.replace(/^\[|\]$/g, "");
562
+ const dottedMatch = stripped.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
563
+ if (dottedMatch) return dottedMatch[1];
564
+ const ipNum = parseIPv6ToBigInt(stripped);
565
+ if (ipNum === null) return null;
566
+ if (ipNum >> 32n !== 65535n) return null;
567
+ const low32 = Number(ipNum & 4294967295n);
568
+ return `${low32 >>> 24 & 255}.${low32 >>> 16 & 255}.${low32 >>> 8 & 255}.${low32 & 255}`;
569
+ };
570
+ const isIPv4MappedPrivate = (ip) => {
571
+ const mapped = extractIPv4Mapped(ip);
572
+ return mapped ? isPrivateIPv4(mapped) : false;
573
+ };
574
+ const isIPv4MappedLinkLocal = (ip) => {
575
+ const mapped = extractIPv4Mapped(ip);
576
+ return mapped ? isLinkLocalIPv4(mapped) : false;
577
+ };
578
+ const isIPv4MappedMetadata = (ip) => {
579
+ const mapped = extractIPv4Mapped(ip);
580
+ return mapped ? isMetadataIP(mapped) : false;
581
+ };
582
+ /**
583
+ * Resolves a hostname to IP addresses.
584
+ * Returns null when resolution is unavailable (browser runtime).
585
+ */
586
+ const resolveHostToIPs = async (host) => {
587
+ const stripped = host.replace(/^\[|\]$/g, "");
588
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(stripped)) return [stripped];
589
+ if (stripped.includes(":")) return [stripped];
590
+ if (!isNodeEnvironment()) return null;
591
+ try {
592
+ return (await (await import("node:dns")).promises.lookup(stripped, { all: true })).map((entry) => entry.address);
593
+ } catch {
594
+ return [];
595
+ }
596
+ };
597
+ /**
598
+ * Returns the action the caller should take for the violating element.
599
+ * Throws SecurityViolationError when violationMode is 'throw'.
600
+ */
601
+ const handleSecurityViolation = (security, violation) => {
602
+ const fullViolation = {
603
+ ...violation,
604
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
605
+ };
606
+ try {
607
+ security.onSecurityViolation?.(fullViolation);
608
+ } catch (error) {
609
+ console.warn("[jspdf-md-renderer] security.onSecurityViolation callback failed:", error);
610
+ }
611
+ const mode = security.violationMode || "skip";
612
+ if (mode === "throw") throw new SecurityViolationError(fullViolation);
613
+ if (mode === "placeholder") return "placeholder";
614
+ return "skip";
615
+ };
616
+ const createViolation = (code, type, message, value, context) => ({
617
+ code,
618
+ type,
619
+ message,
620
+ value,
621
+ context
622
+ });
623
+ /**
624
+ * Returns true when:
625
+ * - allowedDomains is undefined (feature not configured, allow all), OR
626
+ * - host matches an entry in the allowlist.
627
+ *
628
+ * An explicitly empty allowedDomains array means no domains are permitted.
629
+ */
630
+ const isAllowedDomain = (host, allowedDomains) => {
631
+ if (allowedDomains === void 0) return true;
632
+ if (allowedDomains.length === 0) return false;
633
+ return allowedDomains.some((domain) => host === domain || host.endsWith(`.${domain}`));
634
+ };
635
+ const classifyUrl = (raw) => {
636
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return "explicitScheme";
637
+ if (raw.startsWith("//")) return "protocolRelative";
638
+ return "relativePath";
639
+ };
640
+ /**
641
+ * Validates link/image URLs against protocol/domain/SSRF rules.
642
+ *
643
+ * URL classification:
644
+ * - Protocol-relative (`//host/path`): treated as external absolute and validated.
645
+ * - Explicit scheme (`https://...`): fully validated.
646
+ * - Relative path (`/x`, `./x`, `../x`, `?x`, `#x`): allowed by default.
647
+ */
648
+ const validateResourceUrl = async (rawValue, type, security, context) => {
649
+ const urlClass = classifyUrl(rawValue);
650
+ if (urlClass === "relativePath") {
651
+ if (security.validateUrl) {
652
+ let relativeUrl;
653
+ try {
654
+ relativeUrl = new URL(rawValue, "https://relative.local");
655
+ } catch {
656
+ handleSecurityViolation(security, createViolation("INVALID_URL", type, "Invalid relative URL", rawValue, context));
657
+ return false;
658
+ }
659
+ if (!await security.validateUrl(relativeUrl, type)) {
660
+ handleSecurityViolation(security, createViolation("CUSTOM_VALIDATOR_BLOCKED", type, "Custom URL validator rejected relative URL", rawValue, context));
661
+ return false;
662
+ }
663
+ }
664
+ return true;
665
+ }
666
+ let canonicalRaw = rawValue;
667
+ if (urlClass === "protocolRelative") canonicalRaw = `https:${rawValue}`;
668
+ let parsed;
669
+ try {
670
+ parsed = new URL(canonicalRaw);
671
+ } catch {
672
+ handleSecurityViolation(security, createViolation("INVALID_URL", type, "Invalid URL", rawValue, context));
673
+ return false;
674
+ }
675
+ if (urlClass !== "protocolRelative") {
676
+ const protocol = normalizeProtocol(parsed.protocol);
677
+ if (!(type === "link" ? security.allowedLinkProtocols || DEFAULT_SECURITY.allowedLinkProtocols : security.allowedImageProtocols || DEFAULT_SECURITY.allowedImageProtocols).includes(protocol)) {
678
+ handleSecurityViolation(security, createViolation(type === "link" ? "LINK_PROTOCOL_BLOCKED" : "IMAGE_PROTOCOL_BLOCKED", type, `${type} protocol is blocked`, rawValue, context));
679
+ return false;
680
+ }
681
+ }
682
+ if (type === "image" && !isAllowedDomain(parsed.hostname.toLowerCase(), security.allowedImageDomains)) {
683
+ handleSecurityViolation(security, createViolation("IMAGE_DOMAIN_BLOCKED", type, "Image domain is blocked", rawValue, context));
684
+ return false;
685
+ }
686
+ const host = parsed.hostname.toLowerCase();
687
+ if (security.blockLocalhost && isLocalhostHost(host)) {
688
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "Localhost URL is blocked", rawValue, context));
689
+ return false;
690
+ }
691
+ if (security.blockMetadataIPs && metadataHosts.has(host)) {
692
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "Metadata host is blocked", rawValue, context));
693
+ return false;
694
+ }
695
+ const ips = await resolveHostToIPs(host);
696
+ if (ips === null) {
697
+ if (type === "image") console.warn("[jspdf-md-renderer] Security warning: IP-based SSRF checks (blockPrivateIPs, blockLinkLocalIPs, blockMetadataIPs) cannot be fully enforced in browser environments. Route image fetching through a trusted server-side proxy.");
698
+ } else for (const ip of ips) {
699
+ if (security.blockLocalhost && isLocalhostHost(ip)) {
700
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "Localhost IP is blocked", rawValue, context));
701
+ return false;
702
+ }
703
+ if (security.blockPrivateIPs && isPrivateIPv4(ip)) {
704
+ handleSecurityViolation(security, createViolation("PRIVATE_IP_BLOCKED", type, "Private IP is blocked", rawValue, context));
705
+ return false;
706
+ }
707
+ if (security.blockLinkLocalIPs && isLinkLocalIPv4(ip)) {
708
+ handleSecurityViolation(security, createViolation("LINK_LOCAL_IP_BLOCKED", type, "Link-local IP is blocked", rawValue, context));
709
+ return false;
710
+ }
711
+ if (security.blockMetadataIPs && isMetadataIP(ip)) {
712
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "Metadata IP is blocked", rawValue, context));
713
+ return false;
714
+ }
715
+ if (security.blockLocalhost && isLoopbackIPv6(ip)) {
716
+ handleSecurityViolation(security, createViolation("LOCALHOST_BLOCKED", type, "IPv6 loopback is blocked", rawValue, context));
717
+ return false;
718
+ }
719
+ if (security.blockPrivateIPs && (isUniqueLocalIPv6(ip) || isIPv4MappedPrivate(ip))) {
720
+ handleSecurityViolation(security, createViolation("PRIVATE_IP_BLOCKED", type, "IPv6 private address is blocked", rawValue, context));
721
+ return false;
722
+ }
723
+ if (security.blockLinkLocalIPs && (isLinkLocalIPv6(ip) || isIPv4MappedLinkLocal(ip))) {
724
+ handleSecurityViolation(security, createViolation("LINK_LOCAL_IP_BLOCKED", type, "IPv6 link-local address is blocked", rawValue, context));
725
+ return false;
726
+ }
727
+ if (security.blockMetadataIPs && isIPv4MappedMetadata(ip)) {
728
+ handleSecurityViolation(security, createViolation("METADATA_IP_BLOCKED", type, "IPv4-mapped metadata IP is blocked", rawValue, context));
729
+ return false;
730
+ }
731
+ }
732
+ if (security.validateUrl) {
733
+ if (!await security.validateUrl(parsed, type)) {
734
+ handleSecurityViolation(security, createViolation("CUSTOM_VALIDATOR_BLOCKED", type, "Custom URL validator rejected URL", rawValue, context));
735
+ return false;
736
+ }
737
+ }
738
+ return true;
739
+ };
740
+ /**
741
+ * Returns true when the value is a `data:` URL.
742
+ */
743
+ const isDataUrl = (value) => value.trim().toLowerCase().startsWith("data:");
744
+ /**
745
+ * Returns true when the value is an SVG data URL.
746
+ */
747
+ const isSvgDataUrl = (value) => {
748
+ const normalized = value.trim().toLowerCase();
749
+ return normalized.startsWith("data:image/svg+xml") || normalized.startsWith("data:image/svg");
750
+ };
392
751
  //#endregion
393
752
  //#region src/utils/image-utils.ts
394
753
  /**
395
754
  * Standard DPI for web/screen pixels.
396
755
  */
397
756
  const DEFAULT_DPI = 96;
757
+ const getDataUrlPayloadByteSize = (dataUrl) => {
758
+ const commaIndex = dataUrl.indexOf(",");
759
+ if (commaIndex < 0) return null;
760
+ const metadata = dataUrl.slice(0, commaIndex).toLowerCase();
761
+ const payload = dataUrl.slice(commaIndex + 1);
762
+ if (metadata.includes(";base64")) {
763
+ const normalized = payload.replace(/\s/g, "");
764
+ const padding = normalized.match(/=*$/)?.[0].length ?? 0;
765
+ return Math.floor(normalized.length * 3 / 4) - padding;
766
+ }
767
+ try {
768
+ const decoded = decodeURIComponent(payload);
769
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(decoded).length;
770
+ if (typeof Buffer !== "undefined") return Buffer.from(decoded, "utf-8").byteLength;
771
+ return decoded.length;
772
+ } catch {
773
+ return null;
774
+ }
775
+ };
398
776
  /**
399
777
  * Converts pixel values to the document's unit system.
400
778
  * Uses 96 DPI as the standard web pixel density.
@@ -412,6 +790,29 @@ const pxToDocUnit = (px, unit = "mm") => {
412
790
  }
413
791
  };
414
792
  /**
793
+ * Detects the image format from a ParsedElement's data URI and source URL.
794
+ * Returns a format string suitable for jsPDF's addImage (e.g. 'PNG', 'JPEG').
795
+ */
796
+ const detectImageFormat = (element) => {
797
+ if (element.data) {
798
+ if (element.data.startsWith("data:image/png")) return "PNG";
799
+ if (element.data.startsWith("data:image/jpeg") || element.data.startsWith("data:image/jpg")) return "JPEG";
800
+ if (element.data.startsWith("data:image/webp")) return "WEBP";
801
+ if (element.data.startsWith("data:image/gif")) return "GIF";
802
+ }
803
+ if (element.src) {
804
+ const ext = element.src.split("?")[0].split("#")[0].split(".").pop()?.toUpperCase();
805
+ if (ext && [
806
+ "PNG",
807
+ "JPEG",
808
+ "JPG",
809
+ "WEBP",
810
+ "GIF"
811
+ ].includes(ext)) return ext === "JPG" ? "JPEG" : ext;
812
+ }
813
+ return "JPEG";
814
+ };
815
+ /**
415
816
  * Extracts width and height from an SVG data URI if possible.
416
817
  */
417
818
  const extractSvgDimensions = (dataUri) => {
@@ -507,14 +908,84 @@ const calculateImageDimensions = (doc, element, maxWidth, maxHeight, docUnit = "
507
908
  * Recursively traverses parsed elements and loads image data for Image tokens.
508
909
  * @param elements - The parsed elements to process.
509
910
  */
510
- const prefetchImages = async (elements) => {
911
+ const prefetchImages = async (elements, security) => {
511
912
  for (const element of elements) {
512
913
  if (element.type === "image" && element.src) try {
513
- if (element.src.startsWith("data:")) element.data = element.src;
514
- else {
515
- const response = await fetch(element.src);
914
+ if (security?.enabled) if (isDataUrl(element.src)) {
915
+ if (isSvgDataUrl(element.src) && !security.allowSvgImages) {
916
+ handleSecurityViolation(security, {
917
+ code: "SVG_BLOCKED",
918
+ type: "image",
919
+ message: "SVG images are blocked",
920
+ value: element.src,
921
+ context: "image-src"
922
+ });
923
+ element.data = void 0;
924
+ element.src = void 0;
925
+ continue;
926
+ }
927
+ if (!security.allowDataUrls) {
928
+ handleSecurityViolation(security, {
929
+ code: "DATA_URL_BLOCKED",
930
+ type: "image",
931
+ message: "Data URLs are blocked for images",
932
+ value: element.src,
933
+ context: "image-src"
934
+ });
935
+ element.data = void 0;
936
+ element.src = void 0;
937
+ continue;
938
+ }
939
+ } else {
940
+ if (!security.allowRemoteImages) {
941
+ handleSecurityViolation(security, {
942
+ code: "IMAGE_PROTOCOL_BLOCKED",
943
+ type: "image",
944
+ message: "Remote images are disabled",
945
+ value: element.src,
946
+ context: "image-src"
947
+ });
948
+ element.data = void 0;
949
+ element.src = void 0;
950
+ continue;
951
+ }
952
+ if (!await validateResourceUrl(element.src, "image", security, "image-src")) {
953
+ element.data = void 0;
954
+ element.src = void 0;
955
+ continue;
956
+ }
957
+ }
958
+ if (element.src.startsWith("data:")) {
959
+ element.data = element.src;
960
+ const dataUrlBytes = getDataUrlPayloadByteSize(element.data);
961
+ if (security?.enabled && security.maxImageSizeBytes && dataUrlBytes !== null && dataUrlBytes > security.maxImageSizeBytes) {
962
+ handleSecurityViolation(security, {
963
+ code: "IMAGE_SIZE_EXCEEDED",
964
+ type: "image",
965
+ message: "Data URL image exceeds maxImageSizeBytes",
966
+ value: String(dataUrlBytes),
967
+ context: "data-url-size"
968
+ });
969
+ element.data = void 0;
970
+ element.src = void 0;
971
+ continue;
972
+ }
973
+ } else {
974
+ const response = await secureImageFetch(element.src, security);
516
975
  if (!response.ok) throw new Error(`Failed to fetch image: ${response.statusText}`);
517
976
  const blob = await response.blob();
977
+ if (security?.enabled && security.maxImageSizeBytes && blob.size > security.maxImageSizeBytes) {
978
+ handleSecurityViolation(security, {
979
+ code: "IMAGE_SIZE_EXCEEDED",
980
+ type: "image",
981
+ message: "Fetched image exceeds maxImageSizeBytes",
982
+ value: String(blob.size),
983
+ context: "blob-size"
984
+ });
985
+ element.data = void 0;
986
+ element.src = void 0;
987
+ continue;
988
+ }
518
989
  element.data = await new Promise((resolve, reject) => {
519
990
  const reader = new FileReader();
520
991
  reader.onloadend = () => {
@@ -550,11 +1021,23 @@ const prefetchImages = async (elements) => {
550
1021
  });
551
1022
  }
552
1023
  } catch (error) {
1024
+ if (error instanceof SecurityViolationError) throw error;
553
1025
  console.warn(`[jspdf-md-renderer] Warning: Failed to load image at ${element.src}. It will be skipped.`, error);
554
1026
  }
555
- if (element.items && element.items.length > 0) await prefetchImages(element.items);
1027
+ if (element.items && element.items.length > 0) await prefetchImages(element.items, security);
556
1028
  }
557
1029
  };
1030
+ /**
1031
+ * Best-effort remote image fetch hardening.
1032
+ * In Node, re-validates URL immediately before fetch to reduce DNS rebind window.
1033
+ * In browser runtimes, or when security is undefined/disabled, delegates to normal fetch.
1034
+ */
1035
+ const secureImageFetch = async (url, security) => {
1036
+ if (security?.enabled && isNodeEnvironment()) {
1037
+ if (!await validateResourceUrl(url, "image", security, "pre-fetch-recheck")) throw new Error(`[jspdf-md-renderer] URL blocked on pre-fetch recheck: ${url}`);
1038
+ }
1039
+ return fetch(url);
1040
+ };
558
1041
  //#endregion
559
1042
  //#region src/layout/wordSplitter.ts
560
1043
  /**
@@ -589,6 +1072,14 @@ const applyStyleToDoc = (doc, style, store) => {
589
1072
  const curSize = doc.getFontSize();
590
1073
  const boldFont = store.options.font.bold?.name || curFont;
591
1074
  const regularFont = store.options.font.regular?.name || curFont;
1075
+ const italicFont = store.options.font.italic || {
1076
+ name: regularFont,
1077
+ style: "italic"
1078
+ };
1079
+ const boldItalicFont = store.options.font.boldItalic || {
1080
+ name: italicFont.name,
1081
+ style: "bolditalic"
1082
+ };
592
1083
  const codeFont = store.options.font.code || {
593
1084
  name: "courier",
594
1085
  style: "normal"
@@ -598,10 +1089,10 @@ const applyStyleToDoc = (doc, style, store) => {
598
1089
  doc.setFont(boldFont, store.options.font.bold?.style || "bold");
599
1090
  break;
600
1091
  case "italic":
601
- doc.setFont(regularFont, "italic");
1092
+ doc.setFont(italicFont.name, italicFont.style);
602
1093
  break;
603
1094
  case "bolditalic":
604
- doc.setFont(boldFont, "bolditalic");
1095
+ doc.setFont(boldItalicFont.name, boldItalicFont.style);
605
1096
  break;
606
1097
  case "codespan":
607
1098
  doc.setFont(codeFont.name, codeFont.style);
@@ -807,23 +1298,19 @@ const renderLine = (doc, line, x, y, maxWidth, store, alignment = "left") => {
807
1298
  }
808
1299
  };
809
1300
  const renderSingleWord = (doc, word, x, y, store) => {
810
- const savedFont = doc.getFont();
811
- const savedSize = doc.getFontSize();
812
- const savedColor = doc.getTextColor();
813
- applyStyleToDoc(doc, word.style, store);
814
- if (word.isLink && word.linkColor) doc.setTextColor(...word.linkColor);
815
- if (word.isImage && word.imageElement?.data) renderInlineImage(doc, word, x, y);
816
- else {
817
- if (word.style === "codespan") renderCodespanBackground(doc, word, x, y, store);
818
- doc.text(word.text, x, y, { baseline: "top" });
819
- }
820
- if (word.isLink && word.href) {
821
- const h = word.isImage && word.imageHeight ? word.imageHeight : doc.getTextDimensions("H").h;
822
- doc.link(x, y, word.width, h, { url: word.href });
823
- }
824
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
825
- doc.setFontSize(savedSize);
826
- doc.setTextColor(savedColor);
1301
+ withSavedDocState(doc, () => {
1302
+ applyStyleToDoc(doc, word.style, store);
1303
+ if (word.isLink && word.linkColor) doc.setTextColor(...word.linkColor);
1304
+ if (word.isImage && word.imageElement?.data) renderInlineImage(doc, word, x, y);
1305
+ else {
1306
+ if (word.style === "codespan") renderCodespanBackground(doc, word, x, y, store);
1307
+ doc.text(word.text, x, y, { baseline: "top" });
1308
+ }
1309
+ if (word.isLink && word.href) {
1310
+ const h = word.isImage && word.imageHeight ? word.imageHeight : doc.getTextDimensions("H").h;
1311
+ doc.link(x, y, word.width, h, { url: word.href });
1312
+ }
1313
+ });
827
1314
  };
828
1315
  const renderCodespanBackground = (doc, word, x, y, store) => {
829
1316
  const opts = store.options.codespan ?? {};
@@ -837,10 +1324,7 @@ const renderCodespanBackground = (doc, word, x, y, store) => {
837
1324
  };
838
1325
  const renderInlineImage = (doc, word, x, y) => {
839
1326
  const el = word.imageElement;
840
- let fmt = "JPEG";
841
- if (el.data.startsWith("data:image/png")) fmt = "PNG";
842
- else if (el.data.startsWith("data:image/webp")) fmt = "WEBP";
843
- else if (el.data.startsWith("data:image/gif")) fmt = "GIF";
1327
+ const fmt = detectImageFormat(el);
844
1328
  if (word.width > 0 && (word.imageHeight || 0) > 0) doc.addImage(el.data, fmt, x, y, word.width, word.imageHeight);
845
1329
  };
846
1330
  //#endregion
@@ -893,29 +1377,28 @@ const renderPlainText = (doc, text, x, y, maxWidth, store, opts = {}) => {
893
1377
  //#endregion
894
1378
  //#region src/renderer/components/heading.ts
895
1379
  const renderHeading = (doc, element, indent, store) => {
896
- const savedColor = doc.getTextColor();
897
- const headingKey = `h${element?.depth ?? 1}`;
898
- const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultFontSize;
899
- const headingColor = store.options.heading?.[`${headingKey}Color`] ?? store.options.heading?.color ?? "#000000";
900
- const savedSize = doc.getFontSize();
901
- doc.setFontSize(fontSize);
902
- doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
903
- doc.setTextColor(headingColor);
904
- breakIfOverflow(doc, store, getCharHight(doc) * 1.8);
905
- const maxWidth = store.options.page.maxContentWidth - indent;
906
- if (element.items && element.items.length > 0) renderInlineContent(doc, element.items, store.X + indent, store.Y, maxWidth, store, {
907
- alignment: "left",
908
- trimLastLine: true
909
- });
910
- else renderPlainText(doc, element?.content ?? "", store.X + indent, store.Y, maxWidth, store, {
911
- alignment: "left",
912
- trimLastLine: true
1380
+ withSavedDocState(doc, () => {
1381
+ const headingKey = `h${element?.depth ?? 1}`;
1382
+ const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultTitleFontSize;
1383
+ const headingColor = store.options.heading?.[`${headingKey}Color`] ?? store.options.heading?.color ?? "#000000";
1384
+ const useBold = store.options.heading?.bold ?? true;
1385
+ doc.setFontSize(fontSize);
1386
+ if (useBold) doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
1387
+ else doc.setFont(store.options.font.regular.name, store.options.font.regular.style || "normal");
1388
+ doc.setTextColor(headingColor);
1389
+ breakIfOverflow(doc, store, getCharHight(doc) * 1.8);
1390
+ const maxWidth = store.options.page.maxContentWidth - indent;
1391
+ if (element.items && element.items.length > 0) renderInlineContent(doc, element.items, store.X + indent, store.Y, maxWidth, store, {
1392
+ alignment: "left",
1393
+ trimLastLine: true
1394
+ });
1395
+ else renderPlainText(doc, element?.content ?? "", store.X + indent, store.Y, maxWidth, store, {
1396
+ alignment: "left",
1397
+ trimLastLine: true
1398
+ });
1399
+ const bottomSpacing = store.options.heading?.bottomSpacing ?? store.options.spacing?.afterHeading ?? 2;
1400
+ store.updateY(bottomSpacing, "add");
913
1401
  });
914
- const bottomSpacing = store.options.heading?.bottomSpacing ?? store.options.spacing?.afterHeading ?? 2;
915
- store.updateY(bottomSpacing, "add");
916
- doc.setFontSize(savedSize);
917
- doc.setFont(store.options.font.regular.name, store.options.font.regular.style);
918
- doc.setTextColor(savedColor);
919
1402
  store.updateX(store.options.page.xpading, "set");
920
1403
  };
921
1404
  //#endregion
@@ -971,10 +1454,11 @@ const renderParagraph = (doc, element, indent, store, parentElementRenderer) =>
971
1454
  //#region src/renderer/components/list.ts
972
1455
  const renderList = (doc, element, indentLevel, store, parentElementRenderer) => {
973
1456
  doc.setFontSize(store.options.page.defaultFontSize);
1457
+ const listItemGap = store.options.spacing?.betweenListItems ?? 0;
974
1458
  for (const [i, point] of element?.items?.entries() ?? []) {
975
1459
  const _start = element.ordered ? (element.start ?? 1) + i : void 0;
976
1460
  parentElementRenderer(point, indentLevel + 1, store, true, _start, element.ordered);
977
- if (i < (element.items?.length ?? 0) - 1) store.updateY(store.options.spacing?.betweenListItems ?? 0, "add");
1461
+ if (i < (element.items?.length ?? 0) - 1) store.updateY(listItemGap, "add");
978
1462
  }
979
1463
  store.updateY(store.options.spacing?.afterList ?? 3, "add");
980
1464
  };
@@ -984,9 +1468,9 @@ const renderList = (doc, element, indentLevel, store, parentElementRenderer) =>
984
1468
  * Render a single list item, including bullets/numbering, inline text, and any nested lists.
985
1469
  */
986
1470
  const renderListItem = (doc, element, indentLevel, store, parentElementRenderer, start, ordered) => {
987
- breakIfOverflow(doc, store, getCharHight(doc));
988
1471
  const options = store.options;
989
1472
  const listOpts = store.options.list ?? {};
1473
+ breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
990
1474
  const baseIndent = indentLevel * (listOpts.indentSize ?? options.page.indent);
991
1475
  const bullet = ordered ? `${start}. ` : listOpts.bulletChar ?? "• ";
992
1476
  const xLeft = options.page.xpading;
@@ -1063,6 +1547,7 @@ const renderRawItem = (doc, element, indentLevel, store, hasRawBullet, parentEle
1063
1547
  }
1064
1548
  store.updateX(xLeft, "set");
1065
1549
  if (hasRawBullet && bullet) {
1550
+ breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
1066
1551
  const bulletWidth = doc.getTextWidth(bullet);
1067
1552
  const textMaxWidth = options.page.maxContentWidth - indent - bulletWidth;
1068
1553
  doc.setFont(options.font.regular.name, options.font.regular.style);
@@ -1181,7 +1666,9 @@ const renderCodeBlock = (doc, element, indentLevel, store) => {
1181
1666
  //#region src/renderer/components/blockquote.ts
1182
1667
  const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1183
1668
  const options = store.options;
1669
+ const bqOpts = store.options.blockquote ?? {};
1184
1670
  const savedDrawColor = doc.getDrawColor();
1671
+ const savedFillColor = doc.getFillColor();
1185
1672
  const savedLineWidth = doc.getLineWidth();
1186
1673
  const blockquoteIndent = indentLevel + 1;
1187
1674
  const currentX = store.X + indentLevel * options.page.indent;
@@ -1194,17 +1681,27 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1194
1681
  });
1195
1682
  const endY = store.lastContentY || store.Y;
1196
1683
  const endPage = doc.internal.getCurrentPageInfo().pageNumber;
1197
- const bqOpts = store.options.blockquote ?? {};
1198
1684
  const barColor = bqOpts.barColor ?? "#AAAAAA";
1199
1685
  const barWidth = bqOpts.barWidth ?? 1;
1200
1686
  doc.setDrawColor(barColor);
1201
1687
  doc.setLineWidth(barWidth);
1688
+ const bgColor = bqOpts.backgroundColor;
1202
1689
  for (let p = startPage; p <= endPage; p++) {
1203
1690
  doc.setPage(p);
1204
1691
  const isStart = p === startPage;
1205
1692
  const isEnd = p === endPage;
1206
1693
  const lineTop = isStart ? startY : options.page.topmargin;
1207
1694
  const lineBottom = isEnd ? endY : options.page.maxContentHeight;
1695
+ const lineHeight = Math.max(0, lineBottom - lineTop);
1696
+ if (bgColor && lineHeight > 0) {
1697
+ const bgX = barX + barWidth / 2;
1698
+ const bgW = options.page.maxContentWidth - (bgX - options.page.xpading);
1699
+ if (bgW > 0) {
1700
+ doc.setFillColor(bgColor);
1701
+ doc.rect(bgX, lineTop, bgW, lineHeight, "F");
1702
+ doc.setDrawColor(barColor);
1703
+ }
1704
+ }
1208
1705
  doc.line(barX, lineTop, barX, lineBottom);
1209
1706
  }
1210
1707
  store.recordContentY();
@@ -1212,34 +1709,12 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
1212
1709
  const bqBottomSpacing = bqOpts.bottomSpacing ?? options.spacing?.afterBlockquote ?? options.page.lineSpace;
1213
1710
  store.updateY(bqBottomSpacing, "add");
1214
1711
  doc.setDrawColor(savedDrawColor);
1712
+ doc.setFillColor(savedFillColor);
1215
1713
  doc.setLineWidth(savedLineWidth);
1216
1714
  };
1217
1715
  //#endregion
1218
1716
  //#region src/renderer/components/image.ts
1219
1717
  /**
1220
- * Detects the image format from element data and source.
1221
- */
1222
- const detectImageFormat = (element) => {
1223
- if (element.data) {
1224
- if (element.data.startsWith("data:image/png")) return "PNG";
1225
- if (element.data.startsWith("data:image/jpeg") || element.data.startsWith("data:image/jpg")) return "JPEG";
1226
- if (element.data.startsWith("data:image/webp")) return "WEBP";
1227
- if (element.data.startsWith("data:image/webp")) return "WEBP";
1228
- if (element.data.startsWith("data:image/gif")) return "GIF";
1229
- }
1230
- if (element.src) {
1231
- const ext = element.src.split("?")[0].split("#")[0].split(".").pop()?.toUpperCase();
1232
- if (ext && [
1233
- "PNG",
1234
- "JPEG",
1235
- "JPG",
1236
- "WEBP",
1237
- "GIF"
1238
- ].includes(ext)) return ext === "JPG" ? "JPEG" : ext;
1239
- }
1240
- return "JPEG";
1241
- };
1242
- /**
1243
1718
  * Renders an image element into the jsPDF document with smart sizing and alignment.
1244
1719
  *
1245
1720
  * Sizing logic (in order of priority):
@@ -1303,7 +1778,9 @@ const renderTable = (doc, element, indentLevel, store) => {
1303
1778
  return;
1304
1779
  }
1305
1780
  const options = store.options;
1306
- const marginLeft = options.page.xmargin + indentLevel * options.page.indent;
1781
+ const indent = indentLevel * options.page.indent;
1782
+ const marginLeft = options.page.xpading + indent;
1783
+ const availableWidth = Math.max(10, options.page.maxContentWidth - indent);
1307
1784
  ensureSpace(doc, store, 20);
1308
1785
  const columnCount = element.header.length;
1309
1786
  const rows = (element.rows ?? []).map((row) => {
@@ -1336,8 +1813,9 @@ const renderTable = (doc, element, indentLevel, store) => {
1336
1813
  startY: store.Y,
1337
1814
  margin: {
1338
1815
  left: marginLeft,
1339
- right: options.page.xmargin
1816
+ right: Math.max(0, doc.internal.pageSize.getWidth() - (marginLeft + availableWidth))
1340
1817
  },
1818
+ tableWidth: availableWidth,
1341
1819
  ...userTableOptions,
1342
1820
  didDrawPage: safeDidDrawPage,
1343
1821
  didDrawCell: safeDidDrawCell
@@ -1429,6 +1907,7 @@ var RenderStore = class {
1429
1907
  //#endregion
1430
1908
  //#region src/utils/options-validation.ts
1431
1909
  const DEFAULT_HEADING_SIZES = {
1910
+ bold: true,
1432
1911
  h1: 24,
1433
1912
  h2: 20,
1434
1913
  h3: 17,
@@ -1450,6 +1929,14 @@ const DEFAULT_FONT = {
1450
1929
  name: "helvetica",
1451
1930
  style: "light"
1452
1931
  },
1932
+ italic: {
1933
+ name: "helvetica",
1934
+ style: "italic"
1935
+ },
1936
+ boldItalic: {
1937
+ name: "helvetica",
1938
+ style: "bolditalic"
1939
+ },
1453
1940
  code: {
1454
1941
  name: "courier",
1455
1942
  style: "normal"
@@ -1487,6 +1974,8 @@ const validateOptions = (options) => {
1487
1974
  ...options.font
1488
1975
  };
1489
1976
  if (!font.bold?.name) font.bold = DEFAULT_FONT.bold;
1977
+ if (!font.italic?.name) font.italic = DEFAULT_FONT.italic;
1978
+ if (!font.boldItalic?.name) font.boldItalic = DEFAULT_FONT.boldItalic;
1490
1979
  if (!font.code?.name) font.code = DEFAULT_FONT.code;
1491
1980
  const heading = {
1492
1981
  ...DEFAULT_HEADING_SIZES,
@@ -1500,7 +1989,7 @@ const validateOptions = (options) => {
1500
1989
  "h5",
1501
1990
  "h6"
1502
1991
  ].forEach((k) => {
1503
- if (heading[k] < 6 || heading[k] > 72) heading[k] = DEFAULT_HEADING_SIZES[k];
1992
+ if ((heading[k] ?? 0) < 6 || (heading[k] ?? 0) > 72) heading[k] = DEFAULT_HEADING_SIZES[k];
1504
1993
  });
1505
1994
  const codespan = {
1506
1995
  backgroundColor: "#EEEEEE",
@@ -1546,6 +2035,7 @@ const validateOptions = (options) => {
1546
2035
  afterTable: 3,
1547
2036
  ...options.spacing ?? {}
1548
2037
  };
2038
+ if (options.spacing?.betweenListItems === void 0) spacing.betweenListItems = list.itemSpacing ?? spacing.betweenListItems;
1549
2039
  [
1550
2040
  "afterHeading",
1551
2041
  "afterParagraph",
@@ -1579,6 +2069,7 @@ const validateOptions = (options) => {
1579
2069
  codeBlock,
1580
2070
  spacing,
1581
2071
  image,
2072
+ security: normalizeSecurityOptions(options.security),
1582
2073
  endCursorYHandler
1583
2074
  };
1584
2075
  };
@@ -1597,49 +2088,175 @@ const applyHeader = (doc, options, pageNum, totalPages) => {
1597
2088
  if (!hOpts) return;
1598
2089
  const text = typeof hOpts.text === "function" ? hOpts.text(pageNum, totalPages) : hOpts.text ?? "";
1599
2090
  if (!text.trim()) return;
1600
- const savedFont = doc.getFont();
1601
- const savedSize = doc.getFontSize();
1602
- const savedColor = doc.getTextColor();
1603
- doc.setFontSize(hOpts.fontSize ?? 9);
1604
- doc.setTextColor(hOpts.color ?? "#666666");
1605
- const y = hOpts.y ?? 5;
1606
- const align = hOpts.align ?? "center";
1607
- const pageWidth = doc.internal.pageSize.getWidth();
1608
- let x = pageWidth / 2;
1609
- if (align === "left") x = options.page.xmargin;
1610
- if (align === "right") x = pageWidth - options.page.xmargin;
1611
- doc.text(text, x, y, {
1612
- align,
1613
- baseline: "top"
2091
+ withSavedDocState(doc, () => {
2092
+ doc.setFontSize(hOpts.fontSize ?? 9);
2093
+ doc.setTextColor(hOpts.color ?? "#666666");
2094
+ const y = hOpts.y ?? 5;
2095
+ const align = hOpts.align ?? "center";
2096
+ const pageWidth = doc.internal.pageSize.getWidth();
2097
+ let x = pageWidth / 2;
2098
+ if (align === "left") x = options.page.xmargin;
2099
+ if (align === "right") x = pageWidth - options.page.xmargin;
2100
+ doc.text(text, x, y, {
2101
+ align,
2102
+ baseline: "top"
2103
+ });
1614
2104
  });
1615
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
1616
- doc.setFontSize(savedSize);
1617
- doc.setTextColor(savedColor);
1618
2105
  };
1619
2106
  const applyFooter = (doc, options, pageNum, totalPages) => {
1620
2107
  const fOpts = options.footer;
1621
2108
  if (!fOpts) return;
1622
2109
  const text = fOpts.showPageNumbers ? `Page ${pageNum} of ${totalPages}` : typeof fOpts.text === "function" ? fOpts.text(pageNum, totalPages) : fOpts.text ?? "";
1623
2110
  if (!text.trim()) return;
1624
- const savedFont = doc.getFont();
1625
- const savedSize = doc.getFontSize();
1626
- const savedColor = doc.getTextColor();
1627
- doc.setFontSize(fOpts.fontSize ?? 9);
1628
- doc.setTextColor(fOpts.color ?? "#666666");
1629
- const pageHeight = doc.internal.pageSize.getHeight();
1630
- const y = fOpts.y ?? pageHeight - 5;
1631
- const align = fOpts.align ?? "right";
1632
- const pageWidth = doc.internal.pageSize.getWidth();
1633
- let x = pageWidth / 2;
1634
- if (align === "left") x = options.page.xmargin;
1635
- if (align === "right") x = pageWidth - options.page.xmargin;
1636
- doc.text(text, x, y, {
1637
- align,
1638
- baseline: "bottom"
2111
+ withSavedDocState(doc, () => {
2112
+ doc.setFontSize(fOpts.fontSize ?? 9);
2113
+ doc.setTextColor(fOpts.color ?? "#666666");
2114
+ const pageHeight = doc.internal.pageSize.getHeight();
2115
+ const y = fOpts.y ?? pageHeight - 5;
2116
+ const align = fOpts.align ?? "right";
2117
+ const pageWidth = doc.internal.pageSize.getWidth();
2118
+ let x = pageWidth / 2;
2119
+ if (align === "left") x = options.page.xmargin;
2120
+ if (align === "right") x = pageWidth - options.page.xmargin;
2121
+ doc.text(text, x, y, {
2122
+ align,
2123
+ baseline: "bottom"
2124
+ });
1639
2125
  });
1640
- doc.setFont(savedFont.fontName, savedFont.fontStyle);
1641
- doc.setFontSize(savedSize);
1642
- doc.setTextColor(savedColor);
2126
+ };
2127
+ //#endregion
2128
+ //#region src/security/security-guards.ts
2129
+ /**
2130
+ * Enforces input-size limits before tokenization/rendering starts.
2131
+ * Violations are delegated to the configured violation handler.
2132
+ */
2133
+ const enforceMarkdownLimits = (text, security) => {
2134
+ if (!security.enabled) return;
2135
+ if ((security.maxMarkdownLength || 0) > 0 && text.length > (security.maxMarkdownLength || 0)) {
2136
+ const action = handleSecurityViolation(security, {
2137
+ code: "MARKDOWN_TOO_LARGE",
2138
+ type: "markdown",
2139
+ message: "Markdown length exceeds configured limit",
2140
+ value: String(text.length)
2141
+ });
2142
+ if (action === "skip" || action === "placeholder") throw new Error(`[jspdf-md-renderer] Markdown input rejected: length ${text.length} exceeds maxMarkdownLength ${security.maxMarkdownLength}.`);
2143
+ }
2144
+ };
2145
+ /**
2146
+ * Walks the parsed markdown tree and enforces structural limits:
2147
+ * nesting depth and image count.
2148
+ */
2149
+ const enforceNestedDepthAndImageCount = (elements, security) => {
2150
+ if (!security.enabled) return;
2151
+ let imageCount = 0;
2152
+ const maxDepth = security.maxNestedDepth || 0;
2153
+ const maxImageCount = security.maxImageCount || 0;
2154
+ const placeholderText = security.placeholderImageText || "[blocked image]";
2155
+ let imageLimitViolated = false;
2156
+ const sanitizeNodes = (nodes, depth) => {
2157
+ if (maxDepth > 0 && depth > maxDepth) {
2158
+ handleSecurityViolation(security, {
2159
+ code: "MAX_NESTED_DEPTH_EXCEEDED",
2160
+ type: "markdown",
2161
+ message: "Markdown nesting depth exceeds configured limit",
2162
+ value: String(depth)
2163
+ });
2164
+ return [];
2165
+ }
2166
+ const sanitized = [];
2167
+ for (const node of nodes) {
2168
+ if (node.type === "image") {
2169
+ imageCount++;
2170
+ if (maxImageCount > 0 && imageCount > maxImageCount) {
2171
+ if (!imageLimitViolated) {
2172
+ imageLimitViolated = true;
2173
+ handleSecurityViolation(security, {
2174
+ code: "MAX_IMAGE_COUNT_EXCEEDED",
2175
+ type: "image",
2176
+ message: "Image count exceeds configured limit",
2177
+ value: String(imageCount)
2178
+ });
2179
+ }
2180
+ if (security.violationMode === "placeholder") sanitized.push({
2181
+ type: "raw",
2182
+ content: placeholderText
2183
+ });
2184
+ continue;
2185
+ }
2186
+ }
2187
+ if (node.items?.length) node.items = sanitizeNodes(node.items, depth + 1);
2188
+ sanitized.push(node);
2189
+ }
2190
+ return sanitized;
2191
+ };
2192
+ const sanitizedRoot = sanitizeNodes(elements, 1);
2193
+ elements.length = 0;
2194
+ elements.push(...sanitizedRoot);
2195
+ };
2196
+ /**
2197
+ * Creates a lightweight timeout guard function for long render flows.
2198
+ * Call the returned function at checkpoints (parse, prefetch, render loop).
2199
+ */
2200
+ const createTimeoutGuard = (security) => {
2201
+ const timeoutAt = security.enabled && (security.renderTimeoutMs || 0) > 0 ? Date.now() + (security.renderTimeoutMs || 0) : 0;
2202
+ return () => {
2203
+ if (timeoutAt > 0 && Date.now() > timeoutAt) {
2204
+ const action = handleSecurityViolation(security, {
2205
+ code: "RENDER_TIMEOUT_EXCEEDED",
2206
+ type: "render",
2207
+ message: "Render time exceeded configured timeout",
2208
+ value: String(security.renderTimeoutMs)
2209
+ });
2210
+ if (action === "skip" || action === "placeholder") throw new Error(`[jspdf-md-renderer] Render aborted: exceeded renderTimeoutMs (${security.renderTimeoutMs}ms).`);
2211
+ }
2212
+ };
2213
+ };
2214
+ //#endregion
2215
+ //#region src/security/security-transforms.ts
2216
+ /**
2217
+ * Applies link security rules to parsed markdown elements.
2218
+ * Rejected links are downgraded to plain text by clearing `href`.
2219
+ * In placeholder mode, blocked links render `security.placeholderText`.
2220
+ */
2221
+ const applyLinkPolicy = async (elements, security) => {
2222
+ if (!security.enabled) return;
2223
+ const walk = async (nodes) => {
2224
+ for (const node of nodes) {
2225
+ if (node.type === "link" && node.href) {
2226
+ if (security.disablePdfLinks) node.href = void 0;
2227
+ else if (!await validateResourceUrl(node.href, "link", security, "markdown-link")) {
2228
+ node.href = void 0;
2229
+ if (security.violationMode === "placeholder") {
2230
+ node.text = security.placeholderText || "[blocked]";
2231
+ node.items = [{
2232
+ type: "text",
2233
+ content: node.text
2234
+ }];
2235
+ }
2236
+ }
2237
+ }
2238
+ if (node.items?.length) await walk(node.items);
2239
+ }
2240
+ };
2241
+ await walk(elements);
2242
+ };
2243
+ /**
2244
+ * Replaces blocked image nodes with plain raw-text placeholders.
2245
+ * Used by `violationMode: 'placeholder'` to preserve layout continuity.
2246
+ */
2247
+ const convertBlockedImagesToPlaceholder = (elements, security) => {
2248
+ const placeholder = security.placeholderImageText || "[blocked image]";
2249
+ const walk = (nodes) => {
2250
+ for (const node of nodes) {
2251
+ if (node.type === "image" && !node.data) {
2252
+ node.type = "raw";
2253
+ node.content = placeholder;
2254
+ node.src = void 0;
2255
+ }
2256
+ if (node.items?.length) walk(node.items);
2257
+ }
2258
+ };
2259
+ walk(elements);
1643
2260
  };
1644
2261
  //#endregion
1645
2262
  //#region src/renderer/MdTextRender.ts
@@ -1652,9 +2269,18 @@ const applyFooter = (doc, options, pageNum, totalPages) => {
1652
2269
  */
1653
2270
  const MdTextRender = async (doc, text, options) => {
1654
2271
  const validOptions = validateOptions(options);
2272
+ const security = validOptions.security || {};
2273
+ const guardTimeout = createTimeoutGuard(security);
2274
+ enforceMarkdownLimits(text, security);
2275
+ guardTimeout();
1655
2276
  const store = new RenderStore(validOptions);
1656
2277
  const parsedElements = await MdTextParser(text);
1657
- await prefetchImages(parsedElements);
2278
+ guardTimeout();
2279
+ enforceNestedDepthAndImageCount(parsedElements, security);
2280
+ await applyLinkPolicy(parsedElements, security);
2281
+ await prefetchImages(parsedElements, security);
2282
+ guardTimeout();
2283
+ if (security.enabled && security.violationMode === "placeholder") convertBlockedImagesToPlaceholder(parsedElements, security);
1658
2284
  const renderElement = (element, indentLevel = 0, store, hasRawBullet = false, start = 0, ordered = false) => {
1659
2285
  const indent = indentLevel * validOptions.page.indent;
1660
2286
  switch (element.type) {
@@ -1711,9 +2337,12 @@ const MdTextRender = async (doc, text, options) => {
1711
2337
  break;
1712
2338
  }
1713
2339
  };
1714
- for (const item of parsedElements) renderElement(item, 0, store);
2340
+ for (const item of parsedElements) {
2341
+ guardTimeout();
2342
+ renderElement(item, 0, store);
2343
+ }
1715
2344
  applyPageDecorations(doc, validOptions);
1716
2345
  validOptions.endCursorYHandler(store.Y);
1717
2346
  };
1718
2347
  //#endregion
1719
- export { MdTextParser, MdTextRender, MdTokenType, renderInlineContent, renderPlainText, validateOptions };
2348
+ export { MdTextParser, MdTextRender, MdTokenType, SecurityViolationError, renderInlineContent, renderPlainText, validateOptions };