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