feat: 增加 ECharts 渲染与预览交互增强
- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染 - 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略 - 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步 - 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放 - 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
markdownItECharts
|
||||
} from "../src/index.js";
|
||||
import {
|
||||
disposeEChartsBlocks,
|
||||
renderEChartsBlocks,
|
||||
type EChartsEngine,
|
||||
type EChartsInstanceLike
|
||||
} from "../src/browser.js";
|
||||
|
||||
function createDocumentWithChart() {
|
||||
const markdown = new MarkdownIt().use(markdownItECharts);
|
||||
document.body.innerHTML = markdown.render(`
|
||||
\`\`\`echarts
|
||||
version: 1
|
||||
height: 80mm
|
||||
caption: 测试图表
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [A, B]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: bar
|
||||
data: [1, 2]
|
||||
\`\`\`
|
||||
`);
|
||||
}
|
||||
|
||||
function fakeEngine(
|
||||
configureSvg?: (svg: SVGSVGElement) => void
|
||||
) {
|
||||
const dispose = vi.fn();
|
||||
const resize = vi.fn();
|
||||
const init = vi.fn(
|
||||
(container: HTMLElement): EChartsInstanceLike => {
|
||||
dispose.mockImplementation(() => container.replaceChildren());
|
||||
const handlers = new Map<string, () => void>();
|
||||
return {
|
||||
setOption() {
|
||||
const svg = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg"
|
||||
);
|
||||
svg.setAttribute("viewBox", "0 0 400 300");
|
||||
const text = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"text"
|
||||
);
|
||||
text.textContent = "测试图表";
|
||||
svg.append(text);
|
||||
configureSvg?.(svg);
|
||||
container.replaceChildren(svg);
|
||||
handlers.get("finished")?.();
|
||||
},
|
||||
on(eventName, handler) {
|
||||
handlers.set(eventName, handler);
|
||||
},
|
||||
off(eventName) {
|
||||
handlers.delete(eventName);
|
||||
},
|
||||
resize,
|
||||
dispose
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
engine: { init } satisfies EChartsEngine,
|
||||
init,
|
||||
dispose,
|
||||
resize
|
||||
};
|
||||
}
|
||||
|
||||
describe("浏览器 ECharts 渲染", () => {
|
||||
it("生成安全的内联 SVG 并释放实例", async () => {
|
||||
createDocumentWithChart();
|
||||
const { engine, init, dispose } = fakeEngine();
|
||||
|
||||
const outcomes = await renderEChartsBlocks(document, {
|
||||
engine,
|
||||
outputMode: "inline-svg"
|
||||
});
|
||||
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]?.success).toBe(true);
|
||||
expect(init).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
undefined,
|
||||
expect.objectContaining({ renderer: "svg" })
|
||||
);
|
||||
expect(document.querySelector(".md-echarts-host svg")).not.toBeNull();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
document.querySelector(".md-echarts")?.getAttribute(
|
||||
"data-echarts-pending"
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("生成 SVG Data URL 图片", async () => {
|
||||
createDocumentWithChart();
|
||||
const { engine } = fakeEngine();
|
||||
|
||||
const outcomes = await renderEChartsBlocks(document, {
|
||||
engine,
|
||||
outputMode: "svg-image"
|
||||
});
|
||||
|
||||
expect(outcomes[0]?.success).toBe(true);
|
||||
const image = document.querySelector<HTMLImageElement>(
|
||||
".md-echarts-svg-image"
|
||||
);
|
||||
expect(image?.src).toMatch(/^data:image\/svg\+xml/u);
|
||||
expect(image?.alt).toBe("测试图表");
|
||||
});
|
||||
|
||||
it("交互模式保留实例并支持统一销毁", async () => {
|
||||
createDocumentWithChart();
|
||||
const { engine, dispose } = fakeEngine();
|
||||
|
||||
const outcomes = await renderEChartsBlocks(document, {
|
||||
engine,
|
||||
outputMode: "interactive"
|
||||
});
|
||||
|
||||
expect(outcomes[0]?.instance).toBeDefined();
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
disposeEChartsBlocks(document);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("拒绝渲染器产生的外部 SVG 资源", async () => {
|
||||
createDocumentWithChart();
|
||||
const onError = vi.fn();
|
||||
const { engine } = fakeEngine((svg) => {
|
||||
const image = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"image"
|
||||
);
|
||||
image.setAttribute("href", "https://example.com/chart.png");
|
||||
svg.append(image);
|
||||
});
|
||||
|
||||
const outcomes = await renderEChartsBlocks(document, {
|
||||
engine,
|
||||
outputMode: "inline-svg",
|
||||
onError
|
||||
});
|
||||
|
||||
expect(outcomes[0]?.success).toBe(false);
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(document.querySelector(".md-echarts-error")).not.toBeNull();
|
||||
expect(document.body.textContent).toContain("外部 SVG 链接");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
markdownItECharts
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("markdownItECharts", () => {
|
||||
it("将 echarts 围栏转换为安全占位 DOM", () => {
|
||||
const markdown = new MarkdownIt().use(markdownItECharts);
|
||||
const html = markdown.render(`
|
||||
\`\`\`echarts
|
||||
version: 1
|
||||
height: 80mm
|
||||
caption: 年度收入
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [2023, 2024]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: bar
|
||||
data: [120, 180]
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(html).toContain('class="md-echarts"');
|
||||
expect(html).toContain('data-echarts-pending="true"');
|
||||
expect(html).toContain('class="md-echarts-host"');
|
||||
expect(html).toContain(
|
||||
'class="md-echarts-definition" hidden="hidden"'
|
||||
);
|
||||
expect(html).toContain("<figcaption>年度收入</figcaption>");
|
||||
expect(html).toContain(""type":"bar"");
|
||||
});
|
||||
|
||||
it("保留其他代码围栏的默认行为", () => {
|
||||
const markdown = new MarkdownIt().use(markdownItECharts);
|
||||
expect(markdown.render("```ts\nconst answer = 42;\n```")).toContain(
|
||||
'<code class="language-ts">'
|
||||
);
|
||||
});
|
||||
|
||||
it("错误配置产生安全错误节点并通知宿主", () => {
|
||||
const onError = vi.fn();
|
||||
const markdown = new MarkdownIt().use(markdownItECharts, {
|
||||
onError
|
||||
});
|
||||
const environment = { requestId: "render-1" };
|
||||
const html = markdown.render(`
|
||||
\`\`\`echarts
|
||||
version: 1
|
||||
height: 80mm
|
||||
option:
|
||||
series:
|
||||
- type: custom
|
||||
\`\`\`
|
||||
`, environment);
|
||||
|
||||
expect(html).toContain('class="md-echarts-error"');
|
||||
expect(html).not.toContain("<script");
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ environment })
|
||||
);
|
||||
});
|
||||
|
||||
it("缺少数据时输出包含配置路径的局部错误", () => {
|
||||
const markdown = new MarkdownIt().use(markdownItECharts);
|
||||
const html = markdown.render(`
|
||||
\`\`\`echarts
|
||||
option:
|
||||
series:
|
||||
- type: bar
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(html).toContain('class="md-echarts-error"');
|
||||
expect(html).toContain("option.series.0.data");
|
||||
expect(html).not.toContain("data-echarts-pending");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EChartsFenceError,
|
||||
parseEChartsFence
|
||||
} from "../src/index.js";
|
||||
|
||||
const validSource = `
|
||||
version: 1
|
||||
height: 90mm
|
||||
theme: document
|
||||
caption: 年度趋势
|
||||
option:
|
||||
dataset:
|
||||
source:
|
||||
- [year, sales]
|
||||
- [2023, 120]
|
||||
- [2024, 180]
|
||||
xAxis:
|
||||
type: category
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: line
|
||||
encode:
|
||||
x: year
|
||||
y: sales
|
||||
`;
|
||||
|
||||
describe("parseEChartsFence", () => {
|
||||
it("解析并规范化有效协议", () => {
|
||||
expect(parseEChartsFence(validSource)).toEqual({
|
||||
version: 1,
|
||||
height: "90mm",
|
||||
theme: "document",
|
||||
caption: "年度趋势",
|
||||
option: {
|
||||
dataset: {
|
||||
source: [
|
||||
["year", "sales"],
|
||||
[2023, 120],
|
||||
[2024, 180]
|
||||
]
|
||||
},
|
||||
xAxis: {
|
||||
type: "category"
|
||||
},
|
||||
yAxis: {
|
||||
type: "value"
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
encode: {
|
||||
x: "year",
|
||||
y: "sales"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("补充协议版本、尺寸和直角坐标轴默认值", () => {
|
||||
expect(
|
||||
parseEChartsFence(`
|
||||
option:
|
||||
series:
|
||||
- type: line
|
||||
data: [12, 18, 26]
|
||||
`)
|
||||
).toEqual({
|
||||
version: 1,
|
||||
height: "80mm",
|
||||
theme: "document",
|
||||
option: {
|
||||
xAxis: {},
|
||||
yAxis: {},
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
data: [12, 18, 26]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("拒绝 YAML 锚点和别名", () => {
|
||||
expect(() =>
|
||||
parseEChartsFence(`
|
||||
version: 1
|
||||
height: 80mm
|
||||
option:
|
||||
series: &series
|
||||
- type: line
|
||||
color: *series
|
||||
`)
|
||||
).toThrow(EChartsFenceError);
|
||||
});
|
||||
|
||||
it("拒绝显式 YAML 标签", () => {
|
||||
expect(() =>
|
||||
parseEChartsFence(`
|
||||
version: 1
|
||||
height: 80mm
|
||||
option:
|
||||
series:
|
||||
- type: !!str line
|
||||
`)
|
||||
).toThrow(/不允许使用显式标签/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeAndValidateEChartsOption
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("normalizeAndValidateEChartsOption", () => {
|
||||
it("接受首版系列和内置数据转换", () => {
|
||||
const normalized = normalizeAndValidateEChartsOption({
|
||||
dataset: {
|
||||
source: [
|
||||
["name", "value"],
|
||||
["A", 1]
|
||||
],
|
||||
transform: {
|
||||
type: "sort",
|
||||
config: {
|
||||
dimension: "value",
|
||||
order: "desc"
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
encode: {
|
||||
x: "name",
|
||||
y: "value"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
series: [{ type: "bar" }]
|
||||
});
|
||||
expect(Object.getPrototypeOf(normalized)).toBe(
|
||||
Object.prototype
|
||||
);
|
||||
expect(normalized.hasOwnProperty("series")).toBe(true);
|
||||
});
|
||||
|
||||
it("拒绝不支持的系列", () => {
|
||||
expect(() =>
|
||||
normalizeAndValidateEChartsOption({
|
||||
series: [{ type: "custom" }]
|
||||
})
|
||||
).toThrow(/暂不支持 custom/u);
|
||||
});
|
||||
|
||||
it("拒绝外部资源和危险链接", () => {
|
||||
expect(() =>
|
||||
normalizeAndValidateEChartsOption({
|
||||
title: {
|
||||
text: "示例",
|
||||
subtext: "https://example.com/chart"
|
||||
},
|
||||
series: [{ type: "line" }]
|
||||
})
|
||||
).toThrow(/危险 URL/u);
|
||||
});
|
||||
|
||||
it("拒绝 HTML formatter", () => {
|
||||
expect(() =>
|
||||
normalizeAndValidateEChartsOption({
|
||||
tooltip: {
|
||||
formatter: "<img src=x onerror=alert(1)>"
|
||||
},
|
||||
series: [{ type: "line" }]
|
||||
})
|
||||
).toThrow(/不含 HTML/u);
|
||||
});
|
||||
|
||||
it("拒绝原型污染属性", () => {
|
||||
const option = JSON.parse(
|
||||
'{"series":[{"type":"line"}],"__proto__":{"polluted":true}}'
|
||||
);
|
||||
expect(() => normalizeAndValidateEChartsOption(option)).toThrow(
|
||||
/禁止属性/u
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeAndValidateEChartsOption,
|
||||
validateAndApplyEChartsOptionDefaults
|
||||
} from "../src/index.js";
|
||||
|
||||
function validate(option: Record<string, unknown>) {
|
||||
return validateAndApplyEChartsOptionDefaults(
|
||||
normalizeAndValidateEChartsOption(option)
|
||||
);
|
||||
}
|
||||
|
||||
describe("ECharts 系列语义验证", () => {
|
||||
it("允许从 dataset.source 取数并补充坐标轴", () => {
|
||||
expect(
|
||||
validate({
|
||||
dataset: {
|
||||
source: [
|
||||
["name", "value"],
|
||||
["A", 12]
|
||||
]
|
||||
},
|
||||
series: [{ type: "bar" }]
|
||||
})
|
||||
).toMatchObject({
|
||||
xAxis: {},
|
||||
yAxis: {},
|
||||
series: [{ type: "bar" }]
|
||||
});
|
||||
});
|
||||
|
||||
it("缺少所有数据来源时报告精确路径", () => {
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [{ type: "line" }]
|
||||
})
|
||||
).toThrow(
|
||||
/option\.series\.0\.data 必须是非空数组/u
|
||||
);
|
||||
});
|
||||
|
||||
it("只有表头或空列的 dataset 不算有效数据", () => {
|
||||
expect(() =>
|
||||
validate({
|
||||
dataset: {
|
||||
source: [["name", "value"]]
|
||||
},
|
||||
series: [{ type: "bar" }]
|
||||
})
|
||||
).toThrow(/必须是非空数组/u);
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
dataset: {
|
||||
source: {
|
||||
name: [],
|
||||
value: []
|
||||
}
|
||||
},
|
||||
series: [{ type: "bar" }]
|
||||
})
|
||||
).toThrow(/必须是非空数组/u);
|
||||
});
|
||||
|
||||
it("散点图要求二维坐标", () => {
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [{ type: "scatter", data: [12] }]
|
||||
})
|
||||
).toThrow(
|
||||
/option\.series\.0\.data\.0 必须是至少包含两个坐标值/u
|
||||
);
|
||||
});
|
||||
|
||||
it("饼图要求有限数值", () => {
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
data: [{ name: "A", value: "12" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(
|
||||
/option\.series\.0\.data\.0\.value 必须是有限数值/u
|
||||
);
|
||||
});
|
||||
|
||||
it("雷达图要求指标并校验数据维数", () => {
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "radar",
|
||||
data: [{ value: [12, 18] }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/option\.radar\.indicator/u);
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
radar: {
|
||||
indicator: [
|
||||
{ name: "质量" },
|
||||
{ name: "速度" },
|
||||
{ name: "成本" }
|
||||
]
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "radar",
|
||||
data: [{ value: [12, 18] }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/至少包含 3 个指标值/u);
|
||||
});
|
||||
|
||||
it("热力图区分直角坐标系与日历坐标系", () => {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "heatmap",
|
||||
data: [[0, 1, 20]]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({ xAxis: {}, yAxis: {} });
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "heatmap",
|
||||
coordinateSystem: "calendar",
|
||||
data: [["2026-01-01", 20]]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/option\.calendar/u);
|
||||
});
|
||||
|
||||
it("箱线图要求五数数据", () => {
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "boxplot",
|
||||
data: [[1, 2, 3, 4]]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/五元数组/u);
|
||||
});
|
||||
|
||||
it("K 线图要求 OHLC 四元数据", () => {
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "candlestick",
|
||||
data: [[20, 18, 16]]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/四元数组/u);
|
||||
});
|
||||
|
||||
it("极坐标折线图要求完整极坐标组件", () => {
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
coordinateSystem: "polar",
|
||||
data: [[10, 20]]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/option\.polar/u);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user