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