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(/(