Files
Jyotisha/jyotish-app/export.js
T
2026-07-01 19:15:35 +08:00

1094 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Jyotish Export System v2.0 — 100% 完整星盘数据导出
* 覆盖 Python engine full-reading 全部13模块:
* dasha, yoga, varga_full, aspects, jaimini, nakshatra_advanced,
* argala, tajika, shadbala, ashtakavarga, validation, audit, actionable_context
*
* 导出格式: JSON / HTML / SVG / PNG
*/
import { SIGNS, SIGN_LORDS, PLANET_CN } from './jyotish-engine.js';
const DASHA_SHADBALA_EXPORT_CALIBRATION_STATUS = {
id: 'dasha_shadbala',
title: 'Dasha/Shadbala Calibration Status',
queue: 'external_oracle_collection_queue',
validator: 'external_oracle_evidence_validation',
ready_for_calibration: 0,
valid_packets: 0,
production_tuning_allowed: false,
confidence_boundary: 'D1/D9/SAV 高可信;大运起点和 Shadbala 绝对值仍在外部 evidence validator 校准中。',
user_facing_rule: '不得把大运起点或 Shadbala 绝对值说成已完成外部校准。',
status_labels: [
'ready_for_calibration: 0',
'valid_packets: 0',
'production_tuning_allowed: false',
],
};
// ============================================================================
// JSON 导出 — 完整星盘数据 (100% coverage)
// ============================================================================
export function exportJSON(chartData, extras = {}) {
const { planets, ascendant, birth_info } = chartData;
const payload = {
meta: {
app: 'Jyotish Web App',
version: '4.4',
export_date: new Date().toISOString(),
ayanamsa: 'Lahiri (Chitrapaksha)',
provenance: extras.provenance || chartData?._client_audit?.provenance || null,
calculation_settings: extras.provenance?.calculationSettings || chartData?._calculation_settings || null,
terminology_mode: extras.provenance?.terminologyMode || chartData?._terminology_mode || null,
calibration_status: {
dasha_shadbala: DASHA_SHADBALA_EXPORT_CALIBRATION_STATUS,
},
},
birth_info,
ascendant: _cleanAscendant(ascendant),
};
// ── 1. Planets (完整字段) ──
payload.planets = {};
for (const [name, p] of Object.entries(planets)) {
if (p.error) continue;
payload.planets[name] = {
sign: p.sign, sign_cn: p.sign_cn,
degree: p.degree, degree_in_sign: p.degree_in_sign,
house: p.house, status: p.status || '',
retrograde: p.retrograde || false,
speed: p.speed || 0,
combust: p.combust || false,
combust_degree: p.combust_degree || null,
nakshatra: p.nakshatra, nakshatra_pada: p.nakshatra_pada,
nakshatra_lord: p.nakshatra_lord,
};
}
// ── 2. Dasha ──
if (extras.dasha) payload.modules = payload.modules || {};
if (extras.dasha) payload.modules.dasha = extras.dasha;
// ── 3. Yoga ──
if (extras.yogas) {
payload.modules = payload.modules || {};
const ascSign = ascendant?.sign || '';
const kendraLords = _getKendraLords(ascSign);
const trikonaLords = _getTrikonaLords(ascSign);
payload.modules.yoga = {
ascendant: ascSign,
planets_analyzed: Object.keys(payload.planets).length,
kendra_lords: kendraLords,
trikona_lords: trikonaLords,
yogas_detected: extras.yogas.length,
yogas: extras.yogas.map(y => ({
name: y.name || '',
name_cn: y.name_cn || '',
combination: y.combination || '',
effects: y.effects || [],
strength: y.strength || '',
})),
};
}
// ── 4. Varga Full ──
if (extras.vargas) {
payload.modules = payload.modules || {};
payload.modules.varga_full = extras.vargas;
}
// ── 5. Aspects ──
if (extras.aspects) {
payload.modules = payload.modules || {};
payload.modules.aspects = extras.aspects;
}
// ── 6. Jaimini ──
if (extras.jaimini) {
payload.modules = payload.modules || {};
payload.modules.jaimini = extras.jaimini;
}
// ── 7. Nakshatra Advanced ──
if (extras.nakshatraAdvanced) {
payload.modules = payload.modules || {};
payload.modules.nakshatra_advanced = extras.nakshatraAdvanced;
}
// ── 8. Argala ──
if (extras.argala) {
payload.modules = payload.modules || {};
payload.modules.argala = extras.argala;
}
// ── 9. Tajika ──
if (extras.tajika) {
payload.modules = payload.modules || {};
payload.modules.tajika = extras.tajika;
}
// ── 10. Shadbala ──
if (extras.shadbala) {
payload.modules = payload.modules || {};
payload.modules.shadbala = extras.shadbala;
}
// ── 11. Ashtakavarga ──
if (extras.ashtakavarga) {
payload.modules = payload.modules || {};
payload.modules.ashtakavarga = extras.ashtakavarga;
}
// ── 12. Panchanga ──
if (extras.panchanga) {
payload.modules = payload.modules || {};
payload.modules.panchanga = extras.panchanga;
}
// ── 13. Validation ──
if (extras.validation) {
payload.modules = payload.modules || {};
payload.modules.validation = extras.validation;
}
// ── 14. Audit ──
if (extras.audit) {
payload.modules = payload.modules || {};
payload.modules.audit = extras.audit;
}
// ── 15. Actionable Context ──
if (extras.actionableContext) {
payload.modules = payload.modules || {};
payload.modules.actionable_context = extras.actionableContext;
}
// ── 16. Calibration Status ──
payload.modules = payload.modules || {};
payload.modules.calibration_status = {
dasha_shadbala: DASHA_SHADBALA_EXPORT_CALIBRATION_STATUS,
};
// ── 16. User Workflows ──
if (extras.workflows) {
payload.modules = payload.modules || {};
payload.modules.workflows = extras.workflows;
}
// ── 17. Vimsopaka Semantic Summary ──
if (extras.vimsopaka_semantic_summary) {
payload.modules = payload.modules || {};
payload.modules.vimsopaka_semantic_summary = extras.vimsopaka_semantic_summary;
}
if (extras.vedastro_overview) {
payload.modules = payload.modules || {};
payload.modules.vedastro_overview = extras.vedastro_overview;
}
downloadFile(
JSON.stringify(payload, null, 2),
`jyotish-chart-${birth_info?.date || 'export'}.json`,
'application/json'
);
}
// ============================================================================
// HTML 报告导出 — 可直接打开/打印的单文件报告
// ============================================================================
export function exportHTMLReport(chartData, extras = {}) {
if (!chartData) return;
const birth = chartData.birth_info || {};
const html = _buildHTMLReport(chartData, extras);
downloadFile(
html,
`jyotish-report-${_safeFilename(birth.date || 'chart')}.html`,
'text/html;charset=utf-8'
);
}
// ============================================================================
// PDF 报告导出 — 复用后端 report_builder.py 的 Playwright PDF 流水线
// ============================================================================
export async function exportPDFReport(chartData, extras = {}) {
if (!chartData) return null;
const birth = chartData.birth_info || {};
const name = `jyotish-report-${_safeFilename(birth.date || birth.name || 'chart')}`;
const html = _buildHTMLReport(chartData, extras);
const api = window.JyotishAPI?.generateReportArtifact;
if (!api) {
const downloaded_filename = `${name}.html`;
downloadFile(html, downloaded_filename, 'text/html;charset=utf-8');
return {
success: false,
fallback: 'html',
report_artifact_fallback: true,
fallback_reason: 'report_artifact API unavailable',
error: 'report_artifact API unavailable',
artifact_status: 'local_html_fallback_ready',
primary_artifact: 'html',
download_filename: downloaded_filename,
download_mime: 'text/html;charset=utf-8',
user_message: '本地 API 未连接,已改为生成 HTML 报告。',
downloaded_filename,
delivery: {
artifact_status: 'local_html_fallback_ready',
format: 'html',
filename: downloaded_filename,
mime: 'text/html;charset=utf-8',
fallback: true,
fallback_reason: 'report_artifact API unavailable',
user_message: '本地 API 未连接,已改为生成 HTML 报告。',
next_action: '可直接打开,或用浏览器打印为 PDF',
},
};
}
const result = await api({ format: 'pdf', name, html });
let downloaded_filename = '';
if (result?.pdf_base64) {
downloaded_filename = result.download_filename || result.delivery?.filename || result.pdf_filename || `${name}.pdf`;
downloadBase64File(result.pdf_base64, downloaded_filename, result.download_mime || result.delivery?.mime || result.mime || 'application/pdf');
} else if (result?.html_base64) {
downloaded_filename = result.download_filename || result.delivery?.filename || result.html_filename || `${name}.html`;
downloadBase64File(result.html_base64, downloaded_filename, result.download_mime || result.delivery?.mime || 'text/html;charset=utf-8');
} else {
downloaded_filename = `${name}.html`;
downloadFile(html, downloaded_filename, 'text/html;charset=utf-8');
}
return {
...result,
downloaded_filename,
report_artifact_fallback: result?.fallback === 'html' || result?.delivery?.fallback === true,
fallback_reason: result?.fallback_reason || result?.delivery?.fallback_reason || result?.pdf_error || result?.message || '',
artifact_status: formatReportArtifactStatus({ ...result, downloaded_filename }),
};
}
export function formatReportArtifactStatus(result = {}) {
const delivery = result.delivery || {};
const filename = result.downloaded_filename || result.download_filename || delivery.filename || result.pdf_filename || result.html_filename || '报告文件';
const message = result.user_message || delivery.user_message || '';
if (result.pdf_base64 || delivery.format === 'pdf') {
return `${message || 'PDF 报告已生成并开始下载。'}已下载:${filename}`;
}
if (result.report_artifact_fallback || result.fallback === 'html' || delivery.fallback) {
const reason = result.fallback_reason || delivery.fallback_reason || result.error || 'PDF 渲染器不可用';
return `${message || '后端已生成 HTML 报告。'}已下载:${filename}${reason}。可直接打开,或用浏览器打印为 PDF。`;
}
if (result.html_base64 || delivery.format === 'html') {
return `${message || '后端已生成 HTML 报告。'}已下载:${filename}。可直接打开,或用浏览器打印为 PDF。`;
}
return `报告已开始下载。已下载:${filename}`;
}
// ============================================================================
// SVG 导出
// ============================================================================
export function exportSVG(chartElement, filename = 'jyotish-chart') {
if (!chartElement) return;
const svg = chartElement.querySelector('svg');
if (!svg) return;
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
const styleEl = document.createElementNS('http://www.w3.org/2000/svg', 'style');
styleEl.textContent = `text{font-family:'Noto Sans SC','Segoe UI',sans-serif;}.chart-planet-text{font-size:10px;fill:#333;}.chart-sign-text{font-size:9px;fill:#666;}.chart-asc-marker{font-size:11px;fill:#6d28d9;font-weight:700;}rect{stroke:#ccc;stroke-width:0.5;}`;
clone.insertBefore(styleEl, clone.firstChild);
downloadFile(new XMLSerializer().serializeToString(clone), `${filename}.svg`, 'image/svg+xml');
}
// ============================================================================
// PNG 导出
// ============================================================================
export async function exportPNG(chartElement, filename = 'jyotish-chart', scale = 2) {
if (!chartElement) return;
const svg = chartElement.querySelector('svg');
if (!svg) return;
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const w = parseFloat(svg.getAttribute('width')) || 400;
const h = parseFloat(svg.getAttribute('height')) || 400;
clone.setAttribute('width', w);
clone.setAttribute('height', h);
const styleEl = document.createElementNS('http://www.w3.org/2000/svg', 'style');
styleEl.textContent = `text{font-family:'Noto Sans SC','Segoe UI',sans-serif;fill:#333;}.chart-planet-text{font-size:10px;}.chart-sign-text{font-size:9px;fill:#666;}.chart-asc-marker{font-size:11px;fill:#6d28d9;font-weight:700;}rect{stroke:#ccc;stroke-width:0.5;}`;
clone.insertBefore(styleEl, clone.firstChild);
const svgStr = new XMLSerializer().serializeToString(clone);
const img = new Image();
const canvas = document.createElement('canvas');
canvas.width = w * scale; canvas.height = h * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
return new Promise((resolve) => {
img.onload = () => {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob(blob => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${filename}.png`; a.click();
URL.revokeObjectURL(url);
}
resolve();
}, 'image/png');
};
img.onerror = () => resolve();
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)));
});
}
// ============================================================================
// 工具函数
// ============================================================================
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function downloadBase64File(base64, filename, mimeType) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function _cleanAscendant(asc) {
if (!asc) return {};
return {
sign: asc.sign, sign_cn: asc.sign_cn,
degree: asc.degree, degree_in_sign: asc.degree_in_sign,
lord: asc.lord || SIGN_LORDS[asc.sign],
};
}
function _getKendraLords(ascSign) {
const ai = SIGNS.indexOf(ascSign);
if (ai < 0) return [];
const kendra = [1, 4, 7, 10];
return [...new Set(kendra.map(h => SIGN_LORDS[SIGNS[(ai + h - 1) % 12]]))];
}
function _getTrikonaLords(ascSign) {
const ai = SIGNS.indexOf(ascSign);
if (ai < 0) return [];
const trikona = [5, 9];
return [...new Set(trikona.map(h => SIGN_LORDS[SIGNS[(ai + h - 1) % 12]]))];
}
function _buildHTMLReport(chartData, extras) {
const birth = chartData.birth_info || {};
const asc = _cleanAscendant(chartData.ascendant || {});
const provenance = extras.provenance || chartData?._client_audit?.provenance || {};
const panchanga = extras.panchanga || {};
const tithiLord = chartData.tithi_lord_analysis || {};
const yogas = Array.isArray(extras.yogas) ? extras.yogas : [];
const dashaPeriods = Array.isArray(extras.dasha?.periods) ? extras.dasha.periods : [];
const validation = extras.validation || {};
const audit = extras.audit || {};
const workflows = extras.workflows || chartData._client_workflows || {};
const relationshipNarrative = extras.relationship_narrative || chartData?.ai_prompt_pack?.evidence_snapshot?.relationship_narrative || chartData?.relationship_narrative || null;
const careerNarrative = extras.career_narrative || chartData?.ai_prompt_pack?.evidence_snapshot?.career_narrative || chartData?.career_narrative || null;
const financeNarrative = extras.finance_narrative || chartData?.ai_prompt_pack?.evidence_snapshot?.finance_narrative || chartData?.finance_narrative || null;
const vimsopakaSemanticSummary = extras.vimsopaka_semantic_summary || chartData?.ai_prompt_pack?.evidence_snapshot?.vimsopaka_semantic_summary || chartData?.vimsopaka_semantic_summary || null;
const functionalBeneficMalefic = extras.functional_benefic_malefic || chartData?.ai_prompt_pack?.evidence_snapshot?.functional_benefic_malefic || chartData?.functional_benefic_malefic || null;
const vedastroOverview = extras.vedastro_overview || chartData?.ai_prompt_pack?.evidence_snapshot?.vedastro_overview || chartData?.vedastro_overview || null;
const techniqueAuditTable = extras.technique_audit_table || chartData?.ai_prompt_pack?.evidence_snapshot?.technique_audit_table || chartData?.technique_audit_table || null;
const generatedAt = new Date().toLocaleString('zh-CN');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jyotish Report - ${_h(birth.name || birth.date || 'Chart')}</title>
<style>
:root {
--paper: #fbfaf6; --ink: #24201b; --muted: #6d665d;
--line: #ded7ca; --soft: #f1ece2; --accent: #7a5a22;
--good: #166534; --warn: #92400e; --danger: #991b1b;
}
* { box-sizing: border-box; }
body {
margin: 0; background: #e8e2d7; color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.72;
}
main { max-width: 900px; margin: 0 auto; padding: 42px; background: var(--paper); min-height: 100vh; }
header { border-bottom: 2px solid var(--line); padding-bottom: 22px; margin-bottom: 24px; }
.eyebrow { color: var(--accent); font-size: 12px; font-weight: 800; letter-spacing: 1.6px; text-transform: uppercase; }
h1 { margin: 8px 0 10px; font-size: 34px; line-height: 1.2; }
h2 { margin: 28px 0 12px; font-size: 18px; border-bottom: 1px solid var(--line); padding-bottom: 7px; }
h3 { margin: 18px 0 8px; font-size: 15px; color: var(--accent); }
p { margin: 0 0 10px; color: var(--muted); }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.kv, .note, .yoga-card {
background: var(--soft); border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px;
}
.kv span { display: block; color: var(--muted); font-size: 11px; font-weight: 700; }
.kv strong { display: block; margin-top: 4px; font-size: 14px; overflow-wrap: anywhere; }
table { width: 100%; border-collapse: collapse; margin: 8px 0 18px; font-size: 13px; }
th, td { border-bottom: 1px solid var(--line); padding: 8px 9px; text-align: left; vertical-align: top; }
th { color: var(--accent); background: #f4efe6; font-size: 12px; }
.two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px; background: #fff; font-size: 12px; color: var(--muted); }
.score { color: var(--good); font-weight: 800; }
.warning { color: var(--warn); }
.yoga-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.yoga-card strong { display: block; font-size: 13px; }
.yoga-card small { display: block; color: var(--muted); margin-top: 3px; }
.workflow { margin-bottom: 12px; }
.workflow h3 { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
.workflow h3 span { color: var(--muted); font-size: 12px; font-weight: 600; }
.relationship-deliverable { border-left: 4px solid var(--accent); }
.relationship-hero {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: start;
background: #fff; border: 1px solid var(--line); border-radius: 6px; padding: 12px; margin: 10px 0;
}
.relationship-hero strong { display: block; font-size: 16px; color: var(--ink); }
.relationship-hero span { display: inline-block; color: var(--muted); font-size: 12px; margin-top: 4px; }
.relationship-status { color: var(--accent); font-weight: 800; white-space: nowrap; }
.relationship-evidence-grid,
.spouse-print-grid,
.uldk-print-grid,
.comparison-axis-grid,
.composite-print-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 8px 0 12px; }
.relationship-print-card {
background: #fff; border: 1px solid var(--line); border-radius: 6px; padding: 9px; break-inside: avoid;
}
.relationship-print-card span { display: block; color: var(--muted); font-size: 11px; }
.relationship-print-card strong { display: block; margin: 3px 0; font-size: 13px; color: var(--ink); }
.relationship-columns { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 8px 0 12px; }
.relationship-list { background: #fff; border: 1px solid var(--line); border-radius: 6px; padding: 9px; break-inside: avoid; }
.relationship-list strong { color: var(--accent); }
.relationship-list ul { margin: 6px 0 0; padding-left: 18px; color: var(--muted); }
.relationship-caution {
background: #fff8ed; border: 1px solid #f3d19c; border-left: 4px solid #c67a00;
border-radius: 6px; padding: 10px 12px; margin: 10px 0 12px; break-inside: avoid;
}
.relationship-caution strong { display: block; color: #7a4b00; margin-bottom: 4px; }
.relationship-caution span { display: block; color: #7a4b00; font-size: 12px; }
.relationship-boundary { background: #fff8ed; border: 1px solid #ead7b8; border-radius: 6px; padding: 9px; margin-top: 8px; }
.relationship-boundary span { display: block; color: var(--muted); font-size: 12px; }
.calibration-status {
background: #fff7ed; border: 1px solid #fed7aa; border-left: 4px solid var(--danger);
border-radius: 6px; padding: 12px; margin-top: 10px; break-inside: avoid;
}
.calibration-status strong { display: block; color: var(--danger); margin-bottom: 4px; }
.calibration-status span { display: block; color: var(--muted); font-size: 12px; }
.comparison-print-table { font-size: 12px; }
.factor-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 8px; }
.factor { background: #fff; border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
.factor span { display: block; color: var(--muted); font-size: 11px; }
.factor strong { display: block; margin-top: 3px; font-size: 13px; }
footer { margin-top: 32px; padding-top: 14px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); }
@media print {
body { background: var(--paper); }
main { padding: 0; max-width: none; }
h2 { page-break-after: avoid; }
table, .kv, .note, .yoga-card, .relationship-print-card, .relationship-list { break-inside: avoid; }
}
@media (max-width: 760px) {
main { padding: 22px; }
.grid, .two, .yoga-grid { grid-template-columns: 1fr; }
.relationship-hero, .relationship-evidence-grid, .relationship-columns,
.spouse-print-grid, .uldk-print-grid, .comparison-axis-grid, .composite-print-grid { grid-template-columns: 1fr; }
.factor-grid { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<main>
<header>
<div class="eyebrow">Jyotish Web App Report</div>
<h1>${_h(birth.name || '印度占星报告')}</h1>
<p>生成时间:${_h(generatedAt)}。本报告复用当前网页星盘、Panchanga、校验审计和导出模块,可直接用浏览器打开或打印为 PDF。</p>
</header>
<section>
<h2>出生资料与计算参数</h2>
<div class="grid">
${_kv('日期时间', `${birth.date || '-'} ${birth.time || ''} ${birth.tz || ''}`)}
${_kv('地点坐标', `${birth.lat ?? '-'}, ${birth.lon ?? '-'}`)}
${_kv('上升', `${asc.sign_cn || asc.sign || '-'} ${_fmt(asc.degree_in_sign ?? asc.degree)}°`)}
${_kv('Ayanamsa', provenance.ayanamsa || 'Lahiri')}
${_kv('Node', provenance.nodeMode || '-')}
${_kv('House', provenance.houseSystem || '-')}
${_kv('Sunrise', provenance.sunrisePolicy || '-')}
${_kv('Geocoder', provenance.geocoderPolicy || '-')}
${_kv('Ephemeris Backend', provenance.ephemerisBackend || '-')}
${_kv('Terminology', provenance.terminologyMode || '-')}
${_kv('Yoga 口径', provenance.yogaVariant || '-')}
${_kv('Jaimini 口径', provenance.jaiminiKarakaVariant || '-')}
${_kv('KP 口径', provenance.kpSignificatorVariant || '-')}
${_kv('Ashtakavarga 口径', provenance.ashtakavargaVariant || '-')}
${_kv('Shadbala 口径', provenance.shadbalaVariant || '-')}
${_kv('Dasha 口径', provenance.dashaReference || '-')}
${_kv('术语模式', provenance.terminologyLabel || provenance.terminologyMode || '-')}
${_kv('星历/引擎', provenance.engine || 'Jyotish Web App')}
${_kv('盘式', provenance.chartStyle || '-')}
</div>
</section>
<section>
<h2>Panchanga 与 Tithi Lord</h2>
<div class="two">
<div class="note">
<h3>Birth-time Panchanga</h3>
${_table([
['Vara', panchanga.vara || '-'],
['Tithi', `${panchanga.tithi?.number || '-'} · ${panchanga.tithi?.name || '-'}`],
['Paksha', panchanga.tithi?.paksha || '-'],
['Karana', panchanga.karana?.name || panchanga.karana || '-'],
['Yoga', `${panchanga.yoga?.number || '-'} · ${panchanga.yoga?.name || '-'}`],
])}
</div>
<div class="note">
<h3>Tithi Lord Analysis</h3>
${_table([
['Tithi Lord', tithiLord.tithi_lord || '-'],
['分数', tithiLord.tithi_score == null ? '-' : `${Math.round(Number(tithiLord.tithi_score) * 100)}%`],
['位置', `${tithiLord.lord_sign || '-'} H${tithiLord.lord_house || '-'}`],
['关系风格', tithiLord.relationship_style || tithiLord.emotional_pattern || '-'],
])}
</div>
</div>
</section>
<section>
<h2>行星位置</h2>
<table>
<thead><tr><th>行星</th><th>星座</th><th>度数</th><th>宫位</th><th>星宿</th><th>状态</th></tr></thead>
<tbody>${_planetRows(chartData.planets || {})}</tbody>
</table>
</section>
<section>
<h2>Yoga 摘要</h2>
${yogas.length ? `<div class="yoga-grid">${yogas.slice(0, 16).map(y => `
<div class="yoga-card">
<strong>${_h(y.name_cn || y.name || 'Yoga')}</strong>
<small>${_h(y.combination || y.strength || '-')}</small>
</div>
`).join('')}</div>` : '<p>当前导出未检测到 Yoga 摘要。</p>'}
${yogas.length > 16 ? `<p>另有 ${yogas.length - 16} 条 Yoga 已保留在 JSON 导出中。</p>` : ''}
</section>
<section>
<h2>Dasha 摘要</h2>
${dashaPeriods.length ? `<table>
<thead><tr><th>周期</th><th>开始</th><th>结束</th><th>层级/说明</th></tr></thead>
<tbody>${dashaPeriods.slice(0, 12).map(p => `<tr>
<td>${_h(p.lord || p.planet || p.name || '-')}</td>
<td>${_h(p.start || '-')}</td>
<td>${_h(p.end || '-')}</td>
<td>${_h(p.level || p.type || p.precision || '-')}</td>
</tr>`).join('')}</tbody>
</table>` : '<p>当前导出未附带 Dasha 时间线。</p>'}
</section>
${_functionalRoleSummarySection(functionalBeneficMalefic)}
${_vimsopakaSemanticSummarySection(vimsopakaSemanticSummary)}
${_vedastroOverviewSection(vedastroOverview)}
${_techniqueAuditTableSection(techniqueAuditTable)}
${_strictNarrativeSection('事业严格裁决', 'career', careerNarrative, '本段直接消费 career strict evidence,把本命 promise、dual dasha、官方时间窗与结构阻力写进用户可见正文。')}
${_strictNarrativeSection('财富严格裁决', 'finance', financeNarrative, '本段直接消费 finance strict evidence,把收入兑现、现金流动作、结构摩擦与时间边界写进用户可见正文。')}
${_relationshipStrictNarrativeSection(relationshipNarrative)}
${_workflowReportSection(workflows)}
${_calibrationStatusSection(DASHA_SHADBALA_EXPORT_CALIBRATION_STATUS)}
<section>
<h2>校验与审计</h2>
<div class="grid">
${_kv('Validation', validation.status || validation.overall || '-')}
${_kv('Audit', audit.overall || audit.status || '-')}
${_kv('Provenance', provenance.exportStatus || 'HTML/JSON/SVG/PNG export available')}
</div>
<div class="chips">
<span class="chip">65/65 capability registry</span>
<span class="chip">Panchanga preview</span>
<span class="chip">Tithi Lord connected</span>
<span class="chip">可浏览器打印 PDF</span>
</div>
</section>
<footer>
印度占星报告属于传统文化与自我反思工具,不能替代医疗、法律、投资或心理咨询。
</footer>
</main>
</body>
</html>`;
}
function _functionalRoleSummarySection(snapshot) {
if (!snapshot || typeof snapshot !== 'object') return '';
if (snapshot.status === 'blocked' || snapshot.status === 'not_used') return '';
const benefics = Array.isArray(snapshot.functional_benefics) ? snapshot.functional_benefics : [];
const malefics = Array.isArray(snapshot.functional_malefics) ? snapshot.functional_malefics : [];
const neutrals = Array.isArray(snapshot.functional_neutrals) ? snapshot.functional_neutrals : [];
const yogakarakas = Array.isArray(snapshot.yogakarakas) ? snapshot.yogakarakas : [];
return `<section class="functional-role-summary">
<h2>Functional Benefic/Malefic</h2>
<div class="note relationship-deliverable">
${_table([
['状态', snapshot.status || 'used'],
['Ascendant', snapshot.ascendant || snapshot.asc_sign || '-'],
['Functional Benefics', benefics.join('、') || '—'],
['Functional Malefics', malefics.join('、') || '—'],
['Functional Neutrals', neutrals.join('、') || '—'],
['Yogakarakas', yogakarakas.join('、') || '—'],
['Confidence Impact', snapshot.effect_on_confidence || '已进入高严谨裁决链。'],
])}
</div>
</section>`;
}
function _vimsopakaSemanticSummarySection(summary) {
if (!summary || typeof summary !== 'object') return '';
const highlights = Array.isArray(summary.highlights) ? summary.highlights : [];
const warnings = Array.isArray(summary.warnings) ? summary.warnings : [];
const status = summary.status || 'blocked';
return `<section class="vimsopaka-semantic-summary">
<h2>Vimsopaka 语义摘要</h2>
<div class="note relationship-deliverable">
<p>将 Vimsopaka dignity 结果转成用户可见语义,避免只剩分数没有解释边界。</p>
${_table([
['状态', status],
['积极语义', highlights.length ? highlights.join('') : '—'],
['风险语义', warnings.length ? warnings.join('') : '—'],
])}
</div>
</section>`;
}
function _techniqueAuditTableSection(rows) {
if (!Array.isArray(rows) || !rows.length) return '';
const formattedRows = rows.map(row => ([
row.technique || '-',
row.status || '-',
row.note || '-',
]));
return `<section class="technique-audit-table">
<h2>Technique Audit Table</h2>
${_table([['Technique', 'Status', 'Note'], ...formattedRows])}
</section>`;
}
function _vedastroOverviewSection(overview) {
if (!overview || typeof overview !== 'object') return '';
const domainStatuses = overview.domain_statuses && typeof overview.domain_statuses === 'object'
? Object.entries(overview.domain_statuses).map(([domain, status]) => `${domain}:${status}`).join('')
: '—';
const topEvents = overview.top_events_by_domain && typeof overview.top_events_by_domain === 'object'
? Object.entries(overview.top_events_by_domain).map(([domain, payload]) => {
const signal = payload?.signal_label || payload?.event_id || '—';
const start = payload?.start || '—';
return `${domain}:${signal} @ ${start}`;
}).join('')
: '—';
return `<section class="vedastro-overview-section">
<h2>VedAstro 概览证据</h2>
<div class="note relationship-deliverable">
<p>这是主入口自动附带的 VedAstro 单日概览,用来补充外部雷达视角;它是 overview only,不替代长周期精扫。</p>
${_table([
['状态', overview.status || 'blocked'],
['Source', overview.source || 'vedastro_service_adapter_candidate'],
['Ingestion', overview.ingestion_profile || '-'],
['Scope', overview.search_scope || 'single_day_overview'],
['Reference Date', overview.reference_date || '-'],
['Event Count', overview.event_count == null ? '0' : String(overview.event_count)],
['Domain Statuses', domainStatuses || '—'],
['Top Events', topEvents || '—'],
['Boundary', overview.boundary_note || 'overview only;不替代长周期精扫。'],
])}
</div>
</section>`;
}
function _relationshipStrictNarrativeSection(narrative) {
if (!narrative || typeof narrative !== 'object') return '';
const strengths = Array.isArray(narrative.strengths) ? narrative.strengths : [];
const risks = Array.isArray(narrative.risks) ? narrative.risks : [];
const boundaries = Array.isArray(narrative.boundaries) ? narrative.boundaries : [];
const markdown = typeof narrative.markdown === 'string' ? narrative.markdown : '';
const compactMarkdown = markdown
.replace(/^###\s+/gm, '')
.replace(/^\-\s+/gm, '')
.replace(/\n+/g, ' ')
.trim();
const showCaution = (
risks.some(item => String(item).includes('不能误读成接近结婚'))
|| boundaries.some(item => String(item).includes('不等于法律婚姻'))
);
return `<section class="relationship-strict-narrative">
<h2>婚恋严格裁决</h2>
<div class="relationship-hero">
<div>
<strong>${_h(narrative.headline || '婚恋 strict narrative 已接入导出主链。')}</strong>
<span>本段直接消费 relationship strict evidence,把 D9、UL、dual dasha 与 synastry taxonomy 的边界写进用户可见正文。</span>
</div>
<div class="relationship-status">strict narrative</div>
</div>
${showCaution ? `<div class="relationship-caution"><strong>防误判提示</strong><span>当前公开化/关系可见度候选不能被误读成接近法律婚姻;若 core marriage promise、dual dasha 或 external timing 仍未收敛,必须继续降置信度并保持 context-only 解释。</span></div>` : ''}
<div class="relationship-columns">
${_relationshipReportList('支持证据', strengths)}
${_relationshipReportList('需要观察', risks)}
${_relationshipReportList('边界条件', boundaries)}
</div>
${compactMarkdown ? `<div class="relationship-boundary"><span>${_h(compactMarkdown)}</span></div>` : ''}
</section>`;
}
function _strictNarrativeSection(title, domainClass, narrative, subtitle) {
if (!narrative || typeof narrative !== 'object') return '';
const strengths = Array.isArray(narrative.strengths) ? narrative.strengths : [];
const risks = Array.isArray(narrative.risks) ? narrative.risks : [];
const boundaries = Array.isArray(narrative.boundaries) ? narrative.boundaries : [];
const markdown = typeof narrative.markdown === 'string' ? narrative.markdown : '';
const compactMarkdown = markdown
.replace(/^###\s+/gm, '')
.replace(/^\-\s+/gm, '')
.replace(/\n+/g, ' ')
.trim();
return `<section class="${domainClass}-strict-narrative relationship-strict-narrative">
<h2>${_h(title)}</h2>
<div class="relationship-hero">
<div>
<strong>${_h(narrative.headline || `${title}已接入导出主链。`)}</strong>
<span>${_h(subtitle || 'strict narrative 已接入导出主链。')}</span>
</div>
<div class="relationship-status">strict narrative</div>
</div>
<div class="relationship-columns">
${_relationshipReportList('支持证据', strengths)}
${_relationshipReportList('需要观察', risks)}
${_relationshipReportList('边界条件', boundaries)}
</div>
${compactMarkdown ? `<div class="relationship-boundary"><span>${_h(compactMarkdown)}</span></div>` : ''}
</section>`;
}
function _calibrationStatusSection(status) {
return `<section>
<h2>高级技法校准状态</h2>
<div class="calibration-status">
<strong>${_h(status.title)}</strong>
<span>${_h(status.status_labels.join(''))}</span>
<span>${_h(status.validator)} · ${_h(status.queue)}</span>
<span>${_h(status.confidence_boundary)}</span>
<span>${_h(status.user_facing_rule)}</span>
</div>
</section>`;
}
function _workflowReportSection(workflows = {}) {
const sections = [
_kpWorkflowReport(workflows.kp),
_prashnaWorkflowReport(workflows.prashna),
_synastryWorkflowReport(workflows.synastry),
_caseLibraryWorkflowReport(workflows.case_library),
].filter(Boolean).join('');
if (!sections) {
return `<section>
<h2>用户工作流结果</h2>
<p>当前报告未附带 KP、Prashna 或合盘运行结果。运行对应 Tab 后再次导出,报告会自动带入最新结果。</p>
</section>`;
}
return `<section><h2>用户工作流结果</h2>${sections}</section>`;
}
function _caseLibraryWorkflowReport(caseLibrary) {
if (!caseLibrary) return '';
const pairs = Array.isArray(caseLibrary.synastry_pairs) ? caseLibrary.synastry_pairs : [];
const prashnas = Array.isArray(caseLibrary.prashna_cases) ? caseLibrary.prashna_cases : [];
if (!pairs.length && !prashnas.length) return '';
const pairRows = pairs.slice(0, 6).map(record => [
record.label || record.id || '未命名配对',
`${record.score?.total ?? 0} / ${record.score?.max ?? 36}`,
record.verdict || '-',
_dateLabel(record.updatedAt || record.generatedAt),
]);
const prashnaRows = prashnas.slice(0, 6).map(record => [
record.question_text || record.label || record.question_type || '未命名问事',
record.conclusion || '-',
record.confidence || '-',
_dateLabel(record.updatedAt || record.generatedAt),
]);
return `<div class="note workflow">
<h3>保存案例库 <span>${pairs.length} 配对 / ${prashnas.length} 问事</span></h3>
${pairRows.length ? `<h3>最近配对</h3>${_table([['案例', '分数', '判断', '更新时间'], ...pairRows])}` : ''}
${prashnaRows.length ? `<h3>最近问事</h3>${_table([['问题', '结论', '置信度', '更新时间'], ...prashnaRows])}` : ''}
</div>`;
}
function _kpWorkflowReport(kp) {
if (!kp) return '';
const houseCards = (kp.focus_houses || []).map(house => {
const data = kp.houses?.[house] || {};
const sig = data.significators || {};
const lords = data.kp_lords || {};
return `<div class="factor">
<span>${_h(house)}宫 · ${_h(data.sign || '-')}</span>
<strong>Sub ${_h(lords.sub_lord || '-')}</strong>
<span>A ${_h(_list(sig.A))}</span>
<span>B ${_h(_list(sig.B))}</span>
</div>`;
}).join('');
return `<div class="note workflow">
<h3>KP Sublord <span>${_h(kp.label || kp.focus || '-')}</span></h3>
<p>${_h(kp.note || 'KP 事件判断摘要。')}</p>
<div class="factor-grid">${houseCards || '<p>暂无 KP 宫位摘要。</p>'}</div>
</div>`;
}
function _prashnaWorkflowReport(prashna) {
if (!prashna) return '';
const answer = prashna.kp_answer_v2 || prashna.kp_answer || {};
const summary = prashna.summary || {};
const chart = prashna.prashna_chart || {};
const timing = prashna.timing || {};
const conclusion = summary.conclusion || answer.kp_answer || 'MAYBE — 需要更多信息确认';
return `<div class="note workflow">
<h3>Prashna 问事 <span>${_h(prashna.question_type || 'general')}</span></h3>
${prashna.question_text ? `<p>问题:${_h(prashna.question_text)}</p>` : ''}
${_table([
['结论', conclusion],
['置信度', summary.confidence || answer.confidence || '-'],
['问事上升', `${chart.asc_sign || '-'} ${chart.asc_degree ?? '-'}°`],
['问题宫', `${answer.primary_house || summary.primary_house || '-'}`],
['Sub Lord', answer.kp_sub_lord || '-'],
['时机', `${timing.score ?? '-'} / 100 · ${timing.rating || '-'}`],
['下一步', summary.next_action || timing.recommendation || '结合现实证据复核。'],
])}
</div>`;
}
function _synastryWorkflowReport(synastry) {
if (!synastry) return '';
const score = `${synastry.total_score ?? 0} / ${synastry.max_score ?? 36}`;
const percent = Number(synastry.match_percentage);
const status = synastry.is_match_approved ? '匹配通过' : '需要谨慎综合判断';
const deep = synastry.deep || {};
const relationshipReport = synastry.relationship_report || deep.relationshipReport || {};
const spouseStatus = deep.spouseStatus || {};
const ulDkTiming = deep.ulDkTiming || {};
const evidenceRows = Array.isArray(relationshipReport.evidence)
? relationshipReport.evidence.map(item => [item.label || '-', item.value || '-', item.note || '-'])
: [];
const evidenceCards = Array.isArray(relationshipReport.evidence)
? relationshipReport.evidence.map(item => `
<div class="relationship-print-card">
<span>${_h(item.label || '-')}</span>
<strong>${_h(item.value || '-')}</strong>
<span>${_h(item.note || '-')}</span>
</div>
`).join('')
: '';
const spouseRows = ['self', 'partner']
.map(key => spouseStatus[key] ? [
key === 'self' ? '本人' : '对方',
spouseStatus[key].verdict || '-',
`${spouseStatus[key].score ?? '-'} / 5`,
Array.isArray(spouseStatus[key].evidence) ? spouseStatus[key].evidence.slice(0, 2).join('') : '-',
] : null)
.filter(Boolean);
const spouseCards = ['self', 'partner']
.map(key => spouseStatus[key] ? `
<div class="relationship-print-card">
<span>${key === 'self' ? '本人' : '对方'} spouse-status</span>
<strong>${_h(spouseStatus[key].verdict || '-')}</strong>
<span>${_h(spouseStatus[key].score ?? '-')} / 5 · ${_h((spouseStatus[key].evidence || []).slice(0, 2).join('') || '-')}</span>
</div>
` : '')
.join('');
const ulDkCards = ['self', 'partner']
.map(key => ulDkTiming[key] ? `
<div class="relationship-print-card">
<span>${key === 'self' ? '本人' : '对方'} UL/DK timing</span>
<strong>${_h(ulDkTiming[key].summary || '-')}</strong>
<span>${_h((ulDkTiming[key].triggers || []).slice(0, 2).join('') || (ulDkTiming[key].evidence || []).slice(0, 2).join('') || '-')}</span>
</div>
` : '')
.join('');
const comparison = deep.comparison || {};
const axisCards = Array.isArray(comparison.axes)
? comparison.axes.slice(0, 4).map(axis => `
<div class="relationship-print-card">
<span>${_h(axis.label || '-')}</span>
<strong>${_h(axis.relation?.label || '-')}</strong>
<span>本人 ${_h(axis.self?.label || '-')} / 对方 ${_h(axis.partner?.label || '-')}</span>
</div>
`).join('')
: '';
const comparisonRows = Array.isArray(comparison.rows)
? comparison.rows.slice(0, 9).map(row => [
row.label || row.planet || '-',
row.self?.label || '-',
row.partner?.label || '-',
row.relation?.label || '-',
`对方落本人${row.partnerOverlayHouse || '-'}宫 / 本人落对方${row.selfOverlayHouse || '-'}`,
])
: [];
const midpointCards = Array.isArray(comparison.compositeStyle)
? comparison.compositeStyle.map(item => `
<div class="relationship-print-card">
<span>${_h(item.label || '-')} midpoint</span>
<strong>${_h(item.sign || '-')} ${_h(_fmt(item.degree))}°</strong>
<span>${_h(item.note || '-')}</span>
</div>
`).join('')
: '';
const scoreCards = Object.entries(synastry.scores || {}).slice(0, 8).map(([name, value]) => `
<div class="factor"><span>${_h(name)}</span><strong>${_h(value)} / ${_h(_kutaMax(name))}</strong></div>
`).join('');
return `<div class="note workflow relationship-deliverable">
<h3>合盘 Synastry <span>${_h(synastry.mode || 'synastry')}</span></h3>
<div class="relationship-hero">
<div>
<strong>${_h(relationshipReport.headline || '关系报告需要结合完整星盘、D9、Kuja 与 Dasha 复核。')}</strong>
<span>总分 ${_h(score)}${Number.isFinite(percent) ? ` · ${_h(percent.toFixed(1))}%` : ''}</span>
</div>
<div class="relationship-status">${_h(relationshipReport.statusLabel || status)}</div>
</div>
${_table([
['总分', `${score}${Number.isFinite(percent) ? ` · ${percent.toFixed(1)}%` : ''}`],
['状态', status],
['对方摘要', deep.partnerSummary ? `${deep.partnerSummary.ascendant || '-'} · Moon ${deep.partnerSummary.moon || '-'}` : '-'],
['Dasha 同步', deep.dasha?.note || '-'],
['UL/DK 时机', ['self', 'partner'].map(key => ulDkTiming[key]?.summary).filter(Boolean).join(' / ') || '-'],
['Kuja', deep.kuja?.note || '-'],
['D9', deep.d9 ? `本人 ${deep.d9.self?.score ?? '-'} / 对方 ${deep.d9.partner?.score ?? '-'}` : '-'],
])}
${evidenceCards ? `<h3>关系证据卡</h3><div class="relationship-evidence-grid">${evidenceCards}</div>` : (evidenceRows.length ? _table([['证据', '结果', '说明'], ...evidenceRows]) : '')}
${axisCards ? `<h3>双人轴线比较 <span>bi-wheel</span></h3><div class="comparison-axis-grid">${axisCards}</div>` : ''}
${comparisonRows.length ? _table([['行星', '本人', '对方', '互动', 'Overlay'], ...comparisonRows], 'comparison-print-table') : ''}
${midpointCards ? `<h3>Composite-style Midpoints</h3><div class="composite-print-grid">${midpointCards}</div>` : ''}
${ulDkCards ? `<h3>UL/DK 与关系时机 <span>Jaimini · Dasha trigger</span></h3><div class="uldk-print-grid">${ulDkCards}</div>` : ''}
${spouseCards ? `<h3>配偶/婚后成长 Yoga <span>spouse_status_yoga.py</span></h3><div class="spouse-print-grid">${spouseCards}</div>` : (spouseRows.length ? _table([['对象', '判断', '得分', '证据'], ...spouseRows]) : '')}
<div class="relationship-columns">
${_relationshipReportList('支持证据', relationshipReport.strengths)}
${_relationshipReportList('需要观察', relationshipReport.risks)}
${_relationshipReportList('下一步', relationshipReport.nextSteps)}
</div>
<div class="factor-grid">${scoreCards || '<p>暂无 Kuta 分项。</p>'}</div>
${_relationshipBoundary(relationshipReport.boundaries)}
</div>`;
}
function _relationshipReportBullets(label, items) {
if (!Array.isArray(items) || !items.length) return '';
return `<p><strong>${_h(label)}</strong>${_h(items.join(''))}</p>`;
}
function _relationshipReportList(label, items) {
if (!Array.isArray(items) || !items.length) return '';
return `<div class="relationship-list"><strong>${_h(label)}</strong><ul>${items.map(item => `<li>${_h(item)}</li>`).join('')}</ul></div>`;
}
function _relationshipBoundary(items) {
if (!Array.isArray(items) || !items.length) return '';
return `<div class="relationship-boundary">${items.map(item => `<span>${_h(item)}</span>`).join('')}</div>`;
}
function _planetRows(planets) {
const rows = Object.entries(planets)
.filter(([, p]) => p && !p.error)
.map(([name, p]) => {
const degree = p.degree_in_sign ?? (Number.isFinite(Number(p.lon ?? p.degree)) ? Number(p.lon ?? p.degree) % 30 : p.degree);
return `<tr>
<td>${_h(PLANET_CN[name] || name)}</td>
<td>${_h(p.sign_cn || p.sign || '-')}</td>
<td>${_h(_fmt(degree))}°</td>
<td>H${_h(p.house || '-')}</td>
<td>${_h(p.nakshatra || '-')} ${p.nakshatra_pada ? `p${_h(p.nakshatra_pada)}` : ''}</td>
<td>${_h(p.status || '')}${p.retrograde ? ' · R' : ''}${p.combust ? ' · combust' : ''}</td>
</tr>`;
});
return rows.join('') || '<tr><td colspan="6">无行星数据</td></tr>';
}
function _kv(label, value) {
return `<div class="kv"><span>${_h(label)}</span><strong>${_h(value || '-')}</strong></div>`;
}
function _table(rows, className = '') {
const classAttr = className ? ` class="${_h(className)}"` : '';
return `<table${classAttr}><tbody>${rows.map(row => {
const cells = Array.isArray(row) ? row : [row];
if (cells.length <= 2) {
return `<tr><th>${_h(cells[0])}</th><td>${_h(cells[1])}</td></tr>`;
}
return `<tr>${cells.map((cell, index) => index === 0 ? `<th>${_h(cell)}</th>` : `<td>${_h(cell)}</td>`).join('')}</tr>`;
}).join('')}</tbody></table>`;
}
function _list(value) {
if (Array.isArray(value)) return value.length ? value.join('、') : '-';
if (value === null || value === undefined || value === '') return '-';
return String(value);
}
function _kutaMax(name) {
return {
Varna: 1,
Vashya: 2,
Tara: 3,
Yoni: 4,
GrahaMaitri: 5,
Gana: 6,
Bhakoot: 7,
Nadi: 8,
}[name] ?? '-';
}
function _fmt(value, digits = 2) {
const n = Number(value);
return Number.isFinite(n) ? n.toFixed(digits) : '-';
}
function _dateLabel(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function _h(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function _safeFilename(value) {
return String(value || 'chart').replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'chart';
}