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