feat: 增加 ECharts 渲染与预览交互增强

- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染
- 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略
- 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步
- 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放
- 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
This commit is contained in:
SkyJourney
2026-07-27 16:05:14 +08:00
parent 1db21c80fc
commit f5a46f79f1
53 changed files with 3998 additions and 221 deletions
+6
View File
@@ -0,0 +1,6 @@
export * from "./browser/engine.js";
export * from "./browser/freeze-svg.js";
export * from "./browser/render-blocks.js";
export * from "./browser/render-chart.js";
export * from "./browser/resource-policy.js";
export * from "./browser/types.js";
@@ -0,0 +1,76 @@
import * as echarts from "echarts/core";
import {
BarChart,
BoxplotChart,
CandlestickChart,
HeatmapChart,
LineChart,
PieChart,
RadarChart,
ScatterChart
} from "echarts/charts";
import {
AriaComponent,
AxisPointerComponent,
CalendarComponent,
DataZoomComponent,
DatasetComponent,
GridComponent,
LegendComponent,
MarkAreaComponent,
MarkLineComponent,
MarkPointComponent,
PolarComponent,
RadarComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
TransformComponent,
VisualMapComponent
} from "echarts/components";
import { LabelLayout } from "echarts/features";
import { SVGRenderer } from "echarts/renderers";
import type {
EChartsEngine,
EChartsInstanceLike
} from "./types.js";
echarts.use([
LineChart,
BarChart,
PieChart,
ScatterChart,
RadarChart,
HeatmapChart,
BoxplotChart,
CandlestickChart,
GridComponent,
PolarComponent,
RadarComponent,
CalendarComponent,
TitleComponent,
LegendComponent,
TooltipComponent,
AxisPointerComponent,
ToolboxComponent,
DatasetComponent,
TransformComponent,
DataZoomComponent,
VisualMapComponent,
MarkPointComponent,
MarkLineComponent,
MarkAreaComponent,
AriaComponent,
LabelLayout,
SVGRenderer
]);
export const defaultEChartsEngine: EChartsEngine = {
init(container, theme, options) {
return echarts.init(
container,
theme,
options
) as unknown as EChartsInstanceLike;
}
};
@@ -0,0 +1,36 @@
import { echartsDomContract } from "../dom-contract.js";
import { cloneSafeEChartsSvg } from "./resource-policy.js";
import type { EChartsOutputMode } from "./types.js";
function svgDataUrl(serialized: string) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`;
}
export function freezeEChartsSvg(
host: HTMLElement,
source: SVGSVGElement,
outputMode: Exclude<EChartsOutputMode, "interactive">,
label: string
) {
const svg = cloneSafeEChartsSvg(source);
svg.removeAttribute("style");
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", label);
svg.style.width = "100%";
svg.style.height = "100%";
svg.style.display = "block";
if (outputMode === "inline-svg") {
return svg;
}
const image = host.ownerDocument.createElement("img");
image.className = `${echartsDomContract.figureClass}-svg-image`;
image.alt = label;
image.src = svgDataUrl(new XMLSerializer().serializeToString(svg));
image.style.width = "100%";
image.style.height = "100%";
image.style.objectFit = "contain";
image.style.display = "block";
return image;
}
@@ -0,0 +1,58 @@
import { echartsDomContract } from "../dom-contract.js";
import {
disposeEChartsBlock,
renderEChartsBlock
} from "./render-chart.js";
import type {
EChartsBlockRenderOutcome,
RenderEChartsBlocksOptions
} from "./types.js";
export async function renderEChartsBlocks(
root: ParentNode,
options: RenderEChartsBlocksOptions = {}
) {
const elements = Array.from(
root.querySelectorAll<HTMLElement>(
`.${echartsDomContract.figureClass}[${echartsDomContract.pendingAttribute}]`
)
);
const outcomes = new Array<EChartsBlockRenderOutcome>(
elements.length
);
const concurrency = Math.max(
1,
Math.min(options.concurrency ?? 2, elements.length || 1)
);
let nextIndex = 0;
async function worker() {
while (nextIndex < elements.length) {
const index = nextIndex;
nextIndex += 1;
const element = elements[index];
if (!element) {
continue;
}
const outcome = await renderEChartsBlock(element, options);
outcomes[index] = outcome;
if (!outcome.success && outcome.error) {
options.onError?.(outcome.error, element, index);
}
}
}
await Promise.all(
Array.from({ length: concurrency }, () => worker())
);
return outcomes;
}
export function disposeEChartsBlocks(root: ParentNode) {
const elements = root.querySelectorAll<HTMLElement>(
`.${echartsDomContract.figureClass}`
);
for (const element of elements) {
disposeEChartsBlock(element);
}
}
@@ -0,0 +1,338 @@
import { echartsDomContract } from "../dom-contract.js";
import {
DEFAULT_ECHARTS_FENCE_HEIGHT,
EChartsFenceError,
echartsFenceSpecSchema,
type NormalizedEChartsFenceSpec
} from "../protocol.js";
import {
normalizeAndValidateEChartsOption
} from "../security-policy.js";
import {
validateAndApplyEChartsOptionDefaults
} from "../semantic-validator.js";
import { defaultEChartsEngine } from "./engine.js";
import { freezeEChartsSvg } from "./freeze-svg.js";
import type {
EChartsBlockRenderOutcome,
EChartsInstanceLike,
EChartsOutputMode,
RenderEChartsBlockOptions
} from "./types.js";
interface ActiveChart {
instance: EChartsInstanceLike;
observer?: ResizeObserver;
}
const activeCharts = new WeakMap<HTMLElement, ActiveChart>();
function abortError() {
return new DOMException("ECharts 渲染已取消", "AbortError");
}
function throwIfAborted(signal: AbortSignal | undefined) {
if (signal?.aborted) {
throw abortError();
}
}
function normalizeDefinition(value: unknown): NormalizedEChartsFenceSpec {
const protocolResult = echartsFenceSpecSchema.safeParse(value);
if (!protocolResult.success) {
throw new EChartsFenceError(
"INVALID_PROTOCOL",
"ECharts 占位节点中的协议无效"
);
}
const normalized: NormalizedEChartsFenceSpec = {
version: protocolResult.data.version,
theme: protocolResult.data.theme,
option: validateAndApplyEChartsOptionDefaults(
normalizeAndValidateEChartsOption(
protocolResult.data.option
)
)
};
if (protocolResult.data.height !== undefined) {
normalized.height = protocolResult.data.height;
} else if (protocolResult.data.aspectRatio === undefined) {
normalized.height = DEFAULT_ECHARTS_FENCE_HEIGHT;
}
if (protocolResult.data.aspectRatio !== undefined) {
normalized.aspectRatio = protocolResult.data.aspectRatio;
}
if (protocolResult.data.caption !== undefined) {
normalized.caption = protocolResult.data.caption;
}
return normalized;
}
function readDefinition(element: HTMLElement) {
const definition = element.querySelector<HTMLElement>(
`.${echartsDomContract.definitionClass}`
);
if (!definition) {
throw new Error("缺少 ECharts 配置节点");
}
try {
return normalizeDefinition(
JSON.parse(definition.textContent ?? "")
);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error("ECharts 配置节点无法解析");
}
}
function applyChartSize(
host: HTMLElement,
definition: NormalizedEChartsFenceSpec
) {
host.style.width = "100%";
if (definition.height !== undefined) {
host.style.height = definition.height;
host.style.removeProperty("aspect-ratio");
return;
}
host.style.removeProperty("height");
host.style.aspectRatio = String(definition.aspectRatio);
}
function resolveDefaultTheme(
theme: NormalizedEChartsFenceSpec["theme"]
) {
return theme === "dark" ? "dark" : undefined;
}
function nextFrame(element: HTMLElement) {
const requestFrame =
element.ownerDocument.defaultView?.requestAnimationFrame;
if (!requestFrame) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
requestFrame(() => resolve());
});
}
async function waitForRender(
instance: EChartsInstanceLike,
host: HTMLElement,
timeoutMs: number,
signal: AbortSignal | undefined,
setOption: () => void
) {
let finished = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let abortHandler: (() => void) | undefined;
let finishedHandler: (() => void) | undefined;
const completion = new Promise<void>((resolve, reject) => {
const finish = () => {
if (finished) {
return;
}
finished = true;
resolve();
};
const fail = (error: Error) => {
if (finished) {
return;
}
finished = true;
reject(error);
};
finishedHandler = finish;
instance.on("finished", finishedHandler);
timer = setTimeout(
() => fail(new Error(`ECharts 渲染超过 ${timeoutMs}ms`)),
timeoutMs
);
abortHandler = () => fail(abortError());
signal?.addEventListener("abort", abortHandler, { once: true });
try {
setOption();
} catch (error) {
fail(
error instanceof Error
? error
: new Error("ECharts setOption 失败")
);
}
});
try {
await Promise.race([
completion,
(async () => {
await nextFrame(host);
await nextFrame(host);
if (!host.querySelector("svg")) {
await completion;
}
})()
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
if (abortHandler) {
signal?.removeEventListener("abort", abortHandler);
}
if (finishedHandler) {
instance.off("finished", finishedHandler);
}
}
}
function storeInteractiveChart(
element: HTMLElement,
host: HTMLElement,
instance: EChartsInstanceLike
) {
let observer: ResizeObserver | undefined;
const ResizeObserverConstructor =
element.ownerDocument.defaultView?.ResizeObserver;
if (ResizeObserverConstructor) {
observer = new ResizeObserverConstructor(() => instance.resize());
observer.observe(host);
}
const active: ActiveChart = { instance };
if (observer !== undefined) {
active.observer = observer;
}
activeCharts.set(element, active);
}
function showRenderError(element: HTMLElement, error: Error) {
element.removeAttribute(echartsDomContract.pendingAttribute);
element.classList.add(echartsDomContract.errorClass);
const host = element.querySelector<HTMLElement>(
`.${echartsDomContract.hostClass}`
);
if (host) {
host.setAttribute("role", "alert");
host.textContent = `ECharts 图表渲染失败:${error.message}`;
}
}
export function disposeEChartsBlock(element: HTMLElement) {
const active = activeCharts.get(element);
if (!active) {
return;
}
active.observer?.disconnect();
active.instance.dispose();
activeCharts.delete(element);
}
export async function renderEChartsBlock(
element: HTMLElement,
options: RenderEChartsBlockOptions = {}
): Promise<EChartsBlockRenderOutcome> {
const startedAt = performance.now();
const outputMode: EChartsOutputMode =
options.outputMode ?? "inline-svg";
let instance: EChartsInstanceLike | undefined;
try {
throwIfAborted(options.signal);
disposeEChartsBlock(element);
const definition = readDefinition(element);
const host = element.querySelector<HTMLElement>(
`.${echartsDomContract.hostClass}`
);
if (!host) {
throw new Error("缺少 ECharts 渲染容器");
}
applyChartSize(host, definition);
const fonts = element.ownerDocument.fonts;
if (fonts) {
await fonts.ready;
}
throwIfAborted(options.signal);
const engine = options.engine ?? defaultEChartsEngine;
const theme =
options.resolveTheme?.(definition.theme, element) ??
resolveDefaultTheme(definition.theme);
instance = engine.init(host, theme, {
renderer: "svg",
useDirtyRect: false
});
const shouldAnimate =
options.animation ??
(outputMode === "interactive"
? definition.option.animation
: false);
const option = {
...definition.option,
animation:
typeof shouldAnimate === "boolean" ? shouldAnimate : false
};
await waitForRender(
instance,
host,
options.timeoutMs ?? 5_000,
options.signal,
() => instance?.setOption(option, { notMerge: true })
);
throwIfAborted(options.signal);
if (outputMode === "interactive") {
storeInteractiveChart(element, host, instance);
element.removeAttribute(echartsDomContract.pendingAttribute);
element.dataset.echartsRendered = "interactive";
return {
element,
outputMode,
success: true,
durationMs: performance.now() - startedAt,
instance
};
}
const svg = host.querySelector<SVGSVGElement>("svg");
if (!svg) {
throw new Error("ECharts 未生成 SVG");
}
const frozenOutput = freezeEChartsSvg(
host,
svg,
outputMode,
definition.caption ?? "ECharts 图表"
);
instance.dispose();
instance = undefined;
host.replaceChildren(frozenOutput);
element.removeAttribute(echartsDomContract.pendingAttribute);
element.dataset.echartsRendered = outputMode;
return {
element,
outputMode,
success: true,
durationMs: performance.now() - startedAt
};
} catch (error) {
instance?.dispose();
const normalizedError =
error instanceof Error
? error
: new Error("未知 ECharts 渲染错误");
showRenderError(element, normalizedError);
return {
element,
outputMode,
success: false,
durationMs: performance.now() - startedAt,
error: normalizedError
};
}
}
@@ -0,0 +1,57 @@
const forbiddenSvgElements = [
"script",
"foreignObject",
"iframe",
"object",
"embed"
] as const;
const urlFunctionPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/giu;
function assertSafeUrlValue(value: string, context: string) {
for (const match of value.matchAll(urlFunctionPattern)) {
const target = match[2]?.trim() ?? "";
if (!target.startsWith("#")) {
throw new Error(`${context} 包含外部 SVG 资源`);
}
}
}
function assertSafeHref(value: string, context: string) {
const normalized = value.trim();
if (normalized && !normalized.startsWith("#")) {
throw new Error(`${context} 包含外部 SVG 链接`);
}
}
export function cloneSafeEChartsSvg(
source: SVGSVGElement
): SVGSVGElement {
const clone = source.cloneNode(true) as SVGSVGElement;
if (clone.querySelector(forbiddenSvgElements.join(","))) {
throw new Error("ECharts SVG 包含禁止元素");
}
for (const element of [clone, ...clone.querySelectorAll("*")]) {
for (const attribute of Array.from(element.attributes)) {
const name = attribute.name.toLowerCase();
if (name.startsWith("on")) {
element.removeAttribute(attribute.name);
continue;
}
if (name === "href" || name === "xlink:href") {
assertSafeHref(attribute.value, attribute.name);
continue;
}
assertSafeUrlValue(attribute.value, attribute.name);
}
if (
element.tagName.toLowerCase() === "style" &&
/@import|url\(\s*(?!['"]?#)/iu.test(element.textContent ?? "")
) {
throw new Error("ECharts SVG 样式包含外部资源");
}
}
return clone;
}
@@ -0,0 +1,61 @@
import type {
NormalizedEChartsFenceSpec
} from "../protocol.js";
export type EChartsOutputMode =
| "interactive"
| "inline-svg"
| "svg-image";
export interface EChartsInstanceLike {
setOption(option: unknown, options?: { notMerge?: boolean }): void;
on(eventName: string, handler: () => void): void;
off(eventName: string, handler: () => void): void;
resize(): void;
dispose(): void;
}
export interface EChartsEngine {
init(
container: HTMLElement,
theme: string | object | null | undefined,
options: {
renderer: "svg";
width?: number | string;
height?: number | string;
devicePixelRatio?: number;
useDirtyRect?: boolean;
}
): EChartsInstanceLike;
}
export interface RenderEChartsBlockOptions {
engine?: EChartsEngine;
outputMode?: EChartsOutputMode;
timeoutMs?: number;
signal?: AbortSignal;
animation?: boolean;
resolveTheme?: (
theme: NormalizedEChartsFenceSpec["theme"],
element: HTMLElement
) => string | object | null | undefined;
}
export interface RenderEChartsBlocksOptions
extends RenderEChartsBlockOptions {
concurrency?: number;
onError?: (
error: Error,
element: HTMLElement,
index: number
) => void;
}
export interface EChartsBlockRenderOutcome {
element: HTMLElement;
outputMode: EChartsOutputMode;
success: boolean;
durationMs: number;
error?: Error;
instance?: EChartsInstanceLike;
}
@@ -0,0 +1,8 @@
export const echartsDomContract = {
figureClass: "md-echarts",
hostClass: "md-echarts-host",
definitionClass: "md-echarts-definition",
errorClass: "md-echarts-error",
pendingAttribute: "data-echarts-pending",
protocolAttribute: "data-echarts-protocol"
} as const;
+6
View File
@@ -0,0 +1,6 @@
export * from "./dom-contract.js";
export * from "./markdown-it-plugin.js";
export * from "./parse-yaml.js";
export * from "./protocol.js";
export * from "./security-policy.js";
export * from "./semantic-validator.js";
@@ -0,0 +1,140 @@
import type MarkdownIt from "markdown-it";
import type { Options as MarkdownItOptions } from "markdown-it";
import type Renderer from "markdown-it/lib/renderer.mjs";
import type Token from "markdown-it/lib/token.mjs";
import { echartsDomContract } from "./dom-contract.js";
import {
ECHARTS_FENCE_VERSION,
EChartsFenceError,
type NormalizedEChartsFenceSpec
} from "./protocol.js";
import {
parseEChartsFence,
type ParseEChartsFenceOptions
} from "./parse-yaml.js";
export interface MarkdownItEChartsErrorContext {
source: string;
tokenIndex: number;
environment: unknown;
}
export interface MarkdownItEChartsOptions {
parse?: ParseEChartsFenceOptions;
onError?: (
error: EChartsFenceError,
context: MarkdownItEChartsErrorContext
) => void;
}
type FenceRenderer = (
tokens: Token[],
index: number,
options: MarkdownItOptions,
environment: unknown,
renderer: Renderer
) => string;
function renderError(markdown: MarkdownIt, error: EChartsFenceError) {
const message = markdown.utils.escapeHtml(error.message);
return `<div class="${echartsDomContract.errorClass}" role="alert">ECharts 图表配置无效:${message}</div>\n`;
}
function renderDefinition(
markdown: MarkdownIt,
definition: NormalizedEChartsFenceSpec
) {
const serialized = markdown.utils.escapeHtml(
JSON.stringify(definition)
);
const caption =
definition.caption === undefined
? ""
: `<figcaption>${markdown.utils.escapeHtml(definition.caption)}</figcaption>`;
const label = markdown.utils.escapeHtml(
definition.caption ?? "ECharts 图表"
);
return [
`<figure class="${echartsDomContract.figureClass}"`,
` ${echartsDomContract.pendingAttribute}="true"`,
` ${echartsDomContract.protocolAttribute}="${ECHARTS_FENCE_VERSION}">`,
`<div class="${echartsDomContract.hostClass}" role="img" aria-label="${label}"></div>`,
`<pre class="${echartsDomContract.definitionClass}" hidden="hidden">${serialized}</pre>`,
caption,
"</figure>\n"
].join("");
}
function renderDefaultFence(
defaultFenceRenderer: FenceRenderer | undefined,
tokens: Token[],
index: number,
options: MarkdownItOptions,
environment: unknown,
renderer: Renderer
) {
if (defaultFenceRenderer) {
return defaultFenceRenderer(
tokens,
index,
options,
environment,
renderer
);
}
return renderer.renderToken(tokens, index, options);
}
export function markdownItECharts(
markdown: MarkdownIt,
pluginOptions: MarkdownItEChartsOptions = {}
) {
const defaultFenceRenderer = markdown.renderer.rules.fence;
markdown.renderer.rules.fence = (
tokens,
index,
options,
environment,
renderer
) => {
const token = tokens[index];
const language = token?.info
.trim()
.split(/\s+/u)[0]
?.toLowerCase();
if (!token || language !== "echarts") {
return renderDefaultFence(
defaultFenceRenderer,
tokens,
index,
options,
environment,
renderer
);
}
try {
return renderDefinition(
markdown,
parseEChartsFence(token.content, pluginOptions.parse)
);
} catch (error) {
const normalizedError =
error instanceof EChartsFenceError
? error
: new EChartsFenceError(
"INVALID_YAML",
error instanceof Error ? error.message : "未知错误",
{ cause: error }
);
pluginOptions.onError?.(normalizedError, {
source: token.content,
tokenIndex: index,
environment
});
return renderError(markdown, normalizedError);
}
};
}
+160
View File
@@ -0,0 +1,160 @@
import {
isAlias,
isMap,
isScalar,
isSeq,
parseDocument
} from "yaml";
import type { Pair, ParsedNode } from "yaml";
import {
DEFAULT_ECHARTS_FENCE_HEIGHT,
EChartsFenceError,
echartsFenceSpecSchema,
type NormalizedEChartsFenceSpec
} from "./protocol.js";
import {
defaultEChartsSecurityLimits,
normalizeAndValidateEChartsOption,
type EChartsSecurityLimits
} from "./security-policy.js";
import {
validateAndApplyEChartsOptionDefaults
} from "./semantic-validator.js";
export interface ParseEChartsFenceOptions {
maxSourceBytes?: number;
securityLimits?: EChartsSecurityLimits;
}
export const defaultMaxEChartsFenceSourceBytes = 512 * 1024;
function inspectYamlNode(node: ParsedNode | null | undefined): void {
if (!node) {
return;
}
if (isAlias(node)) {
throw new EChartsFenceError(
"YAML_ALIAS_NOT_ALLOWED",
"ECharts YAML 不允许使用别名"
);
}
if (node.anchor !== undefined) {
throw new EChartsFenceError(
"YAML_ANCHOR_NOT_ALLOWED",
"ECharts YAML 不允许使用锚点"
);
}
if (node.tag !== undefined) {
throw new EChartsFenceError(
"YAML_TAG_NOT_ALLOWED",
"ECharts YAML 不允许使用显式标签"
);
}
if (isMap(node)) {
for (const pair of node.items as Pair<ParsedNode, ParsedNode>[]) {
inspectYamlNode(pair.key);
inspectYamlNode(pair.value);
}
return;
}
if (isSeq(node)) {
for (const item of node.items) {
inspectYamlNode(item);
}
return;
}
if (!isScalar(node)) {
throw new EChartsFenceError(
"INVALID_YAML",
"ECharts YAML 包含无法识别的节点"
);
}
}
function formatZodError(error: {
issues: { path: PropertyKey[]; message: string }[];
}) {
return error.issues
.map((issue) => {
const path = issue.path.join(".");
return path ? `${path}${issue.message}` : issue.message;
})
.join("");
}
export function parseEChartsFence(
source: string,
options: ParseEChartsFenceOptions = {}
): NormalizedEChartsFenceSpec {
const maxSourceBytes =
options.maxSourceBytes ?? defaultMaxEChartsFenceSourceBytes;
if (new TextEncoder().encode(source).byteLength > maxSourceBytes) {
throw new EChartsFenceError(
"SOURCE_TOO_LARGE",
`ECharts YAML 不能超过 ${maxSourceBytes} 字节`
);
}
const document = parseDocument(source, {
schema: "core",
strict: true,
uniqueKeys: true,
prettyErrors: true
});
if (document.errors.length > 0) {
throw new EChartsFenceError(
"INVALID_YAML",
document.errors.map((error) => error.message).join("")
);
}
inspectYamlNode(document.contents);
let parsed: unknown;
try {
parsed = document.toJS({
maxAliasCount: 0,
mapAsMap: false
});
} catch (error) {
throw new EChartsFenceError(
"INVALID_YAML",
error instanceof Error ? error.message : "ECharts YAML 解析失败",
{ cause: error }
);
}
const protocolResult = echartsFenceSpecSchema.safeParse(parsed);
if (!protocolResult.success) {
throw new EChartsFenceError(
"INVALID_PROTOCOL",
formatZodError(protocolResult.error)
);
}
const securityLimits =
options.securityLimits ?? defaultEChartsSecurityLimits;
const option = validateAndApplyEChartsOptionDefaults(
normalizeAndValidateEChartsOption(
protocolResult.data.option,
securityLimits
)
);
const normalized: NormalizedEChartsFenceSpec = {
version: protocolResult.data.version,
theme: protocolResult.data.theme,
option
};
if (protocolResult.data.height !== undefined) {
normalized.height = protocolResult.data.height;
} else if (protocolResult.data.aspectRatio === undefined) {
normalized.height = DEFAULT_ECHARTS_FENCE_HEIGHT;
}
if (protocolResult.data.aspectRatio !== undefined) {
normalized.aspectRatio = protocolResult.data.aspectRatio;
}
if (protocolResult.data.caption !== undefined) {
normalized.caption = protocolResult.data.caption;
}
return normalized;
}
+89
View File
@@ -0,0 +1,89 @@
import { z } from "zod";
export const ECHARTS_FENCE_VERSION = 1;
export const DEFAULT_ECHARTS_FENCE_HEIGHT = "80mm";
export const supportedEChartsSeriesTypes = [
"line",
"bar",
"pie",
"scatter",
"radar",
"heatmap",
"boxplot",
"candlestick"
] as const;
export type SupportedEChartsSeriesType =
(typeof supportedEChartsSeriesTypes)[number];
export const echartsFenceThemeSchema = z.enum([
"document",
"light",
"dark"
]);
const positiveLengthPattern =
/^(?:0*[1-9]\d*(?:\.\d+)?|0*\.\d*[1-9]\d*)(?:mm|cm|in|px)$/u;
export const echartsFenceSpecSchema = z
.object({
version: z.literal(ECHARTS_FENCE_VERSION).default(
ECHARTS_FENCE_VERSION
),
height: z
.string()
.regex(
positiveLengthPattern,
"height 必须是大于零的 mm、cm、in 或 px 长度"
)
.optional(),
aspectRatio: z.number().positive().max(10).optional(),
theme: echartsFenceThemeSchema.default("document"),
caption: z.string().trim().min(1).max(500).optional(),
option: z.record(z.string(), z.unknown())
})
.strict();
export type EChartsFenceSpec = z.infer<typeof echartsFenceSpecSchema>;
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue =
| JsonPrimitive
| JsonValue[]
| { [key: string]: JsonValue };
export interface NormalizedEChartsFenceSpec {
version: typeof ECHARTS_FENCE_VERSION;
height?: string;
aspectRatio?: number;
theme: z.infer<typeof echartsFenceThemeSchema>;
caption?: string;
option: Record<string, JsonValue>;
}
export const echartsFenceErrorCodes = [
"SOURCE_TOO_LARGE",
"INVALID_YAML",
"YAML_ALIAS_NOT_ALLOWED",
"YAML_ANCHOR_NOT_ALLOWED",
"YAML_TAG_NOT_ALLOWED",
"INVALID_PROTOCOL",
"INVALID_OPTION",
"UNSAFE_OPTION",
"LIMIT_EXCEEDED"
] as const;
export type EChartsFenceErrorCode =
(typeof echartsFenceErrorCodes)[number];
export class EChartsFenceError extends Error {
constructor(
readonly code: EChartsFenceErrorCode,
message: string,
options?: ErrorOptions
) {
super(message, options);
this.name = "EChartsFenceError";
}
}
@@ -0,0 +1,357 @@
import {
EChartsFenceError,
supportedEChartsSeriesTypes,
type JsonValue
} from "./protocol.js";
export interface EChartsSecurityLimits {
maxDepth: number;
maxNodes: number;
maxObjectKeys: number;
maxArrayLength: number;
maxStringLength: number;
maxSeries: number;
}
export const defaultEChartsSecurityLimits: EChartsSecurityLimits = {
maxDepth: 32,
maxNodes: 50_000,
maxObjectKeys: 1_000,
maxArrayLength: 20_000,
maxStringLength: 100_000,
maxSeries: 32
};
const forbiddenPropertyNames = new Set([
"__proto__",
"prototype",
"constructor",
"renderItem",
"optionToContent",
"contentToOption",
"onclick",
"onClick",
"link",
"sublink"
]);
const allowedTopLevelOptionKeys = new Set([
"aria",
"animation",
"animationDelay",
"animationDelayUpdate",
"animationDuration",
"animationDurationUpdate",
"animationEasing",
"animationEasingUpdate",
"angleAxis",
"axisPointer",
"backgroundColor",
"calendar",
"color",
"darkMode",
"dataZoom",
"dataset",
"grid",
"legend",
"polar",
"radar",
"radiusAxis",
"series",
"textStyle",
"title",
"toolbox",
"tooltip",
"visualMap",
"xAxis",
"yAxis"
]);
const allowedToolboxFeatures = new Set([
"brush",
"dataZoom",
"magicType",
"restore",
"saveAsImage"
]);
const unsafeStringPattern =
/^(?:javascript|file|https?):|^\/\/|^data:|url\s*\(|image:\/\//iu;
interface TraversalState {
nodes: number;
}
function optionPath(path: readonly (string | number)[]) {
return path.length === 0 ? "option" : `option.${path.join(".")}`;
}
function assertSafeKey(key: string, path: readonly (string | number)[]) {
if (forbiddenPropertyNames.has(key)) {
throw new EChartsFenceError(
"UNSAFE_OPTION",
`${optionPath(path)} 包含禁止属性 ${key}`
);
}
}
function normalizeValue(
value: unknown,
path: readonly (string | number)[],
depth: number,
state: TraversalState,
limits: EChartsSecurityLimits
): JsonValue {
state.nodes += 1;
if (state.nodes > limits.maxNodes) {
throw new EChartsFenceError(
"LIMIT_EXCEEDED",
`ECharts option 节点数量不能超过 ${limits.maxNodes}`
);
}
if (depth > limits.maxDepth) {
throw new EChartsFenceError(
"LIMIT_EXCEEDED",
`ECharts option 嵌套深度不能超过 ${limits.maxDepth}`
);
}
if (value === null || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new EChartsFenceError(
"INVALID_OPTION",
`${optionPath(path)} 必须是有限数值`
);
}
return value;
}
if (typeof value === "string") {
if (value.length > limits.maxStringLength) {
throw new EChartsFenceError(
"LIMIT_EXCEEDED",
`${optionPath(path)} 字符串长度不能超过 ${limits.maxStringLength}`
);
}
if (unsafeStringPattern.test(value.trim())) {
throw new EChartsFenceError(
"UNSAFE_OPTION",
`${optionPath(path)} 包含被禁止的外部资源或危险 URL`
);
}
if (
path.at(-1) === "formatter" &&
(value.includes("<") || value.includes(">"))
) {
throw new EChartsFenceError(
"UNSAFE_OPTION",
`${optionPath(path)} 只允许不含 HTML 的模板字符串`
);
}
return value;
}
if (Array.isArray(value)) {
if (value.length > limits.maxArrayLength) {
throw new EChartsFenceError(
"LIMIT_EXCEEDED",
`${optionPath(path)} 数组长度不能超过 ${limits.maxArrayLength}`
);
}
return value.map((item, index) =>
normalizeValue(item, [...path, index], depth + 1, state, limits)
);
}
if (typeof value !== "object") {
throw new EChartsFenceError(
"INVALID_OPTION",
`${optionPath(path)} 只能包含 JSON 可序列化值`
);
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new EChartsFenceError(
"INVALID_OPTION",
`${optionPath(path)} 必须是普通对象`
);
}
const entries = Object.entries(value);
if (entries.length > limits.maxObjectKeys) {
throw new EChartsFenceError(
"LIMIT_EXCEEDED",
`${optionPath(path)} 属性数量不能超过 ${limits.maxObjectKeys}`
);
}
const normalized: Record<string, JsonValue> = {};
for (const [key, child] of entries) {
assertSafeKey(key, [...path, key]);
normalized[key] = normalizeValue(
child,
[...path, key],
depth + 1,
state,
limits
);
}
return normalized;
}
function assertSupportedTopLevelKeys(option: Record<string, JsonValue>) {
for (const key of Object.keys(option)) {
if (!allowedTopLevelOptionKeys.has(key)) {
throw new EChartsFenceError(
"INVALID_OPTION",
`option.${key} 暂不在首版支持范围内`
);
}
}
}
function assertSupportedSeries(
option: Record<string, JsonValue>,
limits: EChartsSecurityLimits
) {
const series = option.series;
if (!Array.isArray(series) || series.length === 0) {
throw new EChartsFenceError(
"INVALID_OPTION",
"option.series 必须是非空数组"
);
}
if (series.length > limits.maxSeries) {
throw new EChartsFenceError(
"LIMIT_EXCEEDED",
`option.series 数量不能超过 ${limits.maxSeries}`
);
}
for (const [index, item] of series.entries()) {
if (
item === null ||
Array.isArray(item) ||
typeof item !== "object"
) {
throw new EChartsFenceError(
"INVALID_OPTION",
`option.series.${index} 必须是对象`
);
}
const type = item.type;
if (
typeof type !== "string" ||
!supportedEChartsSeriesTypes.some(
(supportedType) => supportedType === type
)
) {
throw new EChartsFenceError(
"INVALID_OPTION",
`option.series.${index}.type 暂不支持 ${String(type)}`
);
}
}
}
function assertSafeToolbox(option: Record<string, JsonValue>) {
if (option.toolbox === undefined) {
return;
}
const toolboxes = Array.isArray(option.toolbox)
? option.toolbox
: [option.toolbox];
for (const toolbox of toolboxes) {
if (
toolbox === null ||
Array.isArray(toolbox) ||
typeof toolbox !== "object"
) {
continue;
}
const feature = toolbox.feature;
if (
feature === undefined ||
feature === null ||
Array.isArray(feature) ||
typeof feature !== "object"
) {
continue;
}
for (const key of Object.keys(feature)) {
if (!allowedToolboxFeatures.has(key)) {
throw new EChartsFenceError(
"UNSAFE_OPTION",
`option.toolbox.feature.${key} 不允许使用`
);
}
}
}
}
function assertSafeTransforms(value: JsonValue, path: string[] = []) {
if (Array.isArray(value)) {
value.forEach((item) => assertSafeTransforms(item, path));
return;
}
if (value === null || typeof value !== "object") {
return;
}
for (const [key, child] of Object.entries(value)) {
const childPath = [...path, key];
if (key === "transform") {
const transforms = Array.isArray(child) ? child : [child];
for (const transform of transforms) {
if (
transform === null ||
Array.isArray(transform) ||
typeof transform !== "object" ||
(transform.type !== "filter" && transform.type !== "sort")
) {
throw new EChartsFenceError(
"UNSAFE_OPTION",
`${optionPath(childPath)} 只允许内置 filter 或 sort`
);
}
}
}
assertSafeTransforms(child, childPath);
}
}
export function normalizeAndValidateEChartsOption(
value: unknown,
limits: EChartsSecurityLimits = defaultEChartsSecurityLimits
): Record<string, JsonValue> {
const normalized = normalizeValue(
value,
[],
0,
{ nodes: 0 },
limits
);
if (
normalized === null ||
Array.isArray(normalized) ||
typeof normalized !== "object"
) {
throw new EChartsFenceError(
"INVALID_OPTION",
"option 必须是普通对象"
);
}
assertSupportedTopLevelKeys(normalized);
assertSupportedSeries(normalized, limits);
assertSafeToolbox(normalized);
assertSafeTransforms(normalized);
return normalized;
}
@@ -0,0 +1,372 @@
import {
EChartsFenceError,
type JsonValue,
type SupportedEChartsSeriesType
} from "./protocol.js";
type JsonObject = Record<string, JsonValue>;
export interface EChartsSeriesValidationContext {
option: JsonObject;
series: JsonObject;
seriesIndex: number;
hasDataset: boolean;
}
export type EChartsSeriesValidator = (
context: EChartsSeriesValidationContext
) => void;
function fail(path: string, message: string): never {
throw new EChartsFenceError(
"INVALID_OPTION",
`${path} ${message}`
);
}
function isObject(value: JsonValue | undefined): value is JsonObject {
return (
value !== null &&
value !== undefined &&
!Array.isArray(value) &&
typeof value === "object"
);
}
function asObjects(value: JsonValue | undefined): JsonObject[] {
if (Array.isArray(value)) {
return value.filter(isObject);
}
return isObject(value) ? [value] : [];
}
function isNonEmptyData(value: JsonValue | undefined) {
if (Array.isArray(value)) {
return value.length > 0;
}
return isObject(value) && Object.keys(value).length > 0;
}
function datasetHasRows(dataset: JsonObject) {
const source = dataset.source;
if (Array.isArray(source)) {
if (source.length === 0) {
return false;
}
const firstRow = source[0];
const hasHeader =
dataset.sourceHeader === true ||
(Array.isArray(firstRow) &&
firstRow.length > 0 &&
firstRow.every((value) => typeof value === "string"));
return hasHeader ? source.length > 1 : true;
}
if (isObject(source)) {
return Object.values(source).some(
(column) => Array.isArray(column) && column.length > 0
);
}
return false;
}
function datasets(option: JsonObject) {
return asObjects(option.dataset);
}
function datasetHasSource(
option: JsonObject,
datasetIndex: number,
visited = new Set<number>()
): boolean {
const allDatasets = datasets(option);
const dataset = allDatasets[datasetIndex];
if (!dataset || visited.has(datasetIndex)) {
return false;
}
visited.add(datasetIndex);
if (datasetHasRows(dataset)) {
return true;
}
if (dataset.transform === undefined) {
return false;
}
const upstream = dataset.fromDatasetIndex;
if (
typeof upstream === "number" &&
Number.isInteger(upstream) &&
upstream >= 0
) {
return datasetHasSource(option, upstream, visited);
}
return allDatasets.some(
(candidate, index) =>
index !== datasetIndex && datasetHasRows(candidate)
);
}
function seriesHasDataset(option: JsonObject, series: JsonObject) {
const datasetIndex =
typeof series.datasetIndex === "number" &&
Number.isInteger(series.datasetIndex)
? series.datasetIndex
: 0;
return datasetHasSource(option, datasetIndex);
}
function requireData(context: EChartsSeriesValidationContext) {
if (
!isNonEmptyData(context.series.data) &&
!context.hasDataset
) {
fail(
`option.series.${context.seriesIndex}.data`,
"必须是非空数组,或通过 dataset.source 提供数据"
);
}
}
function ensureCartesianAxes(option: JsonObject) {
option.xAxis ??= {};
option.yAxis ??= {};
}
function requireComponent(
option: JsonObject,
name: string,
seriesIndex: number
) {
if (option[name] === undefined) {
fail(
`option.${name}`,
`是 option.series.${seriesIndex} 所用坐标系的必填组件`
);
}
}
function coordinateSystem(series: JsonObject) {
return typeof series.coordinateSystem === "string"
? series.coordinateSystem
: "cartesian2d";
}
function validateCartesianOrPolar(
context: EChartsSeriesValidationContext
) {
const coordinate = coordinateSystem(context.series);
if (coordinate === "cartesian2d") {
ensureCartesianAxes(context.option);
return;
}
if (coordinate === "polar") {
requireComponent(
context.option,
"polar",
context.seriesIndex
);
requireComponent(
context.option,
"angleAxis",
context.seriesIndex
);
requireComponent(
context.option,
"radiusAxis",
context.seriesIndex
);
return;
}
fail(
`option.series.${context.seriesIndex}.coordinateSystem`,
`暂不支持 ${coordinate}`
);
}
function dataValues(series: JsonObject): JsonValue[] {
return Array.isArray(series.data) ? series.data : [];
}
function itemValue(item: JsonValue): JsonValue {
return isObject(item) && item.value !== undefined
? item.value
: item;
}
function requireTupleData(
context: EChartsSeriesValidationContext,
minimumLength: number,
description: string
) {
if (context.hasDataset) {
return;
}
for (const [dataIndex, item] of dataValues(
context.series
).entries()) {
const value = itemValue(item);
if (!Array.isArray(value) || value.length < minimumLength) {
fail(
`option.series.${context.seriesIndex}.data.${dataIndex}`,
`必须是${description}`
);
}
}
}
function validateLineOrBar(
context: EChartsSeriesValidationContext
) {
requireData(context);
validateCartesianOrPolar(context);
}
function validateScatter(
context: EChartsSeriesValidationContext
) {
requireData(context);
const coordinate = coordinateSystem(context.series);
if (coordinate === "calendar") {
requireComponent(
context.option,
"calendar",
context.seriesIndex
);
requireTupleData(context, 2, "至少包含日期和值的数组");
return;
}
validateCartesianOrPolar(context);
requireTupleData(context, 2, "至少包含两个坐标值的数组");
}
function validatePie(context: EChartsSeriesValidationContext) {
requireData(context);
if (context.hasDataset) {
return;
}
for (const [dataIndex, item] of dataValues(
context.series
).entries()) {
const value = itemValue(item);
if (
typeof value !== "number" ||
!Number.isFinite(value)
) {
fail(
`option.series.${context.seriesIndex}.data.${dataIndex}.value`,
"必须是有限数值"
);
}
}
}
function radarIndicators(option: JsonObject) {
return asObjects(option.radar).flatMap((radar) =>
Array.isArray(radar.indicator)
? radar.indicator.filter(isObject)
: []
);
}
function validateRadar(context: EChartsSeriesValidationContext) {
requireData(context);
const indicators = radarIndicators(context.option);
if (indicators.length === 0) {
fail(
"option.radar.indicator",
"是雷达图的必填非空数组"
);
}
if (context.hasDataset) {
return;
}
requireTupleData(
context,
indicators.length,
`至少包含 ${indicators.length} 个指标值的数组`
);
}
function validateHeatmap(context: EChartsSeriesValidationContext) {
requireData(context);
const coordinate = coordinateSystem(context.series);
if (coordinate === "calendar") {
requireComponent(
context.option,
"calendar",
context.seriesIndex
);
requireTupleData(context, 2, "包含日期和值的数组");
return;
}
if (coordinate !== "cartesian2d") {
fail(
`option.series.${context.seriesIndex}.coordinateSystem`,
`暂不支持 ${coordinate}`
);
}
ensureCartesianAxes(context.option);
requireTupleData(context, 3, "包含 x、y、值的三元数组");
}
function validateBoxplot(
context: EChartsSeriesValidationContext
) {
requireData(context);
ensureCartesianAxes(context.option);
requireTupleData(
context,
5,
"包含最小值、Q1、中位数、Q3、最大值的五元数组"
);
}
function validateCandlestick(
context: EChartsSeriesValidationContext
) {
requireData(context);
ensureCartesianAxes(context.option);
requireTupleData(
context,
4,
"包含开盘、收盘、最低、最高值的四元数组"
);
}
export const echartsSeriesValidators: ReadonlyMap<
SupportedEChartsSeriesType,
EChartsSeriesValidator
> = new Map([
["line", validateLineOrBar],
["bar", validateLineOrBar],
["pie", validatePie],
["scatter", validateScatter],
["radar", validateRadar],
["heatmap", validateHeatmap],
["boxplot", validateBoxplot],
["candlestick", validateCandlestick]
]);
export function validateAndApplyEChartsOptionDefaults(
option: JsonObject
) {
const series = option.series;
if (!Array.isArray(series)) {
return option;
}
for (const [seriesIndex, value] of series.entries()) {
if (!isObject(value) || typeof value.type !== "string") {
continue;
}
const type = value.type as SupportedEChartsSeriesType;
const validator = echartsSeriesValidators.get(type);
validator?.({
option,
series: value,
seriesIndex,
hasDataset: seriesHasDataset(option, value)
});
}
return option;
}