import type { ReportDocumentV2 } from "./personal-report-contract"; import { reportChartExportSvg } from "./report-chart-export"; import { buildLongformOutline, personalReportMarkdownFilename } from "./personal-report-longform-outline"; import { chartAriaLabel, fencedReportChartIds, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block"; import type { ReportFactTable } from "./report-fact-table-schema"; import { factCellText, factStatusLabel } from "./report-fact-table-display"; import type { ReportLanguage } from "./report-language"; import { projectOrdinaryReportMarkdown } from "./report-public-projection"; export type ReportExportBlock = Readonly<{ id: string; group: "body" | "chart" | "table"; title: string; text: string; chars: number; }>; export type ReportExportPreset = "common" | "charts" | "tables"; function houseLine(house: { houseNumber: number; sign: string; occupants: readonly string[] }, language: ReportLanguage): string { return language === "en" ? `- House ${house.houseNumber} · ${house.sign}: ${house.occupants.join(", ") || "—"}` : `- 第 ${house.houseNumber} 宫 · ${house.sign}:${house.occupants.join("、") || "—"}`; } export function chartBlockText(block: ReportChartBlock, language: ReportLanguage = "zh"): string { const chart = toNorthIndianChart(block, language); return `#### ${chartAriaLabel(block.id, language)}\n\n${chart.houses.map(house => houseLine(house, language)).join("\n")}`; } function tableCell(value: ReportFactTable["rows"][number]["cells"][number], language: ReportLanguage): string { return factCellText(value, language).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "
"); } function tableGrid(table: Pick, language: ReportLanguage): string { if (!table.rows.length) return ""; const cell = (value: ReportFactTable["rows"][number]["cells"][number]) => tableCell(value, language); return [ `| ${table.columns.map(cell).join(" | ")} |`, `| ${table.columns.map(() => "---").join(" | ")} |`, ...table.rows.map(row => `| ${row.cells.map(cell).join(" | ")} |`), ].join("\n"); } /** Same labels, precision, missing values and subtables as ReportFactTables; no provenance paths. */ export function factTableToMarkdown(group: ReportFactTable, language: ReportLanguage = "zh"): string { return projectOrdinaryReportMarkdown([ `## ${group.title}`, factStatusLabel(group.claimStatus, language), group.note, tableGrid(group, language), ...(group.subtables ?? []).flatMap(table => [`### ${table.title}`, table.note, tableGrid(table, language)]), ].filter(Boolean).join("\n\n"), language); } export function buildReportExportBlocks({ markdown, charts = [], factTables = [], language = "zh" }: { markdown: string; charts?: ReportDocumentV2["charts"]; factTables?: readonly ReportFactTable[]; /** Which edition `markdown` and `factTables` are; labels added here follow it. */ language?: ReportLanguage; }): ReportExportBlock[] { const projected = projectOrdinaryReportMarkdown(markdown, language); const outline = buildLongformOutline(projected); const blocks: ReportExportBlock[] = []; function add(id: string, group: ReportExportBlock["group"], title: string, text: string) { blocks.push({ id, group, title, text, chars: Array.from(text).length }); } // sourcePreamble/sourceMarkdown are the outline's lossless counterparts. The reader's // leadMarkdown also contains lifted summaries: using it here duplicates/reorders prose. if (outline.sourcePreamble) add("body:lead", "body", language === "en" ? "Summary" : "摘要", stripReportChartBlocks(outline.sourcePreamble)); for (const section of outline.sections) { add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown)); } const chartIds = fencedReportChartIds(projected); for (const match of projected.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) { const block = parseReportChartBlock(match[1]); if (!block) continue; const occurrence = blocks.filter(item => item.group === "chart" && item.id.startsWith(`chart:${block.id}:`)).length + 1; // Only the immediately preceding SVG belongs to this occurrence (D9 may repeat). // The public projection has already rejected unsafe SVG; never recover raw HTML. const preceding = projected.slice(0, match.index); const svg = preceding.match(/(]*>[\s\S]*?<\/svg>)\s*$/i)?.[1]; const safeSvg = svg?.slice(svg.lastIndexOf(" house.occupants.length)) continue; chartIds.add(chart.id); const title = chartAriaLabel(chart.id, language); // Occupants and signs of the structured layer are engine English names in both editions. const text = `#### ${title}\n\n${chart.houses.map(house => houseLine(house, language)).join("\n")}`; add(`chart:${chart.id}`, "chart", title, projectOrdinaryReportMarkdown(`${text}\n\n${reportChartExportSvg(chart, title, language)}`, language)); } for (const table of factTables) add(`table:${table.id}`, "table", table.title, factTableToMarkdown(table, language)); return blocks; } export function joinExportBlocks(blocks: readonly ReportExportBlock[]): string { // Preserve source boundaries (including CRLF/trailing whitespace) for D5. All // body slices are in outline order; stripping once more handles boundary blank runs. const body = stripReportChartBlocks(blocks.filter(block => block.group === "body").map(block => block.text).join("")); const added = blocks.filter(block => block.group !== "body").map(block => block.text).join("\n\n"); return body && added ? `${body}\n\n${added}` : body || added; } export function presetSelection(preset: ReportExportPreset, blocks: readonly ReportExportBlock[]): Set { const group = { common: "body", charts: "chart", tables: "table" }[preset]; return new Set(blocks.filter(block => block.group === group).map(block => block.id)); } /** downloadMarkdownReport appends .md; returning an extension here would duplicate it. */ export function exportFilename(reportDate: string | null | undefined, isFull: boolean, language: ReportLanguage = "zh"): string { if (language === "en") return `${personalReportMarkdownFilename(reportDate, "en")}${isFull ? "" : "-excerpt"}-EN`; return `${personalReportMarkdownFilename(reportDate)}${isFull ? "" : "-节选"}`; } /** UTF-8 bytes of exactly what the download would contain; not a per-character guess. */ export function exportByteSize(blocks: readonly ReportExportBlock[]): number { return blocks.length ? new TextEncoder().encode(joinExportBlocks(blocks)).length : 0; } /** 「约 186 KB」 / 「约 1.4 MB」; under 1 KB still reads 「约 1 KB」 so it never says 0. */ export function formatExportSize(bytes: number): string { if (bytes >= 1024 * 1024) return `约 ${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `约 ${Math.max(1, Math.round(bytes / 1024))} KB`; }