jspdf-md-renderer 4.0.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -269
- package/dist/index.d.mts +93 -3
- package/dist/index.d.ts +93 -3
- package/dist/index.js +629 -15
- package/dist/index.mjs +629 -16
- package/dist/index.umd.js +629 -15
- package/package.json +1 -1
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*(?:"([^"]*)"|'([^']*)'|(\
|
|
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();
|
|
@@ -430,11 +436,367 @@ const withSavedDocState = (doc, fn) => {
|
|
|
430
436
|
}
|
|
431
437
|
};
|
|
432
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
|
+
};
|
|
775
|
+
//#endregion
|
|
433
776
|
//#region src/utils/image-utils.ts
|
|
434
777
|
/**
|
|
435
778
|
* Standard DPI for web/screen pixels.
|
|
436
779
|
*/
|
|
437
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
|
+
};
|
|
438
800
|
/**
|
|
439
801
|
* Converts pixel values to the document's unit system.
|
|
440
802
|
* Uses 96 DPI as the standard web pixel density.
|
|
@@ -570,14 +932,84 @@ const calculateImageDimensions = (doc, element, maxWidth, maxHeight, docUnit = "
|
|
|
570
932
|
* Recursively traverses parsed elements and loads image data for Image tokens.
|
|
571
933
|
* @param elements - The parsed elements to process.
|
|
572
934
|
*/
|
|
573
|
-
const prefetchImages = async (elements) => {
|
|
935
|
+
const prefetchImages = async (elements, security) => {
|
|
574
936
|
for (const element of elements) {
|
|
575
937
|
if (element.type === "image" && element.src) try {
|
|
576
|
-
if (element.src
|
|
577
|
-
|
|
578
|
-
|
|
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);
|
|
579
999
|
if (!response.ok) throw new Error(`Failed to fetch image: ${response.statusText}`);
|
|
580
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
|
+
}
|
|
581
1013
|
element.data = await new Promise((resolve, reject) => {
|
|
582
1014
|
const reader = new FileReader();
|
|
583
1015
|
reader.onloadend = () => {
|
|
@@ -613,10 +1045,22 @@ const prefetchImages = async (elements) => {
|
|
|
613
1045
|
});
|
|
614
1046
|
}
|
|
615
1047
|
} catch (error) {
|
|
1048
|
+
if (error instanceof SecurityViolationError) throw error;
|
|
616
1049
|
console.warn(`[jspdf-md-renderer] Warning: Failed to load image at ${element.src}. It will be skipped.`, error);
|
|
617
1050
|
}
|
|
618
|
-
if (element.items && element.items.length > 0) await prefetchImages(element.items);
|
|
1051
|
+
if (element.items && element.items.length > 0) await prefetchImages(element.items, security);
|
|
1052
|
+
}
|
|
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}`);
|
|
619
1062
|
}
|
|
1063
|
+
return fetch(url);
|
|
620
1064
|
};
|
|
621
1065
|
//#endregion
|
|
622
1066
|
//#region src/layout/wordSplitter.ts
|
|
@@ -959,10 +1403,12 @@ const renderPlainText = (doc, text, x, y, maxWidth, store, opts = {}) => {
|
|
|
959
1403
|
const renderHeading = (doc, element, indent, store) => {
|
|
960
1404
|
withSavedDocState(doc, () => {
|
|
961
1405
|
const headingKey = `h${element?.depth ?? 1}`;
|
|
962
|
-
const fontSize = store.options.heading?.[headingKey] ?? store.options.page.
|
|
1406
|
+
const fontSize = store.options.heading?.[headingKey] ?? store.options.page.defaultTitleFontSize;
|
|
963
1407
|
const headingColor = store.options.heading?.[`${headingKey}Color`] ?? store.options.heading?.color ?? "#000000";
|
|
1408
|
+
const useBold = store.options.heading?.bold ?? true;
|
|
964
1409
|
doc.setFontSize(fontSize);
|
|
965
|
-
doc.setFont(store.options.font.bold.name, store.options.font.bold.style || "bold");
|
|
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");
|
|
966
1412
|
doc.setTextColor(headingColor);
|
|
967
1413
|
breakIfOverflow(doc, store, getCharHight(doc) * 1.8);
|
|
968
1414
|
const maxWidth = store.options.page.maxContentWidth - indent;
|
|
@@ -1032,10 +1478,11 @@ const renderParagraph = (doc, element, indent, store, parentElementRenderer) =>
|
|
|
1032
1478
|
//#region src/renderer/components/list.ts
|
|
1033
1479
|
const renderList = (doc, element, indentLevel, store, parentElementRenderer) => {
|
|
1034
1480
|
doc.setFontSize(store.options.page.defaultFontSize);
|
|
1481
|
+
const listItemGap = store.options.spacing?.betweenListItems ?? 0;
|
|
1035
1482
|
for (const [i, point] of element?.items?.entries() ?? []) {
|
|
1036
1483
|
const _start = element.ordered ? (element.start ?? 1) + i : void 0;
|
|
1037
1484
|
parentElementRenderer(point, indentLevel + 1, store, true, _start, element.ordered);
|
|
1038
|
-
if (i < (element.items?.length ?? 0) - 1) store.updateY(
|
|
1485
|
+
if (i < (element.items?.length ?? 0) - 1) store.updateY(listItemGap, "add");
|
|
1039
1486
|
}
|
|
1040
1487
|
store.updateY(store.options.spacing?.afterList ?? 3, "add");
|
|
1041
1488
|
};
|
|
@@ -1045,9 +1492,9 @@ const renderList = (doc, element, indentLevel, store, parentElementRenderer) =>
|
|
|
1045
1492
|
* Render a single list item, including bullets/numbering, inline text, and any nested lists.
|
|
1046
1493
|
*/
|
|
1047
1494
|
const renderListItem = (doc, element, indentLevel, store, parentElementRenderer, start, ordered) => {
|
|
1048
|
-
breakIfOverflow(doc, store, getCharHight(doc));
|
|
1049
1495
|
const options = store.options;
|
|
1050
1496
|
const listOpts = store.options.list ?? {};
|
|
1497
|
+
breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
|
|
1051
1498
|
const baseIndent = indentLevel * (listOpts.indentSize ?? options.page.indent);
|
|
1052
1499
|
const bullet = ordered ? `${start}. ` : listOpts.bulletChar ?? "• ";
|
|
1053
1500
|
const xLeft = options.page.xpading;
|
|
@@ -1124,6 +1571,7 @@ const renderRawItem = (doc, element, indentLevel, store, hasRawBullet, parentEle
|
|
|
1124
1571
|
}
|
|
1125
1572
|
store.updateX(xLeft, "set");
|
|
1126
1573
|
if (hasRawBullet && bullet) {
|
|
1574
|
+
breakIfOverflow(doc, store, getCharHight(doc) * options.page.defaultLineHeightFactor);
|
|
1127
1575
|
const bulletWidth = doc.getTextWidth(bullet);
|
|
1128
1576
|
const textMaxWidth = options.page.maxContentWidth - indent - bulletWidth;
|
|
1129
1577
|
doc.setFont(options.font.regular.name, options.font.regular.style);
|
|
@@ -1242,7 +1690,9 @@ const renderCodeBlock = (doc, element, indentLevel, store) => {
|
|
|
1242
1690
|
//#region src/renderer/components/blockquote.ts
|
|
1243
1691
|
const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
|
|
1244
1692
|
const options = store.options;
|
|
1693
|
+
const bqOpts = store.options.blockquote ?? {};
|
|
1245
1694
|
const savedDrawColor = doc.getDrawColor();
|
|
1695
|
+
const savedFillColor = doc.getFillColor();
|
|
1246
1696
|
const savedLineWidth = doc.getLineWidth();
|
|
1247
1697
|
const blockquoteIndent = indentLevel + 1;
|
|
1248
1698
|
const currentX = store.X + indentLevel * options.page.indent;
|
|
@@ -1255,17 +1705,27 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
|
|
|
1255
1705
|
});
|
|
1256
1706
|
const endY = store.lastContentY || store.Y;
|
|
1257
1707
|
const endPage = doc.internal.getCurrentPageInfo().pageNumber;
|
|
1258
|
-
const bqOpts = store.options.blockquote ?? {};
|
|
1259
1708
|
const barColor = bqOpts.barColor ?? "#AAAAAA";
|
|
1260
1709
|
const barWidth = bqOpts.barWidth ?? 1;
|
|
1261
1710
|
doc.setDrawColor(barColor);
|
|
1262
1711
|
doc.setLineWidth(barWidth);
|
|
1712
|
+
const bgColor = bqOpts.backgroundColor;
|
|
1263
1713
|
for (let p = startPage; p <= endPage; p++) {
|
|
1264
1714
|
doc.setPage(p);
|
|
1265
1715
|
const isStart = p === startPage;
|
|
1266
1716
|
const isEnd = p === endPage;
|
|
1267
1717
|
const lineTop = isStart ? startY : options.page.topmargin;
|
|
1268
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
|
+
}
|
|
1269
1729
|
doc.line(barX, lineTop, barX, lineBottom);
|
|
1270
1730
|
}
|
|
1271
1731
|
store.recordContentY();
|
|
@@ -1273,6 +1733,7 @@ const renderBlockquote = (doc, element, indentLevel, store, renderElement) => {
|
|
|
1273
1733
|
const bqBottomSpacing = bqOpts.bottomSpacing ?? options.spacing?.afterBlockquote ?? options.page.lineSpace;
|
|
1274
1734
|
store.updateY(bqBottomSpacing, "add");
|
|
1275
1735
|
doc.setDrawColor(savedDrawColor);
|
|
1736
|
+
doc.setFillColor(savedFillColor);
|
|
1276
1737
|
doc.setLineWidth(savedLineWidth);
|
|
1277
1738
|
};
|
|
1278
1739
|
//#endregion
|
|
@@ -1341,7 +1802,9 @@ const renderTable = (doc, element, indentLevel, store) => {
|
|
|
1341
1802
|
return;
|
|
1342
1803
|
}
|
|
1343
1804
|
const options = store.options;
|
|
1344
|
-
const
|
|
1805
|
+
const indent = indentLevel * options.page.indent;
|
|
1806
|
+
const marginLeft = options.page.xpading + indent;
|
|
1807
|
+
const availableWidth = Math.max(10, options.page.maxContentWidth - indent);
|
|
1345
1808
|
ensureSpace(doc, store, 20);
|
|
1346
1809
|
const columnCount = element.header.length;
|
|
1347
1810
|
const rows = (element.rows ?? []).map((row) => {
|
|
@@ -1374,8 +1837,9 @@ const renderTable = (doc, element, indentLevel, store) => {
|
|
|
1374
1837
|
startY: store.Y,
|
|
1375
1838
|
margin: {
|
|
1376
1839
|
left: marginLeft,
|
|
1377
|
-
right:
|
|
1840
|
+
right: Math.max(0, doc.internal.pageSize.getWidth() - (marginLeft + availableWidth))
|
|
1378
1841
|
},
|
|
1842
|
+
tableWidth: availableWidth,
|
|
1379
1843
|
...userTableOptions,
|
|
1380
1844
|
didDrawPage: safeDidDrawPage,
|
|
1381
1845
|
didDrawCell: safeDidDrawCell
|
|
@@ -1467,6 +1931,7 @@ var RenderStore = class {
|
|
|
1467
1931
|
//#endregion
|
|
1468
1932
|
//#region src/utils/options-validation.ts
|
|
1469
1933
|
const DEFAULT_HEADING_SIZES = {
|
|
1934
|
+
bold: true,
|
|
1470
1935
|
h1: 24,
|
|
1471
1936
|
h2: 20,
|
|
1472
1937
|
h3: 17,
|
|
@@ -1594,6 +2059,7 @@ const validateOptions = (options) => {
|
|
|
1594
2059
|
afterTable: 3,
|
|
1595
2060
|
...options.spacing ?? {}
|
|
1596
2061
|
};
|
|
2062
|
+
if (options.spacing?.betweenListItems === void 0) spacing.betweenListItems = list.itemSpacing ?? spacing.betweenListItems;
|
|
1597
2063
|
[
|
|
1598
2064
|
"afterHeading",
|
|
1599
2065
|
"afterParagraph",
|
|
@@ -1627,6 +2093,7 @@ const validateOptions = (options) => {
|
|
|
1627
2093
|
codeBlock,
|
|
1628
2094
|
spacing,
|
|
1629
2095
|
image,
|
|
2096
|
+
security: normalizeSecurityOptions(options.security),
|
|
1630
2097
|
endCursorYHandler
|
|
1631
2098
|
};
|
|
1632
2099
|
};
|
|
@@ -1682,6 +2149,140 @@ const applyFooter = (doc, options, pageNum, totalPages) => {
|
|
|
1682
2149
|
});
|
|
1683
2150
|
};
|
|
1684
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);
|
|
2284
|
+
};
|
|
2285
|
+
//#endregion
|
|
1685
2286
|
//#region src/renderer/MdTextRender.ts
|
|
1686
2287
|
/**
|
|
1687
2288
|
* Renders parsed markdown text into jsPDF document.
|
|
@@ -1692,9 +2293,18 @@ const applyFooter = (doc, options, pageNum, totalPages) => {
|
|
|
1692
2293
|
*/
|
|
1693
2294
|
const MdTextRender = async (doc, text, options) => {
|
|
1694
2295
|
const validOptions = validateOptions(options);
|
|
2296
|
+
const security = validOptions.security || {};
|
|
2297
|
+
const guardTimeout = createTimeoutGuard(security);
|
|
2298
|
+
enforceMarkdownLimits(text, security);
|
|
2299
|
+
guardTimeout();
|
|
1695
2300
|
const store = new RenderStore(validOptions);
|
|
1696
2301
|
const parsedElements = await MdTextParser(text);
|
|
1697
|
-
|
|
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);
|
|
1698
2308
|
const renderElement = (element, indentLevel = 0, store, hasRawBullet = false, start = 0, ordered = false) => {
|
|
1699
2309
|
const indent = indentLevel * validOptions.page.indent;
|
|
1700
2310
|
switch (element.type) {
|
|
@@ -1751,7 +2361,10 @@ const MdTextRender = async (doc, text, options) => {
|
|
|
1751
2361
|
break;
|
|
1752
2362
|
}
|
|
1753
2363
|
};
|
|
1754
|
-
for (const item of parsedElements)
|
|
2364
|
+
for (const item of parsedElements) {
|
|
2365
|
+
guardTimeout();
|
|
2366
|
+
renderElement(item, 0, store);
|
|
2367
|
+
}
|
|
1755
2368
|
applyPageDecorations(doc, validOptions);
|
|
1756
2369
|
validOptions.endCursorYHandler(store.Y);
|
|
1757
2370
|
};
|
|
@@ -1759,6 +2372,7 @@ const MdTextRender = async (doc, text, options) => {
|
|
|
1759
2372
|
exports.MdTextParser = MdTextParser;
|
|
1760
2373
|
exports.MdTextRender = MdTextRender;
|
|
1761
2374
|
exports.MdTokenType = MdTokenType;
|
|
2375
|
+
exports.SecurityViolationError = SecurityViolationError;
|
|
1762
2376
|
exports.renderInlineContent = renderInlineContent;
|
|
1763
2377
|
exports.renderPlainText = renderPlainText;
|
|
1764
2378
|
exports.validateOptions = validateOptions;
|