fix(report): draw North Indian charts from fences after skipHtml dropped SVG

Longform report pages kept the D1/D9/Moon and varga headings but skipHtml
stripped the engine's inline SVG. Emit a jyotish-chart JSON fence beside
each SVG and render a diamond chart on the reader without relaxing HTML
sanitization. BUG-607.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-09 15:11:00 +08:00
co-authored by Cursor
parent d5972ef44a
commit 2fdcb14fd6
23 changed files with 2628 additions and 124 deletions
+1
View File
@@ -357,6 +357,7 @@ Text release is paced, not animated: the frame buffer commits at most once per a
- **Structure:** sticky screen chrome (back, secondary print, primary “导出报告(.md)”), then the longform Markdown body with a sticky heading TOC. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette.
- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed.
- **Charts:** each `####` chart heading is followed by a North Indian diamond figure, not engine SVG HTML. Figures are max 360px and centered. From 720px, consecutive heading+figure pairs sit two to a row; print and narrower viewports stay one column. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 112. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`.
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge.
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them.
- **Width:** 1120px for the reader chrome so the TOC rail can sit beside the article; the TOC stacks into a drawer below 860px.
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "tsx --test tests/*.test.ts",
"test": "tsx --test tests/*.test.ts tests/*.test.tsx",
"test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts",
"test:deployment": "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts tests/staging-image-manifest.test.ts",
"db:migrate": "node scripts/db-migrate.mjs",
+64 -6
View File
@@ -3815,6 +3815,37 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.personal-report-md-lead,
.personal-report-md-section { min-width: 0; }
.personal-report-md-article h4.personal-report-chart-heading {
margin: var(--space-5) 0 var(--space-2);
font-size: var(--type-title-sm);
font-weight: 500;
}
@media (min-width: 720px) {
/* Charts live in the eager 摘要 lead, not only later lazy H2 sections. */
.personal-report-md-lead > h4.personal-report-chart-heading:has(+ .personal-report-chart-figure),
.personal-report-md-section > h4.personal-report-chart-heading:has(+ .personal-report-chart-figure) {
float: left;
width: 50%;
box-sizing: border-box;
padding-inline: var(--space-3);
margin-top: var(--space-5);
margin-bottom: 0;
}
.personal-report-md-lead > h4.personal-report-chart-heading + .personal-report-chart-figure,
.personal-report-md-section > h4.personal-report-chart-heading + .personal-report-chart-figure {
float: left;
width: 50%;
margin-left: -50%;
margin-top: var(--space-5);
padding-top: 2.4em;
box-sizing: border-box;
max-width: none;
}
.personal-report-md-lead > .personal-report-chart-figure + :not(h4):not(.personal-report-chart-figure),
.personal-report-md-section > .personal-report-chart-figure + :not(h4):not(.personal-report-chart-figure) {
clear: both;
}
}
@media (max-width: 860px) {
.personal-report-md-layout { grid-template-columns: 1fr; }
.personal-report-toc { position: static; max-height: none; }
@@ -4092,12 +4123,22 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.personal-report-subheading span { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.personal-report-chart-svg { display: block; width: 100%; height: auto; margin-top: var(--space-4); color: var(--color-ink); }
.personal-report-chart-svg .personal-report-chart-cell { fill: none; stroke: var(--color-ink-strong); stroke-width: 1.25; }
.personal-report-chart-svg .personal-report-chart-core { fill: var(--color-canvas-muted); stroke: var(--color-ink-strong); stroke-width: 1.25; }
.personal-report-chart-svg .personal-report-chart-diamond { fill: none; stroke: var(--color-ink-strong); stroke-width: 1.25; }
.personal-report-chart-svg .personal-report-chart-cell { fill: none; stroke: var(--color-border-strong); stroke-width: 1.25; }
.personal-report-chart-svg .personal-report-chart-core { fill: var(--color-canvas-muted); stroke: var(--color-border-strong); stroke-width: 1.25; }
.personal-report-chart-svg .personal-report-chart-diamond { fill: none; stroke: var(--color-border-strong); stroke-width: 1.25; }
.personal-report-chart-svg text { fill: currentColor; }
.personal-report-chart-svg .personal-report-chart-muted { fill: var(--color-ink-tertiary); }
.personal-report-chart-figure { min-width: 0; margin: 0; }
.personal-report-chart-svg .personal-report-chart-muted { fill: var(--color-ink-muted, var(--color-ink-tertiary)); }
.personal-report-chart-figure {
min-width: 0;
max-width: 360px;
width: 100%;
margin: var(--space-4) auto var(--space-6);
}
.personal-report-chart-invalid {
margin: var(--space-3) 0;
color: var(--color-ink-muted, var(--color-ink-tertiary));
font-size: var(--type-caption);
}
.personal-report-chart-figure figcaption {
margin-top: var(--space-3);
color: var(--color-ink-tertiary);
@@ -4365,8 +4406,25 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.personal-report-narrative p { font-size: 9.6pt; line-height: 1.72; orphans: 3; widows: 3; }
.personal-report-lead-grid { grid-template-columns: minmax(0, 1.04fr) minmax(64mm, .96fr); gap: 8mm; }
.personal-report-chart-svg { max-width: none; margin-top: 3mm; }
.personal-report-md-article .personal-report-chart-figure {
float: none;
width: auto;
max-width: 360px;
margin-left: auto;
margin-right: auto;
padding-top: 0;
break-inside: avoid;
}
.personal-report-md-article h4.personal-report-chart-heading {
float: none;
width: auto;
margin-left: auto;
margin-right: auto;
max-width: 360px;
padding-inline: 0;
}
.personal-report-chart-grid,
.personal-report-chart-grid.is-single { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.personal-report-chart-grid.is-single { grid-template-columns: 1fr; }
.personal-report-chart-grid.is-single { max-width: 118mm; }
.personal-report-table-wrap { max-width: none !important; overflow: visible !important; }
.personal-report-table-wrap table,
@@ -4,13 +4,13 @@ import { Check, Copy } from "lucide-react";
import { isValidElement, useEffect, useRef, useState, type ReactNode } from "react";
/** The fenced language, from the `language-xxx` class react-markdown puts on <code>. */
function fencedLanguage(node: ReactNode): string {
export function fencedLanguage(node: ReactNode): string {
if (!isValidElement<{ className?: string }>(node)) return "";
return /language-([\w+-]+)/.exec(node.props.className ?? "")?.[1] ?? "";
}
/** The raw source inside the fence, for the copy button. */
function fencedSource(node: ReactNode): string {
export function fencedSource(node: ReactNode): string {
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(fencedSource).join("");
if (isValidElement<{ children?: ReactNode }>(node)) return fencedSource(node.props.children);
@@ -4,6 +4,13 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { fencedLanguage, fencedSource } from "@/components/markdown-code-block";
import {
chartAriaLabel,
parseReportChartBlock,
toNorthIndianChart,
} from "@/lib/report-chart-block";
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
import {
buildLongformOutline,
type LongformHeading,
@@ -49,6 +56,32 @@ function markdownComponents(headings: readonly LongformHeading[]): Components {
),
h2: ({ children }) => <h2 id={nextId(2, children)}>{children}</h2>,
h3: ({ children }) => <h3 id={nextId(3, children)}>{children}</h3>,
h4: ({ children }) => {
const title = flattenText(children).trim();
const isChart = /^(?:D\d{1,3}\b|Moon Chart)/.test(title);
return (
<h4 className={isChart ? "personal-report-chart-heading" : undefined}>
{children}
</h4>
);
},
pre: ({ children, ...props }) => {
if (fencedLanguage(children) !== "jyotish-chart") {
return <pre {...props}>{children}</pre>;
}
const block = parseReportChartBlock(fencedSource(children).trim());
if (!block) {
return <p className="personal-report-chart-invalid"></p>;
}
return (
<figure className="personal-report-chart-figure">
<NorthIndianChartSvg
ariaLabel={chartAriaLabel(block.id)}
chart={toNorthIndianChart(block)}
/>
</figure>
);
},
};
}
@@ -1,101 +1,83 @@
"use client";
/**
* Pure React SVG North Indian (Rasi) chart for the personal report.
* Pure React SVG North Indian (Rasi) chart.
*
* - All text comes from the schema-validated report document (house sign,
* occupants, retrograde markers from the real planets array).
* - No canvas, no base64, no remote images/fonts, no inner-HTML injection APIs.
* - Uses a viewBox so print output stays sharp at any size.
* - Fixed house cells follow the standard North Indian layout:
*
* 12 | 11 | 10 | 9
* 1 | | | 8
* 2 | | | 7
* 3 | 4 | 5 | 6
* House geometry is the standard diamond chart: two diagonals plus the
* inner rhombus from the four midpoints. Sign numbers are rasi ordinals
* (1 = Aries 12 = Pisces). Occupant labels are already display text.
*/
import { useId } from "react";
import type { ChartV1 } from "@/lib/personal-report-contract";
import {
CHART_SIZE,
HOUSE_NUMBERS,
HOUSE_POLYGONS,
polygonCentroid,
polygonPoints,
signNumberAnchor,
} from "@/lib/north-indian-chart-geometry";
import { signOrdinal, type NorthIndianChartModel } from "@/lib/report-chart-block";
type HouseV1 = ChartV1["houses"][number];
export const CHART_VIEWBOX_WIDTH = CHART_SIZE;
export const CHART_VIEWBOX_HEIGHT = CHART_SIZE;
export const CHART_VIEWBOX_WIDTH = 400;
export const CHART_VIEWBOX_HEIGHT = 400;
const CELL = 100;
const HOUSE_CELLS: Record<number, { x: number; y: number }> = {
1: { x: 0, y: CELL },
2: { x: 0, y: CELL * 2 },
3: { x: 0, y: CELL * 3 },
4: { x: CELL, y: CELL * 3 },
5: { x: CELL * 2, y: CELL * 3 },
6: { x: CELL * 3, y: CELL * 3 },
7: { x: CELL * 3, y: CELL * 2 },
8: { x: CELL * 3, y: CELL },
9: { x: CELL * 3, y: 0 },
10: { x: CELL * 2, y: 0 },
11: { x: CELL, y: 0 },
12: { x: 0, y: 0 },
};
const HOUSE_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
export type VedicChartModel = NorthIndianChartModel;
interface VedicChartSvgProps {
chart: ChartV1;
chart: NorthIndianChartModel;
ariaLabel: string;
}
function buildRetrogradeSet(chart: ChartV1): Set<string> {
function buildRetrogradeSet(chart: NorthIndianChartModel): Set<string> {
const retrograde = new Set<string>();
for (const planet of chart.planets ?? []) {
if (planet.retrograde) {
retrograde.add(planet.name);
}
if (planet.retrograde) retrograde.add(planet.name);
}
return retrograde;
}
/** Combine house occupants with real retrograde markers from chart.planets. */
function occupantLines(house: HouseV1, chart: ChartV1): string[] {
function occupantLines(house: NorthIndianChartModel["houses"][number], chart: NorthIndianChartModel): string[] {
const retrograde = buildRetrogradeSet(chart);
return house.occupants.map((name) => (retrograde.has(name) ? `${name}` : name));
return house.occupants.map((name) => {
if (name.includes("逆") || name.includes("°")) return name;
return retrograde.has(name) ? `${name}` : name;
});
}
const MAX_RENDERED_OCCUPANTS = 8;
function truncateSvgLabel(value: string, maxCharacters: number): string {
const characters = Array.from(value);
if (characters.length <= maxCharacters) return value;
return `${characters.slice(0, Math.max(1, maxCharacters - 1)).join("")}`;
}
const MAX_RENDERED_OCCUPANTS = 6;
function visibleOccupantLines(lines: string[]): string[] {
if (lines.length <= MAX_RENDERED_OCCUPANTS) return lines;
return [...lines.slice(0, MAX_RENDERED_OCCUPANTS - 1), `+${lines.length - (MAX_RENDERED_OCCUPANTS - 1)}`];
return [...lines.slice(0, MAX_RENDERED_OCCUPANTS - 1), `+${lines.length - (MAX_RENDERED_OCCUPANTS - 1)}`];
}
function PlanetList({ lines }: { lines: string[] }) {
function OccupantStack({
lines,
origin,
}: {
lines: string[];
origin: { x: number; y: number };
}) {
const visible = visibleOccupantLines(lines);
const twoColumns = visible.length > 4;
const fontSize = twoColumns ? 8.5 : visible.length > 2 ? 10 : 11;
const rowStep = twoColumns ? 13 : 15;
const maxCharacters = twoColumns ? 5 : 8;
const fontSize = visible.length > 4 ? 8 : 9;
const rowStep = visible.length > 4 ? 10 : 12;
const startY = origin.y - ((visible.length - 1) * rowStep) / 2;
return (
<>
{visible.map((line, index) => {
const column = twoColumns ? Math.floor(index / 4) : 0;
const row = twoColumns ? index % 4 : index;
return (
{visible.map((line, index) => (
<text
key={`${line}-${index}`}
x={6 + column * 49}
y={45 + row * rowStep}
x={origin.x}
y={startY + index * rowStep}
textAnchor="middle"
fontSize={fontSize}
>
{truncateSvgLabel(line, maxCharacters)}
{line}
</text>
);
})}
))}
</>
);
}
@@ -106,9 +88,7 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
for (const house of chart.houses) {
linesByHouse.set(house.houseNumber, occupantLines(house, chart));
}
const hasRetrogradeMarker = chart.houses.some((house) =>
house.occupants.some((name) => (chart.planets ?? []).some((p) => p.retrograde && p.name === name)),
);
const hasRetrogradeMarker = [...linesByHouse.values()].some((lines) => lines.some((line) => line.includes("逆")));
return (
<svg
@@ -120,55 +100,51 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
className="personal-report-chart-svg"
>
<defs>
{HOUSE_NUMBERS.map((houseNumber) => {
const cell = HOUSE_CELLS[houseNumber];
return (
<clipPath key={houseNumber} id={`${chartId}-house-${houseNumber}`}>
<rect x={cell.x + 2} y={cell.y + 2} width={CELL - 4} height={CELL - 4} />
</clipPath>
);
})}
{HOUSE_NUMBERS.map((houseNumber) => (
<clipPath key={houseNumber} id={`${chartId}-house-${houseNumber}`}>
<polygon points={polygonPoints(HOUSE_POLYGONS[houseNumber])} />
</clipPath>
))}
</defs>
{HOUSE_NUMBERS.map((houseNumber) => {
const cell = HOUSE_CELLS[houseNumber];
const house = chart.houses.find((h) => h.houseNumber === houseNumber);
const polygon = HOUSE_POLYGONS[houseNumber];
const house = chart.houses.find((item) => item.houseNumber === houseNumber);
const lines = house ? linesByHouse.get(houseNumber) ?? [] : [];
const centroid = polygonCentroid(polygon);
const numberAnchor = signNumberAnchor(houseNumber);
const ordinal = house ? signOrdinal(house.sign) : null;
return (
<g key={houseNumber}>
<rect
x={cell.x}
y={cell.y}
width={CELL}
height={CELL}
<polygon
points={polygonPoints(polygon)}
className="personal-report-chart-cell"
/>
<text x={cell.x + 5} y={cell.y + 15} fontSize={10} className="personal-report-chart-muted">
{houseNumber}
</text>
{house && (
<text x={cell.x + 5} y={cell.y + 30} fontSize={12} fontWeight={600}>
{house.sign}
{ordinal !== null && (
<text
x={numberAnchor.x}
y={numberAnchor.y}
textAnchor="middle"
dominantBaseline="middle"
fontSize={10}
className="personal-report-chart-muted"
>
{ordinal}
</text>
)}
<g transform={`translate(${cell.x} ${cell.y})`} clipPath={`url(#${chartId}-house-${houseNumber})`}>
<PlanetList lines={lines} />
<g clipPath={`url(#${chartId}-house-${houseNumber})`}>
<OccupantStack lines={lines} origin={centroid} />
</g>
</g>
);
})}
<rect
x={CELL}
y={CELL}
width={CELL * 2}
height={CELL * 2}
className="personal-report-chart-core"
/>
<path
d={`M ${CELL * 2} ${CELL} L ${CELL * 3} ${CELL * 2} L ${CELL * 2} ${CELL * 3} L ${CELL} ${CELL * 2} Z`}
className="personal-report-chart-diamond"
/>
{hasRetrogradeMarker && (
<text x={CHART_VIEWBOX_WIDTH - 6} y={CHART_VIEWBOX_HEIGHT - 4} textAnchor="end" fontSize={9} className="personal-report-chart-muted">
<text
x={CHART_VIEWBOX_WIDTH - 6}
y={CHART_VIEWBOX_HEIGHT - 8}
textAnchor="end"
fontSize={9}
className="personal-report-chart-muted"
>
=
</text>
)}
@@ -176,7 +152,9 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
);
}
export function hasRealChartData(chart: ChartV1): boolean {
export const NorthIndianChartSvg = VedicChartSvg;
export function hasRealChartData(chart: NorthIndianChartModel): boolean {
return Array.isArray(chart.houses) && chart.houses.length > 0
&& chart.houses.some((house) => Array.isArray(house.occupants) && house.occupants.length > 0);
}
@@ -0,0 +1,114 @@
/** North Indian (diamond) house polygons for a square of side S. */
export const CHART_SIZE = 400;
export type Point = Readonly<{ x: number; y: number }>;
const S = CHART_SIZE;
const H = S / 2;
const Q = S / 4;
/** House 1 is the top diamond; numbers run clockwise from there. */
export const HOUSE_POLYGONS: Readonly<Record<number, readonly Point[]>> = {
1: [{ x: H, y: 0 }, { x: 3 * Q, y: Q }, { x: H, y: H }, { x: Q, y: Q }],
2: [{ x: 0, y: 0 }, { x: H, y: 0 }, { x: Q, y: Q }],
3: [{ x: 0, y: 0 }, { x: Q, y: Q }, { x: 0, y: H }],
4: [{ x: 0, y: H }, { x: Q, y: Q }, { x: H, y: H }, { x: Q, y: 3 * Q }],
5: [{ x: 0, y: H }, { x: Q, y: 3 * Q }, { x: 0, y: S }],
6: [{ x: 0, y: S }, { x: Q, y: 3 * Q }, { x: H, y: S }],
7: [{ x: H, y: S }, { x: Q, y: 3 * Q }, { x: H, y: H }, { x: 3 * Q, y: 3 * Q }],
8: [{ x: H, y: S }, { x: 3 * Q, y: 3 * Q }, { x: S, y: S }],
9: [{ x: S, y: S }, { x: 3 * Q, y: 3 * Q }, { x: S, y: H }],
10: [{ x: S, y: H }, { x: 3 * Q, y: 3 * Q }, { x: H, y: H }, { x: 3 * Q, y: Q }],
11: [{ x: S, y: H }, { x: 3 * Q, y: Q }, { x: S, y: 0 }],
12: [{ x: S, y: 0 }, { x: 3 * Q, y: Q }, { x: H, y: 0 }],
};
export const HOUSE_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const;
const CENTER: Point = { x: H, y: H };
export function polygonPoints(points: readonly Point[]): string {
return points.map((point) => `${point.x},${point.y}`).join(" ");
}
export function polygonCentroid(points: readonly Point[]): Point {
const areaTimes2 = points.reduce((sum, point, index) => {
const next = points[(index + 1) % points.length];
return sum + (point.x * next.y - next.x * point.y);
}, 0);
if (Math.abs(areaTimes2) < 1e-9) {
const n = points.length || 1;
return {
x: points.reduce((sum, point) => sum + point.x, 0) / n,
y: points.reduce((sum, point) => sum + point.y, 0) / n,
};
}
const cx = points.reduce((sum, point, index) => {
const next = points[(index + 1) % points.length];
return sum + (point.x + next.x) * (point.x * next.y - next.x * point.y);
}, 0) / (3 * areaTimes2);
const cy = points.reduce((sum, point, index) => {
const next = points[(index + 1) % points.length];
return sum + (point.y + next.y) * (point.x * next.y - next.x * point.y);
}, 0) / (3 * areaTimes2);
return { x: cx, y: cy };
}
export function polygonArea(points: readonly Point[]): number {
const sum = points.reduce((total, point, index) => {
const next = points[(index + 1) % points.length];
return total + (point.x * next.y - next.x * point.y);
}, 0);
return Math.abs(sum) / 2;
}
export function pointInPolygon(point: Point, polygon: readonly Point[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const a = polygon[i];
const b = polygon[j];
const intersect = (a.y > point.y) !== (b.y > point.y)
&& point.x < ((b.x - a.x) * (point.y - a.y)) / ((b.y - a.y) || Number.EPSILON) + a.x;
if (intersect) inside = !inside;
}
return inside;
}
function distance(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function moveToward(from: Point, to: Point, distancePx: number): Point {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy) || 1;
return { x: from.x + (dx / length) * distancePx, y: from.y + (dy / length) * distancePx };
}
function rightAngleVertex(points: readonly Point[]): Point {
for (let index = 0; index < points.length; index += 1) {
const prev = points[(index + points.length - 1) % points.length];
const vertex = points[index];
const next = points[(index + 1) % points.length];
const d1x = prev.x - vertex.x;
const d1y = prev.y - vertex.y;
const d2x = next.x - vertex.x;
const d2y = next.y - vertex.y;
if (Math.abs(d1x * d2x + d1y * d2y) < 1e-6) return vertex;
}
return points[0];
}
function innerVertex(points: readonly Point[]): Point {
return points.reduce((best, point) => (distance(point, CENTER) < distance(best, CENTER) ? point : best));
}
export function signNumberAnchor(houseNumber: number): Point {
const polygon = HOUSE_POLYGONS[houseNumber];
const centroid = polygonCentroid(polygon);
if (polygon.length === 4) {
return moveToward(innerVertex(polygon), centroid, 12);
}
return moveToward(rightAngleVertex(polygon), centroid, 14);
}
@@ -1,6 +1,7 @@
import { downloadMarkdownReport } from "./consultation-report-export";
import { PERSONAL_REPORT_LEGACY_PLACEHOLDER } from "./personal-report-longform-copy";
import { personalReportMarkdownFilename } from "./personal-report-longform-outline";
import { stripReportChartBlocks } from "./report-chart-block";
export async function requestPersonalReportLongformAppendix(reportId: string): Promise<string> {
const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/professional-reference`, {
@@ -30,5 +31,8 @@ export async function downloadPersonalReportLongformAppendix(
const markdown = options.markdown?.trim()
? options.markdown
: await requestPersonalReportLongformAppendix(reportId);
downloadMarkdownReport(personalReportMarkdownFilename(options.reportDate), markdown);
downloadMarkdownReport(
personalReportMarkdownFilename(options.reportDate),
stripReportChartBlocks(markdown),
);
}
+166
View File
@@ -0,0 +1,166 @@
import { z } from "zod";
const SIGNS = [
"Aries",
"Taurus",
"Gemini",
"Cancer",
"Leo",
"Virgo",
"Libra",
"Scorpio",
"Sagittarius",
"Capricorn",
"Aquarius",
"Pisces",
] as const;
const PLANETS = [
"Sun",
"Moon",
"Mars",
"Mercury",
"Jupiter",
"Venus",
"Saturn",
"Rahu",
"Ketu",
] as const;
const PLANET_LABEL: Record<(typeof PLANETS)[number], string> = {
Sun: "日",
Moon: "月",
Mars: "火",
Mercury: "水",
Jupiter: "木",
Venus: "金",
Saturn: "土",
Rahu: "罗",
Ketu: "计",
};
const SIGN_NUMBER: Record<string, number> = {
Aries: 1,
Taurus: 2,
Gemini: 3,
Cancer: 4,
Leo: 5,
Virgo: 6,
Libra: 7,
Scorpio: 8,
Sagittarius: 9,
Capricorn: 10,
Aquarius: 11,
Pisces: 12,
白羊: 1,
金牛: 2,
双子: 3,
巨蟹: 4,
狮子: 5,
处女: 6,
天秤: 7,
天蝎: 8,
射手: 9,
摩羯: 10,
水瓶: 11,
双鱼: 12,
白羊座: 1,
金牛座: 2,
双子座: 3,
巨蟹座: 4,
狮子座: 5,
处女座: 6,
天秤座: 7,
天蝎座: 8,
射手座: 9,
摩羯座: 10,
水瓶座: 11,
双鱼座: 12,
};
const FENCE_RE = /(?:\n[ \t]*)?```jyotish-chart\n[\s\S]*?```(?:\n[ \t]*)?/g;
export const reportChartBlockSchema = z.strictObject({
version: z.literal(1),
id: z.string().regex(/^(D\d{1,3}|MOON)$/),
title: z.string().max(120),
layout: z.literal("north"),
ascendant: z.strictObject({
sign: z.enum(SIGNS),
degree: z.number().gte(0).lt(30),
}),
planets: z.array(z.strictObject({
name: z.enum(PLANETS),
sign: z.enum(SIGNS),
degree: z.number().gte(0).lt(30),
retrograde: z.boolean(),
})).max(9),
});
export type ReportChartBlock = z.infer<typeof reportChartBlockSchema>;
export type NorthIndianChartModel = {
houses: { houseNumber: number; sign: string; occupants: string[] }[];
planets?: { name: string; retrograde: boolean }[];
};
export function parseReportChartBlock(source: string): ReportChartBlock | null {
try {
return reportChartBlockSchema.parse(JSON.parse(source));
} catch {
return null;
}
}
function integerDegree(degree: number): number {
const value = Math.floor(degree);
if (value >= 30) return 29;
if (value < 0) return 0;
return value;
}
export function planetDisplayLabel(
name: (typeof PLANETS)[number],
degree: number,
retrograde: boolean,
): string {
const body = PLANET_LABEL[name];
const suffix = retrograde ? "逆" : "";
return `${body}${suffix} ${integerDegree(degree)}°`;
}
export function signOrdinal(sign: string): number | null {
return SIGN_NUMBER[sign] ?? null;
}
export function chartAriaLabel(id: ReportChartBlock["id"]): string {
return id === "MOON" ? "月亮参考盘" : `${id} 北印度盘`;
}
export function toNorthIndianChart(block: ReportChartBlock): NorthIndianChartModel {
const ascIndex = SIGNS.indexOf(block.ascendant.sign);
const occupantsByHouse: string[][] = Array.from({ length: 12 }, () => []);
for (const planet of block.planets) {
const signIndex = SIGNS.indexOf(planet.sign);
const houseNumber = ((signIndex - ascIndex + 12) % 12) + 1;
occupantsByHouse[houseNumber - 1].push(
planetDisplayLabel(planet.name, planet.degree, planet.retrograde),
);
}
return {
houses: Array.from({ length: 12 }, (_, index) => ({
houseNumber: index + 1,
sign: SIGNS[(ascIndex + index + 12) % 12],
occupants: occupantsByHouse[index],
})),
planets: block.planets.map((planet) => ({
name: PLANET_LABEL[planet.name],
retrograde: planet.retrograde,
})),
};
}
export function stripReportChartBlocks(markdown: string): string {
const stripped = markdown.replace(FENCE_RE, "\n\n");
return stripped.replace(/\n{3,}/g, "\n\n");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
import { planetDisplayLabel, type ReportChartBlock } from "../src/lib/report-chart-block.ts";
Object.assign(globalThis, { React });
const SAMPLE_BLOCK: ReportChartBlock = {
version: 1,
id: "D1",
title: "D1 — Rashi Chart(本命盘)",
layout: "north",
ascendant: { sign: "Leo", degree: 12.34 },
planets: [
{ name: "Sun", sign: "Leo", degree: 3.21, retrograde: false },
{ name: "Saturn", sign: "Capricorn", degree: 3.4, retrograde: true },
],
};
const MARKDOWN = [
"#### D1 — Rashi Chart(本命盘)",
"",
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 480"><text>engine</text></svg>',
"",
"```jyotish-chart",
JSON.stringify(SAMPLE_BLOCK),
"```",
"",
"```jyotish-chart",
"{not-json",
"```",
].join("\n");
test("markdown view draws the North Indian component and skips engine SVG HTML", () => {
const markup = renderToStaticMarkup(
React.createElement(PersonalReportMarkdownView, { markdown: MARKDOWN }),
);
assert.match(markup, /<svg/);
assert.match(markup, /role="img"/);
assert.match(markup, /personal-report-chart-figure/);
assert.doesNotMatch(markup, /viewBox="0 0 420 480"/);
assert.match(markup, /图盘数据无效/);
assert.doesNotMatch(markup, /<script/i);
assert.match(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)));
});
+8 -4
View File
@@ -243,10 +243,14 @@ test("each house owns its occupant coordinates and dense houses stay clipped and
const dense = structuredClone(canonicalFixture);
dense.charts[0].houses[0].occupants = Array.from({ length: 12 }, (_, index) => `占星体${index + 1}`);
const markup = render(dense);
assert.match(markup, /transform="translate\(0 100\)"/, "house 1 uses its own cell offset");
assert.match(markup, /transform="translate\(300 0\)"/, "house 9 uses its own cell offset");
assert.match(markup, /clip-path="url\(#.*-house-1\)"/, "house text is clipped to its own cell");
assert.match(markup, /\+5 项/, "dense houses summarize overflow instead of painting twelve overlapping lines");
// 原值 / 新值 / 原因
// transform="translate(0 100)" / 1 宫多边形含 200,0 / 北印式是菱形宫,不再用 4×4 方格偏移
// transform="translate(300 0)" / 9 宫多边形含 400,400 / 同上
// +5 项 / +7 / 宫内最多 6 行(含折叠行),12 个占星体变成 5 行加 +7
assert.match(markup, /points="200,0 300,100 200,200 100,100"/, "house 1 is the top diamond");
assert.match(markup, /points="400,400 300,300 400,200"/, "house 9 is the lower-right triangle");
assert.match(markup, /clip-path="url\(#.*-house-1\)"/, "house text is clipped to its own polygon");
assert.match(markup, />\+7</, "dense houses summarize overflow instead of painting twelve overlapping lines");
});
test("evidenceRefs only produce in-page anchors for ids present in the appendix", () => {
+113
View File
@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CHART_SIZE,
HOUSE_NUMBERS,
HOUSE_POLYGONS,
pointInPolygon,
polygonArea,
} from "../src/lib/north-indian-chart-geometry.ts";
import {
parseReportChartBlock,
planetDisplayLabel,
stripReportChartBlocks,
toNorthIndianChart,
type ReportChartBlock,
} from "../src/lib/report-chart-block.ts";
const fixture = JSON.parse(
readFileSync(new URL("./fixtures/report-chart-blocks-golden.json", import.meta.url), "utf8"),
) as {
sourceCommand: string;
blocks: ReportChartBlock[];
};
function wrapMarkdown(blocks: readonly ReportChartBlock[]): string {
return blocks.map((block) => [
`#### ${block.title}`,
"",
`<svg viewBox="0 0 420 480"></svg>`,
"",
"```jyotish-chart",
JSON.stringify(block),
"```",
"",
].join("\n")).join("\n");
}
test("golden fixture records the engine command that produced it", () => {
assert.match(fixture.sourceCommand, /test_report_chart_block|build_professional_report_reference_packet|render_pl9_markdown/);
});
test("all 22 golden blocks parse", () => {
assert.equal(fixture.blocks.length, 22);
for (const raw of fixture.blocks) {
const parsed = parseReportChartBlock(JSON.stringify(raw));
assert.ok(parsed, `block ${raw.id} should parse`);
}
});
test("D1 whole-sign houses start at the ascendant and wrap at 12", () => {
const d1 = fixture.blocks.find((block) => block.id === "D1");
assert.ok(d1);
const chart = toNorthIndianChart(d1);
assert.equal(chart.houses[0]?.sign, d1.ascendant.sign);
const signs = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
] as const;
const ascIndex = signs.indexOf(d1.ascendant.sign);
assert.equal(chart.houses[11]?.sign, signs[(ascIndex + 11) % 12]);
});
test("retrograde labels include 逆 after the planet letter", () => {
const sample = fixture.blocks.flatMap((block) => block.planets).find((planet) => planet.retrograde);
assert.ok(sample, "golden chart should contain at least one retrograde planet");
const label = planetDisplayLabel(sample.name, sample.degree, true);
assert.match(label, /逆 \d+°$/);
});
test("malicious title is not drawn; parse still succeeds", () => {
const d1 = fixture.blocks.find((block) => block.id === "D1");
assert.ok(d1);
const poisoned = parseReportChartBlock(JSON.stringify({
...d1,
title: '<img onerror="alert(1)" src=x>',
}));
assert.ok(poisoned);
const chart = toNorthIndianChart(poisoned);
const serialized = JSON.stringify(chart);
assert.doesNotMatch(serialized, /<img/);
assert.doesNotMatch(serialized, /onerror/);
});
test("invalid payloads return null", () => {
assert.equal(parseReportChartBlock("{"), null);
assert.equal(parseReportChartBlock(JSON.stringify({ ...fixture.blocks[0], extra: true })), null);
assert.equal(parseReportChartBlock(JSON.stringify({ ...fixture.blocks[0], ascendant: { ...fixture.blocks[0].ascendant, degree: 31 } })), null);
assert.equal(parseReportChartBlock(JSON.stringify({
...fixture.blocks[0],
planets: [{ name: "Uranus", sign: "Aries", degree: 1, retrograde: false }],
})), null);
});
test("stripReportChartBlocks removes fences and keeps engine SVGs", () => {
const markdown = wrapMarkdown(fixture.blocks);
const stripped = stripReportChartBlocks(markdown);
assert.doesNotMatch(stripped, /```jyotish-chart/);
assert.equal((stripped.match(/<svg/g) ?? []).length, (markdown.match(/<svg/g) ?? []).length);
});
test("house polygons fill the square without overlapping interiors", () => {
const total = HOUSE_NUMBERS.reduce((sum, houseNumber) => sum + polygonArea(HOUSE_POLYGONS[houseNumber]), 0);
assert.ok(Math.abs(total - CHART_SIZE * CHART_SIZE) < 1e-6, `area ${total}`);
const step = 5;
for (let x = step; x < CHART_SIZE; x += step) {
for (let y = step; y < CHART_SIZE; y += step) {
const hits = HOUSE_NUMBERS.filter((houseNumber) => pointInPolygon({ x, y }, HOUSE_POLYGONS[houseNumber]));
assert.ok(hits.length <= 1, `point ${x},${y} in ${hits.join(",")}`);
}
}
});