Files
Jyotisha/frontend/src/lib/report-export-blocks.ts
T
Jesse_ChenandClaude Opus 5.5 ae18952e51
Independent Staging Quality Gate / validate (push) Successful in 15m27s
Independent Staging Quality Gate / publish (push) Successful in 3m42s
fix(report,chart): blank columns — drop Kranti, split Ashtakavarga house SAV, ascendant nakshatra, engine varga dignity, no duplicate charts (BUG-1199..1203)
- 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
2026-10-03 09:11:15 +08:00

130 lines
7.4 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, 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`;
}