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