// FEATURE 2: PDF Export -- pure HTML/CSS template generators, no I/O
// FEATURE 2: PDF Export — template generators + puppeteer renderer
// ============================================================
/**
* Generate a health score ring SVG (inline, no external deps)
* @param {number} score 0–100
* @param {string} color CSS color
*/
function scoreRingSvg(score, color) {
const r = 54, cx = 64, cy = 64;
const circ = 2 * Math.PI * r;
const fill = circ * (1 - score / 100);
const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444');
return `
${score}
/100
`;
}
/** Shared CSS for all PDF templates */
function pdfBaseCSS() {
return `
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; }
@page { size: A4; margin: 18mm 16mm; }
.page { page-break-after: always; min-height: 247mm; padding: 0; }
.page:last-child { page-break-after: avoid; }
h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; }
h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; }
h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; }
p { margin-bottom: 0.5em; }
table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; }
th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; }
td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; }
tr:nth-child(even) td { background: #f5f3ff; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; }
.badge-green { background: #dcfce7; color: #166534; }
.badge-red { background: #fee2e2; color: #991b1b; }
.badge-yellow{ background: #fef9c3; color: #854d0e; }
.badge-gray { background: #f3f4f6; color: #374151; }
.badge-indigo{ background: #e0e7ff; color: #3730a3; }
.cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; }
.cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; }
.cover-logo span { color: #1e1b4b; }
.cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; }
.cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; }
.cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; }
.cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; }
.cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; }
.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; }
.metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; }
.metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; }
.metric-card .unit { font-size: 8pt; color: #9ca3af; }
.score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; }
.check-list { list-style: none; padding: 0; }
.check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; }
.check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
.check-pass { color: #16a34a; }
.check-fail { color: #dc2626; }
.check-warn { color: #d97706; }
.section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; }
footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; }
`;
}
/**
* Build the PDF HTML for a single ASN.
* @param {object} data Full /api/validate response
* @param {object} aspa /api/aspa response (may be null)
* @param {string} format 'report' | 'executive' | 'technical'
*/
function buildPdfHtml(data, aspa, format) {
const asn = data.asn || '?';
const name = data.name || 'Unknown Network';
const score = Math.round(data.health_score || 0);
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const checks = (data.score_breakdown || []);
const rel = data.relationships || {};
const counts = rel.counts || {};
// validations is an object keyed by check name; rpki details live in rpki_completeness
const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {};
const rpkiDetails = (validations.rpki_completeness?.details || []);
const totalPfx = data.meta?.total_prefixes || 0;
// Derived stats — use rpki_completeness for RPKI metrics
const rpki = validations.rpki_completeness || {};
const rpkiCoverage = rpki.coverage_pct ?? 0;
const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length;
const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length;
const passedChecks = checks.filter(c => c.status === 'pass').length;
const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
const aspaDirect = aspa?.direct?.has_aspa ?? null;
const aspaProviders= aspa?.direct?.provider_asns?.length || 0;
// Check list HTML
function checkIcon(passed) {
if (passed) return ' ';
return ' ';
}
const checksHtml = checks.map(c => {
const isPassed = c.status === 'pass';
const isInfo = c.status === 'info';
const icon = checkIcon(isPassed || isInfo);
const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red';
const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL';
return `
${icon}
${escHtml(c.check || '')}
${statusLabel}
${c.earned !== undefined ? `${c.earned}/${c.weight}pt ` : ''}
`;
}).join('');
// Upstream providers table
const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
AS${u.asn} ${escHtml(u.name || '')} ${u.power ?? '-'}
`).join('');
// Prefix validations table (sample from rpki_completeness.details)
const pfxRows = rpkiDetails.slice(0, 20).map(v => {
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
return `${escHtml(v.prefix)}
${v.status}
${v.validating_roas ?? '-'}
— `;
}).join('');
// Recommendations
const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
const recHtml = failedItems.length === 0
? '✓ No critical issues found. Network health is excellent.
'
: failedItems.map((c, i) => `
${i+1}. ${escHtml(c.check || '')}
${c.detail ? `
${escHtml(c.detail)}
` : ''}
`).join('');
// ── Executive pages (always present) ───────────────────────────────────────
const coverPage = `
⬡ PeerCortex Network Intelligence
AS${asn}
${escHtml(name)}
${formatLabel(format)} Report
${scoreRingSvg(score)}
Health Score
${score}/100
${passedChecks} passed · ${failedChecks} failed
Generated by PeerCortex · ${ts} · peercortex.org
| ${totalPfx} prefixes analysed
| RPKI coverage ${rpkiCoverage}%
`;
const summaryPage = `
Executive Summary
Key performance indicators for AS${asn} (${escHtml(name)}).
Health Score
${score}
out of 100
RPKI Coverage
${rpkiCoverage}%
${rpkiValid}/${rpkiTotal} prefixes
Upstreams
${counts.upstreams || 0}
providers
Downstreams
${counts.downstreams || 0}
customers
Peers
${counts.peers || 0}
BGP sessions
ASPA Status
${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
${aspaProviders} providers
Status Overview
`;
// ── Recommendations page ────────────────────────────────────────────────────
const recsPage = `
Recommendations
Action items to improve network health and security posture.
${recHtml}
${failedItems.length > 0 ? `
Remediation Priority
# Check Impact Effort
${failedItems.map((c, i) => `
${i+1}
${escHtml(c.check || '')}
High
Medium
`).join('')}
` : ''}
`;
// ── Detail pages (included for 'report' and 'technical') ───────────────────
const rpkiPage = `
RPKI Compliance
Route Origin Authorization (ROA) validation results for announced prefixes.
Coverage
${rpkiCoverage}%
${rpkiValid} valid ROAs
Invalid
${rpkiInvalid}
prefix${rpkiInvalid !== 1 ? 'es' : ''}
Not Found
${rpkiNotFound}
no ROA
ASPA (Autonomous System Provider Authorization)
Attribute Value
ASPA Object Present ${aspaDirect ? 'Yes' : 'No'}
Provider Count ${aspaProviders}
Providers ${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || '—'}
${pfxRows ? `
Prefix Validation Sample (first 20)
Prefix Status Max Length ROA Origin
${pfxRows}
` : ''}
`;
const bgpPage = `
BGP Topology
AS relationship overview and upstream connectivity analysis.
Upstreams
${counts.upstreams || 0}
transit providers
Downstreams
${counts.downstreams || 0}
customers
Peers
${counts.peers || 0}
BGP peers
${upstreamRows ? `
Top Upstream Providers
ASN Name Power Score
${upstreamRows}
` : '
No upstream data available.
'}
`;
const footer = `
PeerCortex · AS${asn} · ${escHtml(name)}
Generated ${ts} · peercortex.org
`;
// ── Extra pages for technical / report formats ─────────────────────────────
// Technical: full prefix table (no row cap) + all upstream rows + check scores
const allPfxRows = rpkiDetails.map(v => {
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
return `${escHtml(v.prefix)}
${v.status}
${v.validating_roas ?? '-'}
${v.max_length ?? '-'} `;
}).join('');
const allUpstreamRows = (rel.upstreams || []).map(u =>
`AS${u.asn} ${escHtml(u.name || '')} ${u.power ?? '-'} `
).join('');
const checkScoreRows = checks.map(c =>
`
${escHtml(c.check || '')}
${c.status?.toUpperCase()}
${c.earned ?? '-'} ${c.weight ?? '-'}
${escHtml(c.detail || '')}
`
).join('');
const technicalHeaderPage = `
PeerCortex · Technical Analysis Report
AS${asn} — ${escHtml(name)}
Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%
Health Score
${score}/100
${passedChecks} pass · ${failedChecks} fail
RPKI Coverage
${rpkiCoverage}%
${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing
Upstreams
${counts.upstreams || 0}
transit providers
Peers / Downstreams
${counts.peers || 0} / ${counts.downstreams || 0}
BGP relationships
ASPA
${aspaDirect === true ? 'Deployed' : 'Not deployed'}
${aspaProviders} providers registered
Prefixes Analysed
${totalPfx}
${rpkiDetails.length} with RPKI data
`;
const rpkiFullPage = `
RPKI Compliance — Full Prefix Table
${rpkiDetails.length} prefixes with RPKI validation data. Coverage: ${rpkiCoverage}% · Valid: ${rpkiValid} · Invalid: ${rpkiInvalid} · Not Found: ${rpkiNotFound}
ASPA Object
Attribute Value
ASPA Present ${aspaDirect ? 'Yes' : 'No'}
Provider Count ${aspaProviders}
Registered Providers ${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}
${allPfxRows ? `
All Prefixes
Prefix RPKI Status Validating ROAs Max Length
${allPfxRows}
` : '
No RPKI prefix data available.
'}
`;
const bgpFullPage = `
BGP Topology — Full Relationship Table
Complete AS relationship data. Upstreams: ${counts.upstreams || 0} · Peers: ${counts.peers || 0} · Downstreams: ${counts.downstreams || 0}
${allUpstreamRows ? `
All Upstream Providers
ASN Name Power Score
${allUpstreamRows}
` : '
No upstream data available.
'}
${(rel.peers || []).length > 0 ? `
Peer Sample (top 20)
ASN Name
${(rel.peers || []).slice(0, 20).map(p => `AS${p.asn} ${escHtml(p.name || '')} `).join('')}
` : ''}
`;
const checkDetailPage = `
Check Score Detail
All ${checks.length} health checks with individual scores and diagnostic notes.
Check Status Earned Weight Detail
${checkScoreRows}
Scoring: Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points.
Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100
`;
// Report: add data provenance and ASPA deep-dive
const aspaPage = `
ASPA — Provider Authorization Analysis
Autonomous System Provider Authorization (ASPA) is a BGP security mechanism that cryptographically links an ASN to its upstream providers in the RPKI.
ASPA Deployed
${aspaDirect === true ? 'Yes' : 'No'}
Providers Listed
${aspaProviders}
in ASPA object
RPKI Coverage
${rpkiCoverage}%
of announced prefixes
What ASPA Protects Against
Threat ASPA Mitigates? Notes
Route leaks (valley-free violations) Yes Providers verify upstream path
BGP hijacks (origin AS spoofing) Partial Combined with ROV
AS-path prepending attacks Yes Invalid paths rejected
Forged-origin hijacks No ROA + ROV required
${aspaDirect === false ? `
Recommendation: Deploy ASPA
1. Identify all upstream transit providers (${counts.upstreams || 0} detected)
2. Create an ASPA object in your RIR portal listing their ASNs
3. Sign with your RPKI key — takes effect within hours
4. Verify with: rpki-client -v or RIPE RPKI Validator
` : `
ASPA is deployed — listed providers:
${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(' · ') || '—'}
`}
`;
const provenancePage = `
Data Provenance
All data sources, cache ages, and methodology used to generate this report.
Data Source What it provides Update Frequency
Cloudflare RPKI Feed ROA + ASPA objects Every 4 hours
PeeringDB IXP presence, peering policy, facilities Daily (03:00 UTC)
RIPE Stat AS relationships, prefix visibility, BGP data Real-time / cached 15 min
RIPE Atlas Active measurement probes by ASN Cached 12 hours
bgproutes.io BGP route visibility fallback Real-time
bgp.he.net AS relationship graph Cached 24 hours
Report Metadata
Field Value
ASN AS${asn}
Network Name ${escHtml(name)}
Report Format ${formatLabel(format)}
Generated ${ts}
Platform PeerCortex · peercortex.org
Prefixes Analysed ${totalPfx}
RPKI Objects Checked ${rpkiDetails.length}
Health Checks Run ${checks.length}
This report is generated automatically based on publicly available BGP and RPKI data.
Data may be delayed by cache TTLs. Not a substitute for real-time monitoring.
MIT License · github.com/renefichtmueller/PeerCortex
`;
// ── Compose pages by format ────────────────────────────────────────────────
let bodyContent;
if (format === 'executive') {
// 3 pages: clean stakeholder summary — no deep technical data
bodyContent = coverPage + summaryPage + recsPage;
} else if (format === 'technical') {
// ~8 pages: engineer-focused, dense, no decorative cover, full data tables
bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage;
} else {
// 'report' — full stakeholder + technical, ~10 pages
bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage;
}
return `
PeerCortex — AS${asn} ${formatLabel(format)}
${bodyContent}
${footer}
`;
}
/**
* Build comparison PDF HTML for two ASNs.
*/
function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) {
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
function col(d, aspa) {
const asn = d.asn || '?';
const name = d.name || 'Unknown';
const score = Math.round(d.health_score || 0);
const rel = d.relationships || {};
const counts= rel.counts || {};
const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {};
const rpki = vals.rpki_completeness || {};
const rpkiV = rpki.with_roa || 0;
const rpkiT = rpki.total_checked || 0;
const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0);
const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
const aspaDep = aspa?.direct?.has_aspa ?? null;
const checks= (d.score_breakdown || []);
const passC = checks.filter(c => c.status === 'pass').length;
const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
return `
${scoreRingSvg(score, sc)}
AS${asn}
${escHtml(name)}
Key Metrics
Health Score ${score}/100
RPKI Coverage ${rpkiC}% (${rpkiV}/${rpkiT})
Upstreams ${counts.upstreams || 0}
Downstreams ${counts.downstreams || 0}
Peers ${counts.peers || 0}
ASPA ${aspaDep ? 'Deployed' : 'Not Deployed'}
Checks Passed ${passC} / ${checks.length}
Checks Failed ${failC}
Total Prefixes ${d.meta?.total_prefixes || 0}
`;
}
const colHtml = col(d1, aspa1) + col(d2, aspa2);
// ── Peering Intelligence: common IXPs + common facilities ─────────────────
const ixList1 = (l1?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
const ixList2 = (l2?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
const ixIds1 = new Set(ixList1.map(x => x.id));
const ixIds2 = new Set(ixList2.map(x => x.id));
const commonIxIds = [...ixIds1].filter(id => ixIds2.has(id));
const commonIxps = commonIxIds.slice(0, 20).map(id => {
const x1 = ixList1.find(x => x.id === id);
const x2 = ixList2.find(x => x.id === id);
return { id, name: x1?.name || 'Unknown', city: x1?.city || '', speed1: x1?.speed, speed2: x2?.speed };
});
const facList1 = (l1?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
const facList2 = (l2?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
const facIds1 = new Set(facList1.map(f => f.id));
const facIds2 = new Set(facList2.map(f => f.id));
const commonFacIds = [...facIds1].filter(id => facIds2.has(id));
const commonFacs = commonFacIds.slice(0, 15).map(id => facList1.find(f => f.id === id));
// IXPs of ASN1 where ASN2 is NOT present (opportunity IXPs for ASN2)
const oppsFor2 = ixList1.filter(x => !ixIds2.has(x.id)).slice(0, 8);
const oppsFor1 = ixList2.filter(x => !ixIds1.has(x.id)).slice(0, 8);
const commonIxHtml = commonIxps.length > 0
? `
IXP Name City AS${d1.asn} Speed AS${d2.asn} Speed
${commonIxps.map(x => `
${escHtml(x.name)}
${escHtml(x.city)}
${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}
${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}
`).join('')}
`
: 'No common IXPs found — these networks do not share any Internet Exchange Points.
';
const commonFacHtml = commonFacs.length > 0
? `
Datacenter / Colocation Facility City Country
${commonFacs.map(f => `
${escHtml(f?.name||'')}
${escHtml(f?.city||'')}
${escHtml(f?.country||'')}
`).join('')}
`
: 'No shared colocation facilities found.
';
const oppsHtml = (asns, opps) => opps.length === 0
? `Already maximally peered on these exchanges — or no data available.
`
: opps.map(x => `
${escHtml(x.name)}
${x.city ? ` · ${escHtml(x.city)} ` : ''}
${x.speed ? `${(x.speed/1000).toLocaleString()}G ` : ''}
`).join('');
const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? `
Peering Intelligence
Common Internet Exchange Points and colocation facilities — potential peering locations between AS${d1.asn} and AS${d2.asn}.
${commonIxps.length}
Common IXPs
${commonFacs.length}
Shared Facilities
Common Internet Exchange Points (direct peering possible)
${commonIxHtml}
Shared Colocation Facilities
${commonFacHtml}
${oppsFor1.length + oppsFor2.length > 0 ? `
Expansion Opportunities
AS${d2.asn} could join (AS${d1.asn} is there)
${oppsHtml(d2.asn, oppsFor2)}
AS${d1.asn} could join (AS${d2.asn} is there)
${oppsHtml(d1.asn, oppsFor1)}
` : ''}
` : '';
// Side-by-side check comparison
const allChecks = [...new Set([
...(d1.score_breakdown || []).map(c => c.check),
...(d2.score_breakdown || []).map(c => c.check),
])];
const checkRows = allChecks.map(checkName => {
const c1 = (d1.score_breakdown || []).find(c => c.check === checkName);
const c2 = (d2.score_breakdown || []).find(c => c.check === checkName);
const isP1 = c1?.status === 'pass';
const isP2 = c2?.status === 'pass';
const icon = p => p ? '✓' : '✗';
const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700';
return `
${escHtml(checkName)}
${c1 ? icon(isP1) : '—'}
${c2 ? icon(isP2) : '—'}
`;
}).join('');
return `
PeerCortex — AS${d1.asn} vs AS${d2.asn} Comparison
⬡ PeerCortex Network Intelligence
ASN Comparison Report
AS${d1.asn} vs AS${d2.asn}
${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}
Generated by PeerCortex · ${ts} · peercortex.org
Head-to-Head Health Checks
Check AS${d1.asn} AS${d2.asn}
${checkRows}
${peeringPage}
PeerCortex · AS${d1.asn} vs AS${d2.asn}
Generated ${ts} · peercortex.org
`;
}
function formatLabel(fmt) {
return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report';
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
module.exports = { scoreRingSvg, pdfBaseCSS, buildPdfHtml, buildComparisonPdfHtml, formatLabel, escHtml };