From 92a5bfd0162c8914d2d21f98ec34443269d99301 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Mon, 27 Jul 2026 17:47:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20ECharts=20?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E9=98=B6=E6=AE=B5=E5=9B=BE=E8=A1=A8=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 tree、treemap、sunburst、graph、sankey、chord、funnel、gauge 和 pictorialBar 系列,并注册对应的 ECharts 按需模块。 完善层级与关系数据校验、节点和边数量限制、默认布局及错误占位规则,同时补充默认 Markdown 与 README 示例。 静态渲染全局关闭 ECharts 动画,修复 Treemap 标签冻结在透明动画帧的问题,并完成快速预览、精确 PDF、容器镜像和 147 项全项目测试验证。 --- apps/web/src/App.tsx | 232 +++++++++++- apps/web/src/paged-document-runtime.ts | 1 - docs/PROGRESS.md | 64 +++- packages/markdown-echarts/README.md | 123 ++++++- .../markdown-echarts/src/browser/engine.ts | 20 +- .../src/browser/render-chart.ts | 27 +- .../markdown-echarts/src/browser/types.ts | 1 - packages/markdown-echarts/src/protocol.ts | 11 +- .../markdown-echarts/src/security-policy.ts | 88 ++++- .../src/semantic-validator.ts | 331 +++++++++++++++++- .../tests/browser-render.test.ts | 37 +- .../tests/security-policy.test.ts | 68 ++++ .../tests/semantic-validator.test.ts | 180 ++++++++++ 13 files changed, 1162 insertions(+), 21 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 900f240..000fe6d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -94,9 +94,11 @@ flowchart LR ## ECharts 图表 +### 基础柱状图 + \`\`\`echarts caption: 各模块文档数量 -height: 72mm +height: 58mm option: xAxis: type: category @@ -108,6 +110,234 @@ option: data: [28, 18, 15, 22] \`\`\` +### Tree 树图 + +\`\`\`echarts +caption: 产品能力树 +height: 58mm +option: + series: + - type: tree + orient: LR + label: + position: left + data: + - name: 文档平台 + children: + - name: Markdown + children: + - name: Mermaid + - name: ECharts + - name: PDF + children: + - name: 快速预览 + - name: 精确预览 +\`\`\` + +### Treemap 矩形树图 + +\`\`\`echarts +caption: 模块工作量占比 +height: 58mm +option: + series: + - type: treemap + roam: false + nodeClick: false + breadcrumb: + show: false + label: + show: true + formatter: "{b}" + color: "#1f2937" + fontWeight: bold + backgroundColor: "rgba(255, 255, 255, 0.88)" + borderRadius: 3 + padding: [3, 6] + itemStyle: + borderColor: "#ffffff" + borderWidth: 2 + gapWidth: 2 + levels: + - itemStyle: + borderWidth: 0 + gapWidth: 2 + - upperLabel: + show: true + height: 28 + color: "#1f2937" + fontWeight: bold + backgroundColor: "rgba(255, 255, 255, 0.88)" + padding: [3, 6] + itemStyle: + borderColor: "#ffffff" + borderWidth: 2 + gapWidth: 2 + - label: + show: true + formatter: "{b}" + color: "#1f2937" + fontWeight: bold + backgroundColor: "rgba(255, 255, 255, 0.88)" + borderRadius: 3 + padding: [3, 6] + itemStyle: + borderColor: "#ffffff" + borderWidth: 2 + gapWidth: 2 + data: + - name: 渲染 + children: + - { name: Markdown, value: 32 } + - { name: ECharts, value: 24 } + - { name: Mermaid, value: 18 } + - name: 导出 + children: + - { name: PDF, value: 28 } + - { name: 主题, value: 12 } +\`\`\` + +### Sunburst 旭日图 + +\`\`\`echarts +caption: 文档内容层级 +height: 58mm +option: + series: + - type: sunburst + radius: [15%, 90%] + data: + - name: 技术文档 + children: + - { name: 架构, value: 18 } + - { name: 接口, value: 24 } + - name: 项目文档 + children: + - { name: 周报, value: 16 } + - { name: 计划, value: 12 } +\`\`\` + +### Graph 关系图 + +\`\`\`echarts +caption: 渲染链路关系 +height: 58mm +option: + series: + - type: graph + layout: circular + roam: false + label: + show: true + data: + - { name: Markdown, symbolSize: 42 } + - { name: HTML, symbolSize: 38 } + - { name: 预览, symbolSize: 34 } + - { name: PDF, symbolSize: 34 } + links: + - { source: Markdown, target: HTML } + - { source: HTML, target: 预览 } + - { source: HTML, target: PDF } +\`\`\` + +### Sankey 桑基图 + +\`\`\`echarts +caption: 文档流转量 +height: 58mm +option: + series: + - type: sankey + data: + - { name: Markdown } + - { name: HTML } + - { name: 快速预览 } + - { name: 精确预览 } + - { name: PDF } + links: + - { source: Markdown, target: HTML, value: 100 } + - { source: HTML, target: 快速预览, value: 55 } + - { source: HTML, target: 精确预览, value: 25 } + - { source: HTML, target: PDF, value: 20 } +\`\`\` + +### Chord 和弦图 + +\`\`\`echarts +caption: 团队协作关系 +height: 58mm +option: + series: + - type: chord + label: + show: true + data: + - { name: 研发 } + - { name: 产品 } + - { name: 设计 } + - { name: 运营 } + links: + - { source: 研发, target: 产品, value: 12 } + - { source: 产品, target: 设计, value: 8 } + - { source: 设计, target: 运营, value: 6 } + - { source: 运营, target: 研发, value: 4 } +\`\`\` + +### Funnel 漏斗图 + +\`\`\`echarts +caption: 文档发布转化 +height: 58mm +option: + series: + - type: funnel + sort: descending + label: + formatter: "{b}: {c}" + data: + - { name: 草稿, value: 100 } + - { name: 评审, value: 76 } + - { name: 定稿, value: 58 } + - { name: 发布, value: 45 } +\`\`\` + +### Gauge 仪表盘 + +\`\`\`echarts +caption: 发布完成率 +height: 58mm +option: + series: + - type: gauge + min: 0 + max: 100 + progress: + show: true + detail: + formatter: "{value}%" + data: + - { name: 完成率, value: 78 } +\`\`\` + +### PictorialBar 象形柱图 + +\`\`\`echarts +caption: 各渠道文档数量 +height: 58mm +option: + xAxis: + type: category + data: [知识库, 周报, 手册, 方案] + yAxis: + type: value + series: + - type: pictorialBar + symbol: "path://M0,10 L5,0 L10,10 Z" + symbolRepeat: true + symbolSize: [14, 10] + data: [12, 20, 16, 24] +\`\`\` + > 预览和 PDF 将共用同一份 HTML 与 CSS。 `; diff --git a/apps/web/src/paged-document-runtime.ts b/apps/web/src/paged-document-runtime.ts index 4748761..43142f1 100644 --- a/apps/web/src/paged-document-runtime.ts +++ b/apps/web/src/paged-document-runtime.ts @@ -216,7 +216,6 @@ export class PagedDocumentRuntime { const { renderEChartsBlocks } = await this.loadECharts(); const outcomes = await renderEChartsBlocks(container, { outputMode: "inline-svg", - animation: false, concurrency: 2, timeoutMs: 5_000 }); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index dc63df3..35f2546 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -33,6 +33,8 @@ ec48bce chore: 初始化项目骨架 最新阶段已完成 Markdown ECharts YAML 围栏、统一 SVG 渲染与分页, 并完善默认示例、源文本收起、顶栏文档状态和快速页码跳转。 +当前正在推进 `v0.3.1` ECharts 第二阶段,扩展层级图、关系图和更多 +业务图表类型。 ## 2. 已完成 @@ -294,6 +296,29 @@ ec48bce chore: 初始化项目骨架 - 快速预览的页码识别和跳转会合并外层滚动位置、iframe 位置、内部分页 位置与显示缩放比例,50%~400% 视觉缩放不会破坏页码同步。 +### 3.11 ECharts 第二阶段图表 + +- ECharts 系列支持从首版 8 种扩展到 17 种,新增 `tree`、`treemap`、 + `sunburst`、`graph`、`sankey`、`chord`、`funnel`、`gauge` 和 + `pictorialBar`。 +- 当前使用 ECharts 6.1.0,`chord` 直接采用 ECharts 6 官方和弦图及 + `ChordChart` 按需模块,不引入第三方扩展。 +- `tree`、`treemap` 和 `sunburst` 校验递归层级结构;矩形树图和旭日图 + 的叶节点必须提供有限数值。 +- `graph`、`sankey` 和 `chord` 校验节点、关系边、端点引用与边权重; + Graph 未指定布局时,有完整坐标则使用 `none`,否则默认使用 `force`。 +- `funnel`、`gauge` 校验有限数值数据;`pictorialBar` 复用柱图的数据和 + 坐标轴规则,并允许不加载外部资源的 `path://` 内嵌矢量路径。 +- 安全策略新增层级节点、关系节点和关系边数量限制,继续禁止外部图片、 + 跳转链接、函数、HTML formatter 和原型污染属性。 +- 默认 Markdown 现在包含基础柱状图及九种第二阶段图表,每种均使用独立 + YAML 围栏,可直接编辑和观察 SVG、分页及错误占位行为。 +- Treemap 默认示例关闭节点下钻和面包屑,显式启用父级与叶节点标签,并 + 增加白色分隔边框,避免扁平数据区域缺少辨识信息或底部控件占用空间。 +- Markdown/PDF 属于确定性的静态输出场景,浏览器引擎会在全局和系列级 + 强制关闭 ECharts 动画、动画延迟和持续时间,不允许 YAML 动画参数使 + 预览或 PDF 停留在动画中间帧。 + ## 4. 已执行验证 2026-07-27 在当前完整工作区成功执行: @@ -307,11 +332,12 @@ git diff --check 结果: -- ECharts 围栏测试:27 项通过; +- ECharts 围栏测试:36 项通过; - 共享配置测试:9 项通过; - 渲染器测试:10 项通过; - 前端测试:63 项通过; - 后端测试:29 项通过; +- 全项目共 147 项测试通过; - 全项目类型检查通过; - 生产构建通过; - `git diff --check` 通过。 @@ -356,6 +382,25 @@ git diff --check - Mermaid A/B PDF 页数、逐页文字和抽样视觉结果一致; - 抽查第 2、6、27、53、65 页,Mermaid 尺寸、分页位置和末页内容正常。 +ECharts 第二阶段浏览器与 PDF 验证结果: + +- 默认示例生成 10 个 ECharts SVG,包含原柱状图和九种新增系列,错误 + 占位数为 0,浏览器控制台无警告或错误; +- 快速预览生成 6 页,第 2~6 页各放置两张完整图表,所有图表边界均位于 + 当前页内容区内,没有跨页切割; +- 精确预览同样生成 6 页,逐页跳转正常;Chord、Funnel、Gauge 和 + PictorialBar 的 PDF Canvas 视觉检查通过; +- 使用默认导出配置调用 PDF API 生成 4 页 PDF,响应中的 ECharts 和 + Mermaid 错误数均为 0; +- PDF 中九种新增图表的标题均可搜索;将全部页面渲染为 PNG 后,Tree、 + Treemap、Sunburst、Graph、Sankey、Chord、Funnel、Gauge 和 + PictorialBar 均显示完整,未发现裁切、重叠、黑块或空白图表。 +- 修正 Treemap 示例后再次检查快速与精确预览:底部下钻面包屑已隐藏, + 五个叶节点边界清晰;SVG 中父级和叶节点共 7 个标签均处于可见状态。 +- 进一步确认此前 Treemap 标签虽存在于 SVG,但被冻结在入场动画初始帧, + `fill-opacity` 约为 `0.000014`,肉眼近似完全透明;全局关闭动画后, + 快速预览和精确 PDF 中的 2 个父级及 5 个叶节点标签均清晰可见。 + 2026-07-27 已重新构建并启动本地发布候选镜像: - 使用完整多阶段 `deploy/Dockerfile` 构建 @@ -393,7 +438,22 @@ git diff --check - 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过; - 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。 -为方便用户继续检查,AIO 容器当前运行在 `http://localhost:8080`。 +`v0.3.0` 发布验收完成后,已停止并删除本项目 Compose 容器及网络以释放 +本地资源;`yixiong/md-to-pdf:v0.3.0` 镜像继续保留。 + +2026-07-27 已构建并启动 `v0.3.1` 发布候选: + +- 使用完整多阶段 Dockerfile 构建 `yixiong/md-to-pdf:v0.3.1`,镜像 + 大小约 612 MB; +- Compose 容器使用该镜像运行并通过健康检查,服务监听 + `http://localhost:8080`; +- 容器首页返回 200,默认 Markdown 解析出 10 个 ECharts 围栏, + 错误占位和渲染警告均为 0; +- 容器生成的默认配置 PDF 为 4 页、约 521 KiB,ECharts 和 Mermaid + 渲染错误数均为 0; +- ECharts 已编译进按需加载的前端浏览器 Bundle,生产运行时不需要保留 + 独立的 `echarts` Node.js 包; +- 当前容器保留运行,等待 `v0.3.1` 发布前手动验收。 ## 5. 当前注意事项 diff --git a/packages/markdown-echarts/README.md b/packages/markdown-echarts/README.md index bfc5a1f..53f6f62 100644 --- a/packages/markdown-echarts/README.md +++ b/packages/markdown-echarts/README.md @@ -67,7 +67,7 @@ option: ## 系列验证规则 -当前首版支持以下系列: +当前支持以下系列: | 系列 | 主要规则 | | --- | --- | @@ -79,6 +79,15 @@ option: | `heatmap` | 直角坐标数据为 `[x, y, value]`;日历数据为 `[date, value]` | | `boxplot` | 直接数据为 `[min, Q1, median, Q3, max]` | | `candlestick` | 直接数据为 `[open, close, lowest, highest]` | +| `tree` | 必须提供非空层级 `data`;递归校验 `children` | +| `treemap` | 必须提供非空层级 `data`;叶节点必须具有有限数值 | +| `sunburst` | 必须提供非空层级 `data`;叶节点必须具有有限数值 | +| `graph` | 必须提供 `data` 或 `nodes`;校验边引用;缺少坐标时默认 `force` 布局 | +| `sankey` | 必须提供节点和关系边;边权重必须是非负有限数值 | +| `chord` | 使用 ECharts 6 和弦图;必须提供节点、关系边和非负权重 | +| `funnel` | 数据项必须是有限数值,或使用有效 `dataset` | +| `gauge` | 数据项必须是有限数值,或使用有效 `dataset` | +| `pictorialBar` | 需要有效数据和直角坐标系;允许安全的 `path://` 内嵌矢量符号 | 直角坐标系列缺少 `xAxis` 或 `yAxis` 时会补充空对象,让 ECharts 使用自身的轴默认值。极坐标、雷达和日历坐标系表达了明确语义, @@ -87,6 +96,111 @@ option: 使用 `dataset` 时,验证器只验证数据源是否存在,不重复实现 ECharts 的维度推断和 `encode` 映射规则。 +## 第二阶段系列示例 + +### 层级图 + +```yaml +height: 60mm +option: + series: + - type: treemap + nodeClick: false + breadcrumb: + show: false + label: + show: true + formatter: "{b}" + color: "#1f2937" + fontWeight: bold + backgroundColor: "rgba(255, 255, 255, 0.88)" + borderRadius: 3 + padding: [3, 6] + itemStyle: + borderColor: "#ffffff" + borderWidth: 2 + gapWidth: 2 + levels: + - itemStyle: + borderWidth: 0 + gapWidth: 2 + - upperLabel: + show: true + height: 28 + color: "#1f2937" + fontWeight: bold + backgroundColor: "rgba(255, 255, 255, 0.88)" + padding: [3, 6] + itemStyle: + borderColor: "#ffffff" + borderWidth: 2 + gapWidth: 2 + - label: + show: true + formatter: "{b}" + color: "#1f2937" + fontWeight: bold + backgroundColor: "rgba(255, 255, 255, 0.88)" + borderRadius: 3 + padding: [3, 6] + itemStyle: + borderColor: "#ffffff" + borderWidth: 2 + gapWidth: 2 + data: + - name: 渲染 + children: + - { name: Markdown, value: 32 } + - { name: ECharts, value: 24 } + - name: 导出 + children: + - { name: PDF, value: 28 } +``` + +`tree`、`treemap` 和 `sunburst` 共用递归 `children` 结构。 +`treemap` 与 `sunburst` 的叶节点需要数值;父节点可以省略数值, +由 ECharts 根据子节点汇总。 + +### 关系图 + +```yaml +height: 60mm +option: + series: + - type: sankey + data: + - { name: Markdown } + - { name: HTML } + - { name: PDF } + links: + - { source: Markdown, target: HTML, value: 100 } + - { source: HTML, target: PDF, value: 40 } +``` + +`graph` 的关系边可以省略;`sankey` 和 ECharts 6 `chord` 必须 +具有非空 `links` 或 `edges`。`source`、`target` 必须引用节点 +名称、ID 或数组索引。 + +### 业务图表 + +```yaml +height: 60mm +option: + series: + - type: gauge + min: 0 + max: 100 + progress: + show: true + detail: + formatter: "{value}%" + data: + - { name: 完成率, value: 78 } +``` + +`funnel` 和 `gauge` 使用有限数值数据;`pictorialBar` 复用柱图 +坐标系和数据规则,并允许不加载外部资源的 `path://` SVG 路径。 + ## 错误行为 配置错误不会中断整篇 Markdown。对应围栏会变成局部错误占位: @@ -133,7 +247,6 @@ import "@md-to-pdf/markdown-echarts/styles.css"; const outcomes = await renderEChartsBlocks(document, { outputMode: "inline-svg", - animation: false, concurrency: 2, timeoutMs: 5000 }); @@ -148,6 +261,9 @@ const outcomes = await renderEChartsBlocks(document, { PDF 输出前应等待所有图表、字体和图片完成,再开始分页或调用 Chromium `page.pdf()`。 +浏览器引擎会强制关闭全局及各系列动画,Markdown 中的动画参数不会改变 +静态 SVG、快速预览、精确预览或 PDF,避免输出停留在动画中间帧。 + ## 安全边界 默认策略会拒绝: @@ -158,7 +274,8 @@ Chromium `page.pdf()`。 - 原型污染属性; - 未允许的 toolbox 功能; - 非内置数据转换; -- 超过节点数、深度、字符串、数组和系列数量上限的配置。 +- 超过节点数、深度、字符串、数组、系列、层级节点或关系边数量 + 上限的配置。 这些限制适用于不受信任的 Markdown。宿主如需扩大能力,应在明确 风险后扩展验证器,而不是直接执行 YAML 中的 JavaScript。 diff --git a/packages/markdown-echarts/src/browser/engine.ts b/packages/markdown-echarts/src/browser/engine.ts index d7ea46f..14b664a 100644 --- a/packages/markdown-echarts/src/browser/engine.ts +++ b/packages/markdown-echarts/src/browser/engine.ts @@ -3,11 +3,20 @@ import { BarChart, BoxplotChart, CandlestickChart, + ChordChart, + FunnelChart, + GaugeChart, + GraphChart, HeatmapChart, LineChart, PieChart, + PictorialBarChart, RadarChart, - ScatterChart + SankeyChart, + ScatterChart, + SunburstChart, + TreeChart, + TreemapChart } from "echarts/charts"; import { AriaComponent, @@ -44,6 +53,15 @@ echarts.use([ HeatmapChart, BoxplotChart, CandlestickChart, + TreeChart, + TreemapChart, + SunburstChart, + GraphChart, + SankeyChart, + ChordChart, + FunnelChart, + GaugeChart, + PictorialBarChart, GridComponent, PolarComponent, RadarComponent, diff --git a/packages/markdown-echarts/src/browser/render-chart.ts b/packages/markdown-echarts/src/browser/render-chart.ts index f9307f9..ff02a4c 100644 --- a/packages/markdown-echarts/src/browser/render-chart.ts +++ b/packages/markdown-echarts/src/browser/render-chart.ts @@ -267,15 +267,28 @@ export async function renderEChartsBlock( useDirtyRect: false }); - const shouldAnimate = - options.animation ?? - (outputMode === "interactive" - ? definition.option.animation - : false); + const series = Array.isArray(definition.option.series) + ? definition.option.series.map((item) => + item && typeof item === "object" && !Array.isArray(item) + ? { + ...item, + animation: false, + animationDelay: 0, + animationDelayUpdate: 0, + animationDuration: 0, + animationDurationUpdate: 0 + } + : item + ) + : definition.option.series; const option = { ...definition.option, - animation: - typeof shouldAnimate === "boolean" ? shouldAnimate : false + animation: false, + animationDelay: 0, + animationDelayUpdate: 0, + animationDuration: 0, + animationDurationUpdate: 0, + series }; await waitForRender( instance, diff --git a/packages/markdown-echarts/src/browser/types.ts b/packages/markdown-echarts/src/browser/types.ts index a315d76..431c285 100644 --- a/packages/markdown-echarts/src/browser/types.ts +++ b/packages/markdown-echarts/src/browser/types.ts @@ -34,7 +34,6 @@ export interface RenderEChartsBlockOptions { outputMode?: EChartsOutputMode; timeoutMs?: number; signal?: AbortSignal; - animation?: boolean; resolveTheme?: ( theme: NormalizedEChartsFenceSpec["theme"], element: HTMLElement diff --git a/packages/markdown-echarts/src/protocol.ts b/packages/markdown-echarts/src/protocol.ts index af574b2..4e2686a 100644 --- a/packages/markdown-echarts/src/protocol.ts +++ b/packages/markdown-echarts/src/protocol.ts @@ -11,7 +11,16 @@ export const supportedEChartsSeriesTypes = [ "radar", "heatmap", "boxplot", - "candlestick" + "candlestick", + "tree", + "treemap", + "sunburst", + "graph", + "sankey", + "chord", + "funnel", + "gauge", + "pictorialBar" ] as const; export type SupportedEChartsSeriesType = diff --git a/packages/markdown-echarts/src/security-policy.ts b/packages/markdown-echarts/src/security-policy.ts index d4dbcce..9b3d889 100644 --- a/packages/markdown-echarts/src/security-policy.ts +++ b/packages/markdown-echarts/src/security-policy.ts @@ -11,6 +11,9 @@ export interface EChartsSecurityLimits { maxArrayLength: number; maxStringLength: number; maxSeries: number; + maxHierarchyNodes: number; + maxGraphNodes: number; + maxGraphEdges: number; } export const defaultEChartsSecurityLimits: EChartsSecurityLimits = { @@ -19,7 +22,10 @@ export const defaultEChartsSecurityLimits: EChartsSecurityLimits = { maxObjectKeys: 1_000, maxArrayLength: 20_000, maxStringLength: 100_000, - maxSeries: 32 + maxSeries: 32, + maxHierarchyNodes: 5_000, + maxGraphNodes: 5_000, + maxGraphEdges: 10_000 }; const forbiddenPropertyNames = new Set([ @@ -257,6 +263,86 @@ function assertSupportedSeries( `option.series.${index}.type 暂不支持 ${String(type)}` ); } + assertSeriesStructureLimits(item, index, type, limits); + } +} + +function countHierarchyNodes( + value: JsonValue | undefined, + limit: number, + path: string +) { + if (!Array.isArray(value)) { + return; + } + let count = 0; + const pending = [...value]; + while (pending.length > 0) { + const node = pending.pop(); + count += 1; + if (count > limit) { + throw new EChartsFenceError( + "LIMIT_EXCEEDED", + `${path} 层级节点数量不能超过 ${limit}` + ); + } + if ( + node !== null && + !Array.isArray(node) && + typeof node === "object" && + Array.isArray(node.children) + ) { + pending.push(...node.children); + } + } +} + +function arrayLength(value: JsonValue | undefined) { + return Array.isArray(value) ? value.length : 0; +} + +function assertSeriesStructureLimits( + series: Record, + seriesIndex: number, + type: string, + limits: EChartsSecurityLimits +) { + const path = `option.series.${seriesIndex}`; + if ( + type === "tree" || + type === "treemap" || + type === "sunburst" + ) { + countHierarchyNodes( + series.data, + limits.maxHierarchyNodes, + `${path}.data` + ); + return; + } + if ( + type !== "graph" && + type !== "sankey" && + type !== "chord" + ) { + return; + } + + const nodeCount = + arrayLength(series.data) + arrayLength(series.nodes); + if (nodeCount > limits.maxGraphNodes) { + throw new EChartsFenceError( + "LIMIT_EXCEEDED", + `${path} 节点数量不能超过 ${limits.maxGraphNodes}` + ); + } + const edgeCount = + arrayLength(series.links) + arrayLength(series.edges); + if (edgeCount > limits.maxGraphEdges) { + throw new EChartsFenceError( + "LIMIT_EXCEEDED", + `${path} 边数量不能超过 ${limits.maxGraphEdges}` + ); } } diff --git a/packages/markdown-echarts/src/semantic-validator.ts b/packages/markdown-echarts/src/semantic-validator.ts index 3917856..236e7ca 100644 --- a/packages/markdown-echarts/src/semantic-validator.ts +++ b/packages/markdown-echarts/src/semantic-validator.ts @@ -333,6 +333,326 @@ function validateCandlestick( ); } +function requireDirectArray( + context: EChartsSeriesValidationContext, + key: "data" | "nodes" | "links" | "edges", + description: string +): JsonValue[] { + const value = context.series[key]; + if (!Array.isArray(value) || value.length === 0) { + fail( + `option.series.${context.seriesIndex}.${key}`, + `必须是${description}` + ); + } + return value; +} + +function validateHierarchyNodes( + context: EChartsSeriesValidationContext, + requireLeafValue: boolean +) { + const roots = requireDirectArray( + context, + "data", + "非空层级节点数组" + ); + const pending = roots.map((node, index) => ({ + node, + path: `option.series.${context.seriesIndex}.data.${index}` + })); + + while (pending.length > 0) { + const current = pending.pop(); + if (!current) { + continue; + } + if (!isObject(current.node)) { + fail(current.path, "必须是层级节点对象"); + } + const children = current.node.children; + if (children !== undefined && !Array.isArray(children)) { + fail(`${current.path}.children`, "必须是数组"); + } + const childNodes = Array.isArray(children) ? children : []; + childNodes.forEach((node, index) => + pending.push({ + node, + path: `${current.path}.children.${index}` + }) + ); + + if (!requireLeafValue || childNodes.length > 0) { + continue; + } + const value = current.node.value; + const valid = + typeof value === "number" + ? Number.isFinite(value) + : Array.isArray(value) && + value.length > 0 && + value.every( + (item) => + typeof item === "number" && Number.isFinite(item) + ); + if (!valid) { + fail( + `${current.path}.value`, + "必须是有限数值或非空有限数值数组" + ); + } + } +} + +function validateTree(context: EChartsSeriesValidationContext) { + validateHierarchyNodes(context, false); +} + +function validateTreemapOrSunburst( + context: EChartsSeriesValidationContext +) { + validateHierarchyNodes(context, true); +} + +function relationNodes( + context: EChartsSeriesValidationContext +): { values: JsonValue[]; key: "data" | "nodes" } { + if ( + Array.isArray(context.series.data) && + context.series.data.length > 0 + ) { + return { values: context.series.data, key: "data" }; + } + return { + values: requireDirectArray( + context, + "nodes", + "非空节点数组" + ), + key: "nodes" + }; +} + +function relationEdges( + context: EChartsSeriesValidationContext, + required: boolean +): { values: JsonValue[]; key: "links" | "edges" } { + if ( + Array.isArray(context.series.links) && + context.series.links.length > 0 + ) { + return { values: context.series.links, key: "links" }; + } + if ( + Array.isArray(context.series.edges) && + context.series.edges.length > 0 + ) { + return { values: context.series.edges, key: "edges" }; + } + if (required) { + return { + values: requireDirectArray( + context, + "links", + "非空关系边数组" + ), + key: "links" + }; + } + return { values: [], key: "links" }; +} + +function nodeIdentifiers(node: JsonValue, index: number) { + const identifiers = new Set([`#${index}`]); + if (typeof node === "string" || typeof node === "number") { + identifiers.add(String(node)); + return identifiers; + } + if (!isObject(node)) { + return identifiers; + } + if ( + typeof node.id === "string" || + typeof node.id === "number" + ) { + identifiers.add(String(node.id)); + } + if ( + typeof node.name === "string" || + typeof node.name === "number" + ) { + identifiers.add(String(node.name)); + } + return identifiers; +} + +function edgeEndpointExists( + value: JsonValue | undefined, + identifiers: Set, + nodeCount: number +) { + if ( + typeof value !== "string" && + typeof value !== "number" + ) { + return false; + } + if ( + typeof value === "number" && + Number.isInteger(value) && + value >= 0 && + value < nodeCount + ) { + return true; + } + return identifiers.has(String(value)); +} + +function validateRelationSeries( + context: EChartsSeriesValidationContext, + options: { + requireEdges: boolean; + requireEdgeValue: boolean; + defaultGraphLayout?: boolean; + } +) { + const nodes = relationNodes(context); + const identifiers = new Set(); + nodes.values.forEach((node, index) => { + if ( + node === null || + Array.isArray(node) || + (typeof node !== "object" && + typeof node !== "string" && + typeof node !== "number") + ) { + fail( + `option.series.${context.seriesIndex}.${nodes.key}.${index}`, + "必须是节点对象、名称或数值" + ); + } + nodeIdentifiers(node, index).forEach((identifier) => + identifiers.add(identifier) + ); + }); + + if ( + options.defaultGraphLayout && + context.series.layout === undefined + ) { + const hasCoordinates = nodes.values.every( + (node) => + isObject(node) && + typeof node.x === "number" && + Number.isFinite(node.x) && + typeof node.y === "number" && + Number.isFinite(node.y) + ); + context.series.layout = hasCoordinates ? "none" : "force"; + } + + const edges = relationEdges(context, options.requireEdges); + edges.values.forEach((edge, edgeIndex) => { + const path = `option.series.${context.seriesIndex}.${edges.key}.${edgeIndex}`; + if (!isObject(edge)) { + fail(path, "必须是关系边对象"); + } + if ( + !edgeEndpointExists( + edge.source, + identifiers, + nodes.values.length + ) + ) { + fail(`${path}.source`, "必须引用已存在的节点"); + } + if ( + !edgeEndpointExists( + edge.target, + identifiers, + nodes.values.length + ) + ) { + fail(`${path}.target`, "必须引用已存在的节点"); + } + if (options.requireEdgeValue || edge.value !== undefined) { + if ( + typeof edge.value !== "number" || + !Number.isFinite(edge.value) || + edge.value < 0 + ) { + fail(`${path}.value`, "必须是非负有限数值"); + } + } + }); +} + +function validateGraph(context: EChartsSeriesValidationContext) { + const coordinate = context.series.coordinateSystem; + if (coordinate !== undefined && coordinate !== "none") { + fail( + `option.series.${context.seriesIndex}.coordinateSystem`, + `暂不支持 ${String(coordinate)}` + ); + } + validateRelationSeries(context, { + requireEdges: false, + requireEdgeValue: false, + defaultGraphLayout: true + }); +} + +function validateSankey(context: EChartsSeriesValidationContext) { + const coordinate = context.series.coordinateSystem; + if (coordinate !== undefined && coordinate !== "view") { + fail( + `option.series.${context.seriesIndex}.coordinateSystem`, + `暂不支持 ${String(coordinate)}` + ); + } + validateRelationSeries(context, { + requireEdges: true, + requireEdgeValue: true + }); +} + +function validateChord(context: EChartsSeriesValidationContext) { + const coordinate = context.series.coordinateSystem; + if (coordinate !== undefined && coordinate !== "none") { + fail( + `option.series.${context.seriesIndex}.coordinateSystem`, + `暂不支持 ${String(coordinate)}` + ); + } + validateRelationSeries(context, { + requireEdges: true, + requireEdgeValue: true + }); +} + +function validateFiniteValueData( + 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`, + "必须是有限数值" + ); + } + } +} + export const echartsSeriesValidators: ReadonlyMap< SupportedEChartsSeriesType, EChartsSeriesValidator @@ -344,7 +664,16 @@ export const echartsSeriesValidators: ReadonlyMap< ["radar", validateRadar], ["heatmap", validateHeatmap], ["boxplot", validateBoxplot], - ["candlestick", validateCandlestick] + ["candlestick", validateCandlestick], + ["tree", validateTree], + ["treemap", validateTreemapOrSunburst], + ["sunburst", validateTreemapOrSunburst], + ["graph", validateGraph], + ["sankey", validateSankey], + ["chord", validateChord], + ["funnel", validateFiniteValueData], + ["gauge", validateFiniteValueData], + ["pictorialBar", validateLineOrBar] ]); export function validateAndApplyEChartsOptionDefaults( diff --git a/packages/markdown-echarts/tests/browser-render.test.ts b/packages/markdown-echarts/tests/browser-render.test.ts index 137b516..4989ff8 100644 --- a/packages/markdown-echarts/tests/browser-render.test.ts +++ b/packages/markdown-echarts/tests/browser-render.test.ts @@ -37,12 +37,14 @@ function fakeEngine( ) { const dispose = vi.fn(); const resize = vi.fn(); + const setOption = vi.fn(); const init = vi.fn( (container: HTMLElement): EChartsInstanceLike => { dispose.mockImplementation(() => container.replaceChildren()); const handlers = new Map void>(); return { - setOption() { + setOption(option, setOptionOptions) { + setOption(option, setOptionOptions); const svg = document.createElementNS( "http://www.w3.org/2000/svg", "svg" @@ -73,7 +75,8 @@ function fakeEngine( engine: { init } satisfies EChartsEngine, init, dispose, - resize + resize, + setOption }; } @@ -103,6 +106,36 @@ describe("浏览器 ECharts 渲染", () => { ).toBeNull(); }); + it("全局及系列级动画始终关闭", async () => { + createDocumentWithChart(); + const { engine, setOption } = fakeEngine(); + + await renderEChartsBlocks(document, { + engine, + outputMode: "interactive" + }); + + expect(setOption).toHaveBeenCalledWith( + expect.objectContaining({ + animation: false, + animationDelay: 0, + animationDelayUpdate: 0, + animationDuration: 0, + animationDurationUpdate: 0, + series: [ + expect.objectContaining({ + animation: false, + animationDelay: 0, + animationDelayUpdate: 0, + animationDuration: 0, + animationDurationUpdate: 0 + }) + ] + }), + { notMerge: true } + ); + }); + it("生成 SVG Data URL 图片", async () => { createDocumentWithChart(); const { engine } = fakeEngine(); diff --git a/packages/markdown-echarts/tests/security-policy.test.ts b/packages/markdown-echarts/tests/security-policy.test.ts index c11e70c..5df4b62 100644 --- a/packages/markdown-echarts/tests/security-policy.test.ts +++ b/packages/markdown-echarts/tests/security-policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + defaultEChartsSecurityLimits, normalizeAndValidateEChartsOption } from "../src/index.js"; @@ -47,6 +48,73 @@ describe("normalizeAndValidateEChartsOption", () => { ).toThrow(/暂不支持 custom/u); }); + it("接受第二阶段系列和安全的内嵌矢量路径", () => { + const types = [ + "tree", + "treemap", + "sunburst", + "graph", + "sankey", + "chord", + "funnel", + "gauge", + "pictorialBar" + ]; + expect( + normalizeAndValidateEChartsOption({ + series: types.map((type) => ({ + type, + ...(type === "pictorialBar" + ? { symbol: "path://M0,0 L10,0 L10,10 Z" } + : {}) + })) + }).series + ).toHaveLength(types.length); + }); + + it("限制层级节点和关系边数量", () => { + const limits = { + ...defaultEChartsSecurityLimits, + maxHierarchyNodes: 2, + maxGraphEdges: 1 + }; + expect(() => + normalizeAndValidateEChartsOption( + { + series: [ + { + type: "tree", + data: [ + { + children: [{ value: 1 }, { value: 2 }] + } + ] + } + ] + }, + limits + ) + ).toThrow(/层级节点数量不能超过 2/u); + + expect(() => + normalizeAndValidateEChartsOption( + { + series: [ + { + type: "graph", + data: [{ name: "A" }, { name: "B" }], + links: [ + { source: "A", target: "B" }, + { source: "B", target: "A" } + ] + } + ] + }, + limits + ) + ).toThrow(/边数量不能超过 1/u); + }); + it("拒绝外部资源和危险链接", () => { expect(() => normalizeAndValidateEChartsOption({ diff --git a/packages/markdown-echarts/tests/semantic-validator.test.ts b/packages/markdown-echarts/tests/semantic-validator.test.ts index 8430eb0..255f4f9 100644 --- a/packages/markdown-echarts/tests/semantic-validator.test.ts +++ b/packages/markdown-echarts/tests/semantic-validator.test.ts @@ -182,4 +182,184 @@ describe("ECharts 系列语义验证", () => { }) ).toThrow(/option\.polar/u); }); + + it("支持树图并校验递归 children 结构", () => { + expect( + validate({ + series: [ + { + type: "tree", + data: [ + { + name: "根节点", + children: [{ name: "子节点" }] + } + ] + } + ] + }) + ).toMatchObject({ + series: [{ type: "tree" }] + }); + + expect(() => + validate({ + series: [ + { + type: "tree", + data: [{ name: "根节点", children: "错误" }] + } + ] + }) + ).toThrow(/data\.0\.children 必须是数组/u); + }); + + it("矩形树图和旭日图要求叶节点数值", () => { + for (const type of ["treemap", "sunburst"]) { + expect( + validate({ + series: [ + { + type, + data: [ + { + name: "分类", + children: [{ name: "项目", value: 12 }] + } + ] + } + ] + }) + ).toMatchObject({ series: [{ type }] }); + + expect(() => + validate({ + series: [ + { + type, + data: [{ name: "无数值叶节点" }] + } + ] + }) + ).toThrow(/data\.0\.value 必须是有限数值/u); + } + }); + + it("关系图默认选择可渲染布局并校验边引用", () => { + expect( + validate({ + series: [ + { + type: "graph", + data: [{ name: "A" }, { name: "B" }], + links: [{ source: "A", target: "B" }] + } + ] + }) + ).toMatchObject({ + series: [{ type: "graph", layout: "force" }] + }); + + expect( + validate({ + series: [ + { + type: "graph", + data: [ + { name: "A", x: 10, y: 20 }, + { name: "B", x: 30, y: 40 } + ] + } + ] + }) + ).toMatchObject({ + series: [{ type: "graph", layout: "none" }] + }); + + expect(() => + validate({ + series: [ + { + type: "graph", + nodes: [{ name: "A" }], + edges: [{ source: "A", target: "B" }] + } + ] + }) + ).toThrow(/edges\.0\.target 必须引用已存在的节点/u); + }); + + it("桑基图和和弦图要求有效节点、关系边和权重", () => { + for (const type of ["sankey", "chord"]) { + expect( + validate({ + series: [ + { + type, + data: [{ name: "A" }, { name: "B" }], + links: [ + { source: "A", target: "B", value: 12 } + ] + } + ] + }) + ).toMatchObject({ series: [{ type }] }); + + expect(() => + validate({ + series: [ + { + type, + data: [{ name: "A" }, { name: "B" }], + links: [{ source: "A", target: "B" }] + } + ] + }) + ).toThrow(/links\.0\.value 必须是非负有限数值/u); + } + }); + + it("漏斗图和仪表盘要求有限数值", () => { + for (const type of ["funnel", "gauge"]) { + expect( + validate({ + series: [ + { + type, + data: [{ name: "完成率", value: 72 }] + } + ] + }) + ).toMatchObject({ series: [{ type }] }); + + expect(() => + validate({ + series: [ + { + type, + data: [{ name: "完成率", value: "72" }] + } + ] + }) + ).toThrow(/data\.0\.value 必须是有限数值/u); + } + }); + + it("象形柱图复用柱图数据和坐标轴默认值", () => { + expect( + validate({ + series: [ + { + type: "pictorialBar", + symbol: "path://M0,0 L10,0 L10,10 Z", + data: [12, 18] + } + ] + }) + ).toMatchObject({ + xAxis: {}, + yAxis: {}, + series: [{ type: "pictorialBar" }] + }); + }); });