Files
Jyotisha/frontend/src/lib/report-export-blocks.ts
T

123 lines
6.3 KiB
TypeScript
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.
import type { ReportDocumentV2 } from "./personal-report-contract";
import { reportChartExportSvg } from "./report-chart-export";
import { buildLongformOutline, personalReportMarkdownFilename } from "./personal-report-longform-outline";
import { chartAriaLabel, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block";
import type { ReportFactTable } from "./report-fact-table-schema";
import { factCellText, FACT_STATUS_LABELS } from "./report-fact-table-display";
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";
export function chartBlockText(block: ReportChartBlock): string {
const chart = toNorthIndianChart(block);
return `#### ${chartAriaLabel(block.id)}\n\n${chart.houses.map(house =>
`- 第 ${house.houseNumber} 宫 · ${house.sign}:${house.occupants.join("、") || "—"}`,
).join("\n")}`;
}
function tableCell(value: ReportFactTable["rows"][number]["cells"][number]): string {
return factCellText(value).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
}
function tableGrid(table: Pick<ReportFactTable, "columns" | "rows">): string {
if (!table.rows.length) return "";
return [
`| ${table.columns.map(tableCell).join(" | ")} |`,
`| ${table.columns.map(() => "---").join(" | ")} |`,
...table.rows.map(row => `| ${row.cells.map(tableCell).join(" | ")} |`),
].join("\n");
}
/** Same labels, precision, missing values and subtables as ReportFactTables; no provenance paths. */
export function factTableToMarkdown(group: ReportFactTable): string {
return projectOrdinaryReportMarkdown([
`## ${group.title}`,
FACT_STATUS_LABELS[group.claimStatus],
group.note,
tableGrid(group),
...(group.subtables ?? []).flatMap(table => [`### ${table.title}`, table.note, tableGrid(table)]),
].filter(Boolean).join("\n\n"));
}
export function buildReportExportBlocks({ markdown, charts = [], factTables = [] }: {
markdown: string;
charts?: ReportDocumentV2["charts"];
factTables?: readonly ReportFactTable[];
}): ReportExportBlock[] {
const projected = projectOrdinaryReportMarkdown(markdown);
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", "摘要", stripReportChartBlocks(outline.sourcePreamble));
for (const section of outline.sections) {
add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown));
}
const chartIds = new Set<string>();
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;
chartIds.add(block.id);
// 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(/(<svg\b[^>]*>[\s\S]*?<\/svg>)\s*$/i)?.[1];
const safeSvg = svg?.slice(svg.lastIndexOf("<svg"))
?? reportChartExportSvg(toNorthIndianChart(block), chartAriaLabel(block.id));
add(`chart:${block.id}:${occurrence}`, "chart", chartAriaLabel(block.id), `${chartBlockText(block)}\n\n${safeSvg}`);
}
// New structured layers can contain charts absent from old Markdown. Never invent
// missing houses, nor duplicate a chart already supplied by a validated fence.
for (const chart of charts) {
if (chartIds.has(chart.id) || !chart.houses.some(house => house.occupants.length)) continue;
chartIds.add(chart.id);
const title = chartAriaLabel(chart.id);
const text = `#### ${title}\n\n${chart.houses.map(house =>
`- 第 ${house.houseNumber} 宫 · ${house.sign}:${house.occupants.join("、") || "—"}`,
).join("\n")}`;
add(`chart:${chart.id}`, "chart", title, projectOrdinaryReportMarkdown(`${text}\n\n${reportChartExportSvg(chart, title)}`));
}
for (const table of factTables) add(`table:${table.id}`, "table", table.title, factTableToMarkdown(table));
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<string> {
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): string {
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`;
}