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
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(",")}`);
}
}
});