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
+57
View File
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit";
describe("ECharts 单页高度适配", () => {
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
const root = document.createElement("div");
root.innerHTML = `
<figure class="md-echarts">
<div class="md-echarts-host" style="height: 2000px">
<svg width="1000" height="2000"></svg>
</div>
</figure>
`;
fitOversizedEChartsToPage(root, defaultExportConfig);
const figure = root.querySelector<HTMLElement>(".md-echarts");
const host = root.querySelector<HTMLElement>(
".md-echarts-host"
);
const svg = root.querySelector<SVGSVGElement>("svg");
expect(figure?.dataset.pageHeightFitted).toBe("true");
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
1000.5748,
3
);
expect(host?.style.aspectRatio).toBe("");
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
1000.5748,
3
);
});
it("普通高度图表不改变作者设置的宿主高度", () => {
const root = document.createElement("div");
root.innerHTML = `
<figure class="md-echarts">
<div class="md-echarts-host" style="height: 300px">
<svg viewBox="0 0 1000 400"></svg>
</div>
</figure>
`;
fitOversizedEChartsToPage(root, defaultExportConfig);
const figure = root.querySelector<HTMLElement>(".md-echarts");
const host = root.querySelector<HTMLElement>(
".md-echarts-host"
);
expect(figure?.dataset.pageHeightFitted).toBeUndefined();
expect(host?.style.height).toBe("300px");
});
});
+14 -1
View File
@@ -41,7 +41,8 @@ describe("分页预览协议", () => {
expect(css).toContain("padding: 34px 0");
expect(css).toContain('html[data-render-target="preview"]');
expect(css).toContain("overflow-x: hidden");
expect(css).toContain("overflow-y: scroll");
expect(css).toContain("overflow-y: hidden");
expect(css).not.toContain("scrollbar-color");
expect(css).toContain('html[data-render-target="pdf"]');
expect(css).toContain("height: auto !important");
expect(css).toContain("overflow: visible !important");
@@ -178,9 +179,21 @@ describe("分页预览协议", () => {
type: "rendered",
requestId: 3,
pageCount: 2,
contentHeight: 2400,
echartsErrors: [],
mermaidErrors: []
})
).toBe(true);
expect(
isPagedPreviewFrameMessage({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId: 3,
pageCount: 2,
echartsErrors: [],
mermaidErrors: []
})
).toBe(false);
expect(
isPagedPreviewFrameMessage({
scope: "other",
+5
View File
@@ -23,6 +23,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-test"]),
fileName: "测试.pdf",
pageCount: 1,
echartsErrorCount: 0,
mermaidErrorCount: 0
};
const cache: CachedPdfExport = { key, result };
@@ -65,6 +66,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-cached"]),
fileName: "测试.pdf",
pageCount: 1,
echartsErrorCount: 0,
mermaidErrorCount: 0
};
const cache: CachedPdfExport = {
@@ -95,6 +97,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-new"]),
fileName: "测试.pdf",
pageCount: 2,
echartsErrorCount: 0,
mermaidErrorCount: 0
};
const requester = vi.fn(async () => result);
@@ -128,6 +131,7 @@ describe("PDF 导出客户端", () => {
"content-disposition":
"attachment; filename=\"document.pdf\"",
"x-pdf-page-count": "4",
"x-echarts-error-count": "2",
"x-mermaid-error-count": "1"
}
});
@@ -149,6 +153,7 @@ describe("PDF 导出客户端", () => {
);
expect(result.fileName).toBe("document.pdf");
expect(result.pageCount).toBe(4);
expect(result.echartsErrorCount).toBe(2);
expect(result.mermaidErrorCount).toBe(1);
expect(await result.blob.text()).toBe("%PDF-test");
});
+37 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { getNearestPageNumber } from "../src/preview-page";
import {
getNearestPageNumber,
getPreviewPageScrollTop,
normalizePreviewPageNumber
} from "../src/preview-page";
const pages = [
{ top: 0, bottom: 100 },
@@ -22,3 +26,35 @@ describe("getNearestPageNumber", () => {
expect(getNearestPageNumber(Number.NaN, pages)).toBe(0);
});
});
describe("normalizePreviewPageNumber", () => {
it("保留有效的整数页码", () => {
expect(normalizePreviewPageNumber("12", 3, 65)).toBe(12);
});
it("将越界页码限制在有效范围", () => {
expect(normalizePreviewPageNumber("0", 3, 65)).toBe(1);
expect(normalizePreviewPageNumber("99", 3, 65)).toBe(65);
});
it("无效输入恢复当前页", () => {
expect(normalizePreviewPageNumber("", 7, 65)).toBe(7);
expect(normalizePreviewPageNumber("3.5", 7, 65)).toBe(7);
expect(normalizePreviewPageNumber("abc", 7, 65)).toBe(7);
});
it("没有可用页面时返回 0", () => {
expect(normalizePreviewPageNumber("1", 0, 0)).toBe(0);
});
});
describe("getPreviewPageScrollTop", () => {
it("按目标页相对滚动容器的位置计算滚动量", () => {
expect(getPreviewPageScrollTop(200, 900, 120)).toBe(980);
});
it("避免返回负数或无效滚动量", () => {
expect(getPreviewPageScrollTop(20, 10, 100)).toBe(0);
expect(getPreviewPageScrollTop(Number.NaN, 10, 0)).toBe(0);
});
});