- Report p33 table: no Kranti column (no producer; materials.kranti stays blocked). - Report p53 table: the "SAV" column was the with-Lagna total (386). Split in the reader into SAV / Lagna BAV / SAV + Lagna with a totals row; ashtakavarga.py is untouched (frozen rectification identity, ERR-115). - /api/chart ascendant carries nakshatra / pada / lord (_nakshatra_fields shared with the nine bodies); chart cache schema 4 -> 5. - /api/varga_full bodies carry dignity_level from varga_dignity_level, the function the Vimsopaka path now shares (A/B identical); the chart page shows the D1 status wording and no longer derives a three-level dignity itself. - Report page draws each chart once: the trailing block skips charts the body already fenced (same rule as the export); the birth-time caveat moves under the body's chart heading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
130 lines
7.4 KiB
TypeScript
130 lines
7.4 KiB
TypeScript
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, "<br>");
|
||
}
|
||
|
||
function tableGrid(table: Pick<ReportFactTable, "columns" | "rows">, 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(/(<svg\b[^>]*>[\s\S]*?<\/svg>)\s*$/i)?.[1];
|
||
const safeSvg = svg?.slice(svg.lastIndexOf("<svg"))
|
||
?? reportChartExportSvg(toNorthIndianChart(block, language), chartAriaLabel(block.id, language), language);
|
||
add(`chart:${block.id}:${occurrence}`, "chart", chartAriaLabel(block.id, language), `${chartBlockText(block, language)}\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, 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<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, 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`;
|
||
}
|