// 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 = `
    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

    ${failedItems.map((c, i) => ` `).join('')}
    #CheckImpactEffort
    ${i+1} ${escHtml(c.check || '')} High Medium
    ` : ''}
    `; // ── 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)

    AttributeValue
    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)

    ${pfxRows}
    PrefixStatusMax LengthROA Origin
    ` : ''}
    `; 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

    ${upstreamRows}
    ASNNamePower Score
    ` : '

    No upstream data available.

    '}
    `; const footer = ` `; // ── 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

    AttributeValue
    ASPA Present${aspaDirect ? 'Yes' : 'No'}
    Provider Count${aspaProviders}
    Registered Providers${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}
    ${allPfxRows ? `

    All Prefixes

    ${allPfxRows}
    PrefixRPKI StatusValidating ROAsMax Length
    ` : '

    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

    ${allUpstreamRows}
    ASNNamePower Score
    ` : '

    No upstream data available.

    '} ${(rel.peers || []).length > 0 ? `

    Peer Sample (top 20)

    ${(rel.peers || []).slice(0, 20).map(p => ``).join('')}
    ASNName
    AS${p.asn}${escHtml(p.name || '')}
    ` : ''}
    `; const checkDetailPage = `

    Check Score Detail

    All ${checks.length} health checks with individual scores and diagnostic notes.

    ${checkScoreRows}
    CheckStatusEarnedWeightDetail
    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

    ThreatASPA Mitigates?Notes
    Route leaks (valley-free violations)YesProviders verify upstream path
    BGP hijacks (origin AS spoofing)PartialCombined with ROV
    AS-path prepending attacksYesInvalid paths rejected
    Forged-origin hijacksNoROA + 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 SourceWhat it providesUpdate Frequency
    Cloudflare RPKI FeedROA + ASPA objectsEvery 4 hours
    PeeringDBIXP presence, peering policy, facilitiesDaily (03:00 UTC)
    RIPE StatAS relationships, prefix visibility, BGP dataReal-time / cached 15 min
    RIPE AtlasActive measurement probes by ASNCached 12 hours
    bgproutes.ioBGP route visibility fallbackReal-time
    bgp.he.netAS relationship graphCached 24 hours

    Report Metadata

    FieldValue
    ASNAS${asn}
    Network Name${escHtml(name)}
    Report Format${formatLabel(format)}
    Generated${ts}
    PlatformPeerCortex · 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 ? ` ${commonIxps.map(x => ` `).join('')}
    IXP NameCityAS${d1.asn} SpeedAS${d2.asn} Speed
    ${escHtml(x.name)} ${escHtml(x.city)} ${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'} ${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}
    ` : '

    No common IXPs found — these networks do not share any Internet Exchange Points.

    '; const commonFacHtml = commonFacs.length > 0 ? ` ${commonFacs.map(f => ` `).join('')}
    Datacenter / Colocation FacilityCityCountry
    ${escHtml(f?.name||'')} ${escHtml(f?.city||'')} ${escHtml(f?.country||'')}
    ` : '

    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
    ASN Comparison Report
    AS${d1.asn} vs AS${d2.asn}
    ${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}
    ${colHtml}
    Generated by PeerCortex · ${ts} · peercortex.org

    Head-to-Head Health Checks

    ${checkRows}
    CheckAS${d1.asn}AS${d2.asn}
    ${peeringPage} `; } 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 };