feat: 增加 ECharts 渲染与预览交互增强
- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染 - 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略 - 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步 - 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放 - 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
This commit is contained in:
@@ -149,6 +149,8 @@ function createPdfServerTiming(
|
||||
`navigation;dur=${milliseconds(timings.navigationMs)}`,
|
||||
`document-render;dur=${milliseconds(timings.documentRenderMs)}`,
|
||||
`runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`,
|
||||
`echarts;dur=${milliseconds(timings.echartsMs)}`,
|
||||
`echarts-fit;dur=${milliseconds(timings.echartsFitMs)}`,
|
||||
`mermaid;dur=${milliseconds(timings.mermaidMs)}`,
|
||||
`mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`,
|
||||
`mermaid-conversion;dur=${milliseconds(
|
||||
@@ -397,6 +399,10 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
.header("cache-control", "no-store")
|
||||
.header("server-timing", serverTiming)
|
||||
.header("x-pdf-page-count", String(generated.pageCount))
|
||||
.header(
|
||||
"x-echarts-error-count",
|
||||
String(generated.echartsErrors.length)
|
||||
)
|
||||
.header(
|
||||
"x-mermaid-error-count",
|
||||
String(generated.mermaidErrors.length)
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PdfRenderPayload = PagedDocumentPayload;
|
||||
export interface PdfGenerationResult {
|
||||
pdf: Buffer;
|
||||
pageCount: number;
|
||||
echartsErrors: string[];
|
||||
mermaidErrors: string[];
|
||||
timings: PdfGenerationTimings;
|
||||
}
|
||||
@@ -21,6 +22,8 @@ export interface PdfGenerationTimings {
|
||||
navigationMs: number;
|
||||
documentRenderMs: number;
|
||||
runtimeSetupMs: number;
|
||||
echartsMs: number;
|
||||
echartsFitMs: number;
|
||||
mermaidMs: number;
|
||||
mermaidFitMs: number;
|
||||
mermaidConversionMs: number;
|
||||
@@ -59,6 +62,8 @@ interface PdfDocumentResult
|
||||
| "navigationMs"
|
||||
| "documentRenderMs"
|
||||
| "runtimeSetupMs"
|
||||
| "echartsMs"
|
||||
| "echartsFitMs"
|
||||
| "mermaidMs"
|
||||
| "mermaidFitMs"
|
||||
| "mermaidConversionMs"
|
||||
@@ -469,11 +474,14 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
||||
return {
|
||||
pdf,
|
||||
pageCount: renderResult.pageCount,
|
||||
echartsErrors: renderResult.echartsErrors,
|
||||
mermaidErrors: renderResult.mermaidErrors,
|
||||
timings: {
|
||||
navigationMs,
|
||||
documentRenderMs,
|
||||
runtimeSetupMs: renderResult.timings.setupMs,
|
||||
echartsMs: renderResult.timings.echartsMs,
|
||||
echartsFitMs: renderResult.timings.echartsFitMs,
|
||||
mermaidMs: renderResult.timings.mermaidMs,
|
||||
mermaidFitMs: renderResult.timings.mermaidFitMs,
|
||||
mermaidConversionMs:
|
||||
|
||||
@@ -369,6 +369,7 @@ describe("PDF API", () => {
|
||||
const generate = vi.fn(async () => ({
|
||||
pdf: Buffer.from("%PDF-test"),
|
||||
pageCount: 3,
|
||||
echartsErrors: ["图表 2"],
|
||||
mermaidErrors: ["图表 1"],
|
||||
timings: {
|
||||
queueMs: 0,
|
||||
@@ -377,6 +378,8 @@ describe("PDF API", () => {
|
||||
navigationMs: 3,
|
||||
documentRenderMs: 4,
|
||||
runtimeSetupMs: 0.1,
|
||||
echartsMs: 0.15,
|
||||
echartsFitMs: 0.18,
|
||||
mermaidMs: 0.2,
|
||||
mermaidFitMs: 0.3,
|
||||
mermaidConversionMs: 0.35,
|
||||
@@ -415,6 +418,7 @@ describe("PDF API", () => {
|
||||
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf"
|
||||
);
|
||||
expect(response.headers["x-pdf-page-count"]).toBe("3");
|
||||
expect(response.headers["x-echarts-error-count"]).toBe("1");
|
||||
expect(response.headers["x-mermaid-error-count"]).toBe("1");
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"document-render;dur=4.0"
|
||||
@@ -422,6 +426,9 @@ describe("PDF API", () => {
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"pdf-print;dur=5.0"
|
||||
);
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"echarts;dur=0.1"
|
||||
);
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"mermaid;dur=0.2"
|
||||
);
|
||||
|
||||
@@ -47,9 +47,12 @@ function fakeBrowser(
|
||||
waitForFunction: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => ({
|
||||
pageCount: 2,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: [],
|
||||
timings: {
|
||||
setupMs: 1,
|
||||
echartsMs: 1.5,
|
||||
echartsFitMs: 1.75,
|
||||
mermaidMs: 2,
|
||||
mermaidFitMs: 3,
|
||||
mermaidConversionMs: 3.5,
|
||||
@@ -191,6 +194,8 @@ describe("Playwright PDF 生成器", () => {
|
||||
navigationMs: expect.any(Number),
|
||||
documentRenderMs: expect.any(Number),
|
||||
runtimeSetupMs: 1,
|
||||
echartsMs: 1.5,
|
||||
echartsFitMs: 1.75,
|
||||
mermaidMs: 2,
|
||||
mermaidFitMs: 3,
|
||||
mermaidConversionMs: 3.5,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||
"@mermaid-js/layout-elk": "0.2.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
|
||||
+215
-50
@@ -16,6 +16,7 @@ import {
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
|
||||
import { PreviewPageControl } from "./PreviewPageControl";
|
||||
import { PreviewZoomControl } from "./PreviewZoomControl";
|
||||
import {
|
||||
loadExportConfig,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
} from "./paged-preview";
|
||||
import {
|
||||
getNearestPageNumber,
|
||||
PREVIEW_SCROLLBAR_WIDTH_PX
|
||||
getPreviewPageScrollTop
|
||||
} from "./preview-page";
|
||||
import {
|
||||
createPdfExportCacheKey,
|
||||
@@ -73,7 +74,7 @@ keywords:
|
||||
|
||||
- [x] Markdown 实时预览
|
||||
- [x] 表格与任务列表
|
||||
- [x] 数学公式与 Mermaid
|
||||
- [x] 数学公式、Mermaid 与 ECharts
|
||||
- [ ] Chromium PDF 导出
|
||||
|
||||
| 功能 | 当前状态 |
|
||||
@@ -91,6 +92,22 @@ flowchart LR
|
||||
C --> E["PDF 导出"]
|
||||
\`\`\`
|
||||
|
||||
## ECharts 图表
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 各模块文档数量
|
||||
height: 72mm
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [研发, 产品, 设计, 运营]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: bar
|
||||
data: [28, 18, 15, 22]
|
||||
\`\`\`
|
||||
|
||||
> 预览和 PDF 将共用同一份 HTML 与 CSS。
|
||||
`;
|
||||
|
||||
@@ -100,8 +117,10 @@ export function App() {
|
||||
const [exportConfig, setExportConfig] =
|
||||
useState<ExportConfig>(loadExportConfig);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
|
||||
const [status, setStatus] = useState("正在准备预览…");
|
||||
const [appError, setAppError] = useState("");
|
||||
const [echartsError, setEChartsError] = useState("");
|
||||
const [mermaidError, setMermaidError] = useState("");
|
||||
const [paginationError, setPaginationError] = useState("");
|
||||
const [pdfError, setPdfError] = useState("");
|
||||
@@ -111,6 +130,8 @@ export function App() {
|
||||
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
|
||||
const [pdfCache, setPdfCache] = useState<CachedPdfExport>();
|
||||
const [previewFrameReady, setPreviewFrameReady] = useState(false);
|
||||
const [quickPreviewContentHeight, setQuickPreviewContentHeight] =
|
||||
useState<number>();
|
||||
const [previewPagination, setPreviewPagination] = useState({
|
||||
current: 0,
|
||||
total: 0
|
||||
@@ -148,6 +169,7 @@ export function App() {
|
||||
[]
|
||||
);
|
||||
const handleRenderStart = useCallback(() => {
|
||||
setEChartsError("");
|
||||
setMermaidError("");
|
||||
}, []);
|
||||
const { error: renderError, result } = useMarkdownRender(
|
||||
@@ -167,6 +189,7 @@ export function App() {
|
||||
renderError ||
|
||||
themeError ||
|
||||
appError ||
|
||||
echartsError ||
|
||||
mermaidError ||
|
||||
paginationError ||
|
||||
pdfError;
|
||||
@@ -177,17 +200,23 @@ export function App() {
|
||||
exportConfig.paper.orientation
|
||||
);
|
||||
const previewScale = previewZoom / 100;
|
||||
const naturalPaperWidth =
|
||||
millimetersToCssPixels(paperDimensions.width) +
|
||||
PREVIEW_SCROLLBAR_WIDTH_PX;
|
||||
const naturalPaperWidth = millimetersToCssPixels(paperDimensions.width);
|
||||
const scaledPaperWidth = naturalPaperWidth * previewScale;
|
||||
const paperStyle = {
|
||||
width: `${scaledPaperWidth}px`,
|
||||
minWidth: `${scaledPaperWidth}px`
|
||||
minWidth: `${scaledPaperWidth}px`,
|
||||
...(quickPreviewContentHeight
|
||||
? {
|
||||
height: `${quickPreviewContentHeight * previewScale}px`,
|
||||
minHeight: `${quickPreviewContentHeight * previewScale}px`
|
||||
}
|
||||
: {})
|
||||
} satisfies CSSProperties;
|
||||
const previewFrameStyle = {
|
||||
width: `${100 / previewScale}%`,
|
||||
height: `${100 / previewScale}%`,
|
||||
height: quickPreviewContentHeight
|
||||
? `${quickPreviewContentHeight}px`
|
||||
: `${100 / previewScale}%`,
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: "top left"
|
||||
} satisfies CSSProperties;
|
||||
@@ -271,7 +300,9 @@ export function App() {
|
||||
|
||||
if (event.data.type === "rendering") {
|
||||
setPaginationError("");
|
||||
setEChartsError("");
|
||||
setMermaidError("");
|
||||
setQuickPreviewContentHeight(undefined);
|
||||
setPreviewPagination({ current: 0, total: 0 });
|
||||
setStatus("正在分页…");
|
||||
return;
|
||||
@@ -283,12 +314,18 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
setEChartsError(
|
||||
event.data.echartsErrors.length > 0
|
||||
? `ECharts:${event.data.echartsErrors.join(";")}`
|
||||
: ""
|
||||
);
|
||||
setMermaidError(
|
||||
event.data.mermaidErrors.length > 0
|
||||
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
||||
: ""
|
||||
);
|
||||
setStatus(`预览已分页(${event.data.pageCount} 页)`);
|
||||
setQuickPreviewContentHeight(event.data.contentHeight);
|
||||
setPreviewPagination({
|
||||
current: event.data.pageCount > 0 ? 1 : 0,
|
||||
total: event.data.pageCount
|
||||
@@ -305,22 +342,15 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const frameWindow = previewFrameRef.current?.contentWindow;
|
||||
if (!frameWindow || !previewFrameReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handlePreviewScroll() {
|
||||
synchronizeScroll("preview");
|
||||
function handleWindowScroll() {
|
||||
if (getOuterPreviewScroller() === document.scrollingElement) {
|
||||
updatePreviewCurrentPage();
|
||||
}
|
||||
}
|
||||
|
||||
frameWindow.addEventListener("scroll", handlePreviewScroll, {
|
||||
passive: true
|
||||
});
|
||||
return () =>
|
||||
frameWindow.removeEventListener("scroll", handlePreviewScroll);
|
||||
}, [previewFrameReady]);
|
||||
window.addEventListener("scroll", handleWindowScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleWindowScroll);
|
||||
}, [previewMode, quickPreviewContentHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -346,12 +376,19 @@ export function App() {
|
||||
);
|
||||
}, [previewFrameReady, previewPayload]);
|
||||
|
||||
function getPreviewScroller() {
|
||||
if (previewModeRef.current === "precise") {
|
||||
return previewScrollRef.current;
|
||||
function getOuterPreviewScroller() {
|
||||
const previewScroller = previewScrollRef.current;
|
||||
if (!previewScroller) {
|
||||
return null;
|
||||
}
|
||||
return previewFrameRef.current?.contentDocument
|
||||
?.scrollingElement as HTMLElement | null;
|
||||
|
||||
return previewScroller.scrollHeight > previewScroller.clientHeight + 1
|
||||
? previewScroller
|
||||
: (document.scrollingElement as HTMLElement | null);
|
||||
}
|
||||
|
||||
function getPreviewScroller() {
|
||||
return getOuterPreviewScroller();
|
||||
}
|
||||
|
||||
function synchronizeScroll(origin: "editor" | "preview") {
|
||||
@@ -392,15 +429,28 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollerViewportTop =
|
||||
scrollElement === document.scrollingElement
|
||||
? 0
|
||||
: scrollElement.getBoundingClientRect().top;
|
||||
const frameViewportTop = frame.getBoundingClientRect().top;
|
||||
const viewportCenter =
|
||||
scrollElement.scrollTop + frame.clientHeight / 2;
|
||||
scrollElement.scrollTop + scrollElement.clientHeight / 2;
|
||||
const pages = Array.from(
|
||||
frameDocument.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
).map((page) => {
|
||||
const rect = page.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top + scrollElement.scrollTop,
|
||||
bottom: rect.bottom + scrollElement.scrollTop
|
||||
top: getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
frameViewportTop + rect.top * previewScale,
|
||||
scrollerViewportTop
|
||||
),
|
||||
bottom: getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
frameViewportTop + rect.bottom * previewScale,
|
||||
scrollerViewportTop
|
||||
)
|
||||
};
|
||||
});
|
||||
const current = getNearestPageNumber(viewportCenter, pages);
|
||||
@@ -417,20 +467,35 @@ export function App() {
|
||||
}
|
||||
|
||||
function updatePrecisePreviewCurrentPage() {
|
||||
const scrollElement = previewScrollRef.current;
|
||||
const scrollElement = getOuterPreviewScroller();
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
const scrollerViewportTop =
|
||||
scrollElement === document.scrollingElement
|
||||
? 0
|
||||
: scrollElement.getBoundingClientRect().top;
|
||||
const viewportCenter =
|
||||
scrollElement.scrollTop + scrollElement.clientHeight / 2;
|
||||
const pages = Array.from(
|
||||
scrollElement.querySelectorAll<HTMLElement>(
|
||||
".precise-pdf-page"
|
||||
)
|
||||
).map((page) => ({
|
||||
top: page.offsetTop,
|
||||
bottom: page.offsetTop + page.offsetHeight
|
||||
}));
|
||||
).map((page) => {
|
||||
const rect = page.getBoundingClientRect();
|
||||
return {
|
||||
top: getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
rect.top,
|
||||
scrollerViewportTop
|
||||
),
|
||||
bottom: getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
rect.bottom,
|
||||
scrollerViewportTop
|
||||
)
|
||||
};
|
||||
});
|
||||
const current = getNearestPageNumber(viewportCenter, pages);
|
||||
|
||||
setPreviewPagination((pagination) =>
|
||||
@@ -444,6 +509,60 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function handlePreviewPageChange(pageNumber: number) {
|
||||
const scrollElement = getPreviewScroller();
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetTop: number | undefined;
|
||||
if (previewModeRef.current === "precise") {
|
||||
const page = scrollElement.querySelectorAll<HTMLElement>(
|
||||
".precise-pdf-page"
|
||||
)[pageNumber - 1];
|
||||
if (page) {
|
||||
const scrollerViewportTop =
|
||||
scrollElement === document.scrollingElement
|
||||
? 0
|
||||
: scrollElement.getBoundingClientRect().top;
|
||||
targetTop = getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
page.getBoundingClientRect().top,
|
||||
scrollerViewportTop
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const page =
|
||||
previewFrameRef.current?.contentDocument?.querySelectorAll<HTMLElement>(
|
||||
".pagedjs_page"
|
||||
)[pageNumber - 1];
|
||||
if (page) {
|
||||
const scrollerViewportTop =
|
||||
scrollElement === document.scrollingElement
|
||||
? 0
|
||||
: scrollElement.getBoundingClientRect().top;
|
||||
const frameViewportTop =
|
||||
previewFrameRef.current?.getBoundingClientRect().top ?? 0;
|
||||
targetTop = getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
frameViewportTop +
|
||||
page.getBoundingClientRect().top * previewScale,
|
||||
scrollerViewportTop
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetTop === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewPagination((pagination) => ({
|
||||
...pagination,
|
||||
current: pageNumber
|
||||
}));
|
||||
scrollElement.scrollTo({ top: Math.max(0, targetTop) });
|
||||
}
|
||||
|
||||
const handlePrecisePageCount = useCallback((pageCount: number) => {
|
||||
setPreviewPagination({
|
||||
current: pageCount > 0 ? 1 : 0,
|
||||
@@ -502,8 +621,12 @@ export function App() {
|
||||
exported.mermaidErrorCount > 0
|
||||
? `,${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
|
||||
: "";
|
||||
const echartsDescription =
|
||||
exported.echartsErrorCount > 0
|
||||
? `,${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
|
||||
: "";
|
||||
setStatus(
|
||||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${mermaidDescription}`
|
||||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`
|
||||
);
|
||||
} catch (reason) {
|
||||
setPdfError(
|
||||
@@ -521,6 +644,7 @@ export function App() {
|
||||
}
|
||||
setPreviewMode(mode);
|
||||
setPreviewFrameReady(false);
|
||||
setQuickPreviewContentHeight(undefined);
|
||||
setPreviewPagination({ current: 0, total: 0 });
|
||||
previewScrollRef.current?.scrollTo({ top: 0 });
|
||||
if (mode === "precise" && !cachedPdf) {
|
||||
@@ -529,20 +653,30 @@ export function App() {
|
||||
}
|
||||
|
||||
function handlePreviewScroll() {
|
||||
if (previewModeRef.current !== "precise") {
|
||||
return;
|
||||
}
|
||||
synchronizeScroll("preview");
|
||||
updatePrecisePreviewCurrentPage();
|
||||
updatePreviewCurrentPage();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="workspace">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<div className="topbar-primary">
|
||||
<div className="topbar-brand">
|
||||
<p className="eyebrow">内网文档工具</p>
|
||||
<h1>Markdown PDF 导出器</h1>
|
||||
</div>
|
||||
<div className="document-summary">
|
||||
<span className="panel-kicker">源文件</span>
|
||||
<strong title={fileName}>{fileName}</strong>
|
||||
<span
|
||||
className={`render-status${error ? " is-error" : ""}`}
|
||||
aria-live="polite"
|
||||
title={error || status}
|
||||
>
|
||||
{error || status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<label className="file-button">
|
||||
选择 Markdown
|
||||
@@ -565,16 +699,31 @@ export function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="editor-layout">
|
||||
<section
|
||||
className={`editor-layout${
|
||||
sourcePanelCollapsed ? " is-source-collapsed" : ""
|
||||
}`}
|
||||
>
|
||||
<aside className="editor-panel" aria-label="Markdown 编辑区">
|
||||
<div
|
||||
id="markdown-source-content"
|
||||
className="source-panel-content"
|
||||
hidden={sourcePanelCollapsed}
|
||||
>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="panel-kicker">源文件</span>
|
||||
<strong>{fileName}</strong>
|
||||
<span className="panel-kicker">源文本</span>
|
||||
<strong>Markdown 原文</strong>
|
||||
</div>
|
||||
<span className={`render-status${error ? " is-error" : ""}`}>
|
||||
{error || status}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="source-panel-toggle"
|
||||
aria-controls="markdown-source-content"
|
||||
aria-expanded="true"
|
||||
onClick={() => setSourcePanelCollapsed(true)}
|
||||
>
|
||||
收起
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
@@ -584,6 +733,18 @@ export function App() {
|
||||
onChange={(event) => setMarkdown(event.target.value)}
|
||||
onScroll={() => synchronizeScroll("editor")}
|
||||
/>
|
||||
</div>
|
||||
{sourcePanelCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="source-panel-restore"
|
||||
aria-controls="markdown-source-content"
|
||||
aria-expanded="false"
|
||||
onClick={() => setSourcePanelCollapsed(false)}
|
||||
>
|
||||
展开源文本
|
||||
</button>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<section className="preview-panel" aria-label="文档预览">
|
||||
@@ -593,11 +754,6 @@ export function App() {
|
||||
<strong>{result?.metadata.title || "未命名文档"}</strong>
|
||||
</div>
|
||||
<span className="preview-heading-actions">
|
||||
{previewPagination.total > 0 ? (
|
||||
<span className="preview-page-indicator" aria-live="polite">
|
||||
第 {previewPagination.current} / {previewPagination.total} 页
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="preview-mode-control"
|
||||
role="group"
|
||||
@@ -654,6 +810,15 @@ export function App() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="preview-toolbar">
|
||||
{previewPagination.total > 0 ? (
|
||||
<PreviewPageControl
|
||||
current={previewPagination.current}
|
||||
total={previewPagination.total}
|
||||
onNavigate={handlePreviewPageChange}
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<PreviewZoomControl
|
||||
value={previewZoom}
|
||||
onChange={setPreviewZoom}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { normalizePreviewPageNumber } from "./preview-page";
|
||||
|
||||
interface PreviewPageControlProps {
|
||||
current: number;
|
||||
total: number;
|
||||
onNavigate: (pageNumber: number) => void;
|
||||
}
|
||||
|
||||
export function PreviewPageControl({
|
||||
current,
|
||||
total,
|
||||
onNavigate
|
||||
}: PreviewPageControlProps) {
|
||||
const [draft, setDraft] = useState(String(current));
|
||||
const editingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingRef.current) {
|
||||
setDraft(String(current));
|
||||
}
|
||||
}, [current, total]);
|
||||
|
||||
function commitPageNumber() {
|
||||
const pageNumber = normalizePreviewPageNumber(draft, current, total);
|
||||
setDraft(String(pageNumber));
|
||||
if (pageNumber !== current) {
|
||||
onNavigate(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="preview-page-control">
|
||||
<span>第</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={total}
|
||||
step={1}
|
||||
value={draft}
|
||||
aria-label="跳转页码"
|
||||
onBlur={() => {
|
||||
editingRef.current = false;
|
||||
commitPageNumber();
|
||||
}}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onFocus={(event) => {
|
||||
editingRef.current = true;
|
||||
event.currentTarget.select();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span aria-live="polite">/ {total} 页</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
lengthToMillimeters,
|
||||
millimetersToCssPixels,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface DiagramDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DiagramPageFit extends DiagramDimensions {
|
||||
scaled: boolean;
|
||||
}
|
||||
|
||||
export interface SvgDiagramFitOptions {
|
||||
containerSelector: string;
|
||||
svgSelector?: string;
|
||||
resolvePageContent?: (
|
||||
container: HTMLElement,
|
||||
pageContent: DiagramDimensions
|
||||
) => DiagramDimensions;
|
||||
onFit?: (
|
||||
container: HTMLElement,
|
||||
svg: SVGSVGElement,
|
||||
fit: DiagramPageFit
|
||||
) => void;
|
||||
}
|
||||
|
||||
const PAGE_FIT_EPSILON_PX = 1;
|
||||
|
||||
export function parseSvgViewBox(
|
||||
value: string | null
|
||||
): DiagramDimensions | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const values = value
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number(part));
|
||||
const [, , width = Number.NaN, height = Number.NaN] = values;
|
||||
|
||||
if (
|
||||
values.length !== 4 ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function parseSvgDimensions(
|
||||
svg: SVGSVGElement
|
||||
): DiagramDimensions | undefined {
|
||||
const width = Number.parseFloat(svg.getAttribute("width") ?? "");
|
||||
const height = Number.parseFloat(
|
||||
svg.getAttribute("height") ?? ""
|
||||
);
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function getPageContentDimensions(
|
||||
config: ExportConfig
|
||||
): DiagramDimensions {
|
||||
const paper = getPaperDimensionsMm(
|
||||
config.paper.format,
|
||||
config.paper.orientation
|
||||
);
|
||||
const width =
|
||||
paper.width -
|
||||
lengthToMillimeters(config.paper.margins.left) -
|
||||
lengthToMillimeters(config.paper.margins.right);
|
||||
const height =
|
||||
paper.height -
|
||||
lengthToMillimeters(config.paper.margins.top) -
|
||||
lengthToMillimeters(config.paper.margins.bottom);
|
||||
|
||||
return {
|
||||
width: millimetersToCssPixels(width),
|
||||
height: millimetersToCssPixels(height)
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateDiagramPageFit(
|
||||
intrinsic: DiagramDimensions,
|
||||
pageContent: DiagramDimensions
|
||||
): DiagramPageFit {
|
||||
const heightAfterWidthFit =
|
||||
(pageContent.width * intrinsic.height) / intrinsic.width;
|
||||
|
||||
if (heightAfterWidthFit <= pageContent.height) {
|
||||
return {
|
||||
width: pageContent.width,
|
||||
height: heightAfterWidthFit,
|
||||
scaled: false
|
||||
};
|
||||
}
|
||||
|
||||
const height = Math.max(
|
||||
0,
|
||||
pageContent.height - PAGE_FIT_EPSILON_PX
|
||||
);
|
||||
return {
|
||||
width: (height * intrinsic.width) / intrinsic.height,
|
||||
height,
|
||||
scaled: true
|
||||
};
|
||||
}
|
||||
|
||||
export function fitSvgDiagramsToPage(
|
||||
root: ParentNode,
|
||||
config: ExportConfig,
|
||||
options: SvgDiagramFitOptions
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(config);
|
||||
const containers = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(options.containerSelector)
|
||||
);
|
||||
|
||||
for (const container of containers) {
|
||||
const svg = container.querySelector<SVGSVGElement>(
|
||||
options.svgSelector ?? "svg"
|
||||
);
|
||||
const intrinsic = svg
|
||||
? parseSvgViewBox(svg.getAttribute("viewBox")) ??
|
||||
parseSvgDimensions(svg)
|
||||
: undefined;
|
||||
if (!svg || !intrinsic) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const availablePageContent =
|
||||
options.resolvePageContent?.(container, pageContent) ??
|
||||
pageContent;
|
||||
const fit = calculateDiagramPageFit(
|
||||
intrinsic,
|
||||
availablePageContent
|
||||
);
|
||||
if (!fit.scaled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
container.dataset.pageHeightFitted = "true";
|
||||
container.style.setProperty("margin-block", "0", "important");
|
||||
if (!svg.getAttribute("viewBox")) {
|
||||
svg.setAttribute(
|
||||
"viewBox",
|
||||
`0 0 ${intrinsic.width} ${intrinsic.height}`
|
||||
);
|
||||
}
|
||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
svg.style.setProperty("display", "block");
|
||||
svg.style.setProperty("width", `${fit.width}px`, "important");
|
||||
svg.style.setProperty("height", `${fit.height}px`, "important");
|
||||
svg.style.setProperty("max-width", "100%", "important");
|
||||
svg.style.setProperty(
|
||||
"max-height",
|
||||
`${fit.height}px`,
|
||||
"important"
|
||||
);
|
||||
options.onFit?.(container, svg, fit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import { fitSvgDiagramsToPage } from "./diagram-page-fit";
|
||||
|
||||
export function fitOversizedEChartsToPage(
|
||||
container: ParentNode,
|
||||
config: ExportConfig
|
||||
) {
|
||||
fitSvgDiagramsToPage(container, config, {
|
||||
containerSelector: ".md-echarts",
|
||||
svgSelector: ".md-echarts-host svg",
|
||||
resolvePageContent(figure, pageContent) {
|
||||
const host = figure.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
const figureHeight = figure.getBoundingClientRect().height;
|
||||
const hostHeight = host?.getBoundingClientRect().height ?? 0;
|
||||
const nonChartHeight = Math.max(
|
||||
0,
|
||||
figureHeight - hostHeight
|
||||
);
|
||||
return {
|
||||
width: pageContent.width,
|
||||
height: Math.max(0, pageContent.height - nonChartHeight)
|
||||
};
|
||||
},
|
||||
onFit(figure, _svg, fit) {
|
||||
const host = figure.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
host.style.setProperty(
|
||||
"width",
|
||||
`${fit.width}px`,
|
||||
"important"
|
||||
);
|
||||
host.style.setProperty(
|
||||
"height",
|
||||
`${fit.height}px`,
|
||||
"important"
|
||||
);
|
||||
host.style.setProperty("margin-inline", "auto", "important");
|
||||
host.style.removeProperty("aspect-ratio");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,123 +1,26 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
lengthToMillimeters,
|
||||
millimetersToCssPixels,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
calculateDiagramPageFit,
|
||||
fitSvgDiagramsToPage,
|
||||
getPageContentDimensions,
|
||||
parseSvgViewBox,
|
||||
type DiagramDimensions,
|
||||
type DiagramPageFit
|
||||
} from "./diagram-page-fit";
|
||||
|
||||
export interface MermaidDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface MermaidPageFit extends MermaidDimensions {
|
||||
scaled: boolean;
|
||||
}
|
||||
|
||||
const PAGE_FIT_EPSILON_PX = 1;
|
||||
|
||||
export function parseSvgViewBox(
|
||||
value: string | null
|
||||
): MermaidDimensions | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const values = value
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number(part));
|
||||
const [, , width = Number.NaN, height = Number.NaN] = values;
|
||||
|
||||
if (
|
||||
values.length !== 4 ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function getPageContentDimensions(
|
||||
config: ExportConfig
|
||||
): MermaidDimensions {
|
||||
const paper = getPaperDimensionsMm(
|
||||
config.paper.format,
|
||||
config.paper.orientation
|
||||
);
|
||||
const width =
|
||||
paper.width -
|
||||
lengthToMillimeters(config.paper.margins.left) -
|
||||
lengthToMillimeters(config.paper.margins.right);
|
||||
const height =
|
||||
paper.height -
|
||||
lengthToMillimeters(config.paper.margins.top) -
|
||||
lengthToMillimeters(config.paper.margins.bottom);
|
||||
|
||||
return {
|
||||
width: millimetersToCssPixels(width),
|
||||
height: millimetersToCssPixels(height)
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateMermaidPageFit(
|
||||
intrinsic: MermaidDimensions,
|
||||
pageContent: MermaidDimensions
|
||||
): MermaidPageFit {
|
||||
const heightAfterWidthFit =
|
||||
(pageContent.width * intrinsic.height) / intrinsic.width;
|
||||
|
||||
if (heightAfterWidthFit <= pageContent.height) {
|
||||
return {
|
||||
width: pageContent.width,
|
||||
height: heightAfterWidthFit,
|
||||
scaled: false
|
||||
};
|
||||
}
|
||||
|
||||
const height = Math.max(
|
||||
0,
|
||||
pageContent.height - PAGE_FIT_EPSILON_PX
|
||||
);
|
||||
return {
|
||||
width: (height * intrinsic.width) / intrinsic.height,
|
||||
height,
|
||||
scaled: true
|
||||
};
|
||||
}
|
||||
export type MermaidDimensions = DiagramDimensions;
|
||||
export type MermaidPageFit = DiagramPageFit;
|
||||
export {
|
||||
getPageContentDimensions,
|
||||
parseSvgViewBox
|
||||
};
|
||||
export const calculateMermaidPageFit = calculateDiagramPageFit;
|
||||
|
||||
export function fitOversizedMermaidToPage(
|
||||
container: ParentNode,
|
||||
config: ExportConfig
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(config);
|
||||
const diagrams = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".mermaid")
|
||||
);
|
||||
|
||||
for (const diagram of diagrams) {
|
||||
const svg = diagram.querySelector<SVGSVGElement>("svg");
|
||||
const intrinsic = parseSvgViewBox(svg?.getAttribute("viewBox") ?? null);
|
||||
if (!svg || !intrinsic) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fit = calculateMermaidPageFit(intrinsic, pageContent);
|
||||
if (!fit.scaled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
diagram.dataset.pageHeightFitted = "true";
|
||||
diagram.style.setProperty("margin-block", "0", "important");
|
||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
svg.style.setProperty("display", "block");
|
||||
svg.style.setProperty("width", `${fit.width}px`, "important");
|
||||
svg.style.setProperty("height", `${fit.height}px`, "important");
|
||||
svg.style.setProperty("max-width", "100%", "important");
|
||||
svg.style.setProperty("max-height", `${fit.height}px`, "important");
|
||||
}
|
||||
fitSvgDiagramsToPage(container, config, {
|
||||
containerSelector: ".mermaid"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||
import katexCss from "katex/dist/katex.min.css?inline";
|
||||
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
||||
import type {
|
||||
PagedDocumentRenderResult,
|
||||
PagedDocumentTimings
|
||||
} from "@md-to-pdf/core";
|
||||
import { Previewer } from "pagedjs";
|
||||
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
||||
import {
|
||||
fitOversizedMermaidToPage,
|
||||
@@ -82,6 +84,7 @@ function mountMeasurementContainer(
|
||||
documentBaseCss,
|
||||
highlightCss,
|
||||
katexCss,
|
||||
echartsCss,
|
||||
themeCss,
|
||||
documentGeometryCss
|
||||
].join("\n");
|
||||
@@ -159,6 +162,11 @@ export class PagedDocumentRuntime {
|
||||
private mermaidPromise:
|
||||
| Promise<(typeof import("mermaid"))["default"]>
|
||||
| undefined;
|
||||
private echartsPromise:
|
||||
| Promise<
|
||||
typeof import("@md-to-pdf/markdown-echarts/browser")
|
||||
>
|
||||
| undefined;
|
||||
|
||||
constructor(private readonly root: HTMLElement) {}
|
||||
|
||||
@@ -188,6 +196,42 @@ export class PagedDocumentRuntime {
|
||||
return this.mermaidPromise;
|
||||
}
|
||||
|
||||
private loadECharts() {
|
||||
if (!this.echartsPromise) {
|
||||
this.echartsPromise = import(
|
||||
"@md-to-pdf/markdown-echarts/browser"
|
||||
);
|
||||
}
|
||||
return this.echartsPromise;
|
||||
}
|
||||
|
||||
private async renderECharts(
|
||||
container: ParentNode,
|
||||
payload: PagedPreviewPayload
|
||||
) {
|
||||
if (!payload.features.includes("echarts")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { renderEChartsBlocks } = await this.loadECharts();
|
||||
const outcomes = await renderEChartsBlocks(container, {
|
||||
outputMode: "inline-svg",
|
||||
animation: false,
|
||||
concurrency: 2,
|
||||
timeoutMs: 5_000
|
||||
});
|
||||
|
||||
return outcomes.flatMap((outcome, index) =>
|
||||
outcome.success
|
||||
? []
|
||||
: [
|
||||
`图表 ${index + 1}:${
|
||||
outcome.error?.message ?? "未知错误"
|
||||
}`
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private async renderMermaid(
|
||||
container: DocumentFragment,
|
||||
payload: PagedPreviewPayload
|
||||
@@ -276,9 +320,15 @@ export class PagedDocumentRuntime {
|
||||
const mermaidErrors = await this.renderMermaid(content, payload);
|
||||
const mermaidMs = performance.now() - mermaidStartedAt;
|
||||
|
||||
let echartsErrors: string[] = [];
|
||||
let echartsMs = 0;
|
||||
let echartsFitMs = 0;
|
||||
let mermaidFitMs = 0;
|
||||
let mermaidConversionMs = 0;
|
||||
if (content.querySelector(".mermaid svg")) {
|
||||
if (
|
||||
payload.features.includes("echarts") ||
|
||||
content.querySelector(".mermaid svg")
|
||||
) {
|
||||
const measurement = mountMeasurementContainer(
|
||||
documentRef,
|
||||
content,
|
||||
@@ -287,8 +337,23 @@ export class PagedDocumentRuntime {
|
||||
);
|
||||
try {
|
||||
await documentRef.fonts.ready;
|
||||
|
||||
const echartsStartedAt = performance.now();
|
||||
echartsErrors = await this.renderECharts(
|
||||
measurement.host,
|
||||
payload
|
||||
);
|
||||
echartsMs = performance.now() - echartsStartedAt;
|
||||
await waitForImages(measurement.host);
|
||||
|
||||
const echartsFitStartedAt = performance.now();
|
||||
fitOversizedEChartsToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
echartsFitMs =
|
||||
performance.now() - echartsFitStartedAt;
|
||||
|
||||
const mermaidFitStartedAt = performance.now();
|
||||
fitOversizedMermaidToPage(
|
||||
measurement.host,
|
||||
@@ -326,6 +391,7 @@ export class PagedDocumentRuntime {
|
||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
||||
stylesheet(documentRef, highlightCss, "highlight"),
|
||||
stylesheet(documentRef, katexCss, "katex"),
|
||||
stylesheet(documentRef, echartsCss, "echarts"),
|
||||
stylesheet(documentRef, payload.themeCss, "theme"),
|
||||
stylesheet(
|
||||
documentRef,
|
||||
@@ -355,9 +421,12 @@ export class PagedDocumentRuntime {
|
||||
|
||||
return {
|
||||
pageCount: flow.total,
|
||||
echartsErrors,
|
||||
mermaidErrors,
|
||||
timings: {
|
||||
setupMs,
|
||||
echartsMs,
|
||||
echartsFitMs,
|
||||
mermaidMs,
|
||||
mermaidFitMs,
|
||||
mermaidConversionMs,
|
||||
|
||||
@@ -62,6 +62,7 @@ async function renderPagedPreview(
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendered",
|
||||
requestId,
|
||||
contentHeight: Math.ceil(document.documentElement.scrollHeight),
|
||||
...result
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
type MarkdownDocumentMetadata,
|
||||
type PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
|
||||
|
||||
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
||||
|
||||
export type PreviewMetadata = MarkdownDocumentMetadata;
|
||||
@@ -33,6 +31,8 @@ export type PagedPreviewFrameMessage =
|
||||
type: "rendered";
|
||||
requestId: number;
|
||||
pageCount: number;
|
||||
contentHeight: number;
|
||||
echartsErrors: string[];
|
||||
mermaidErrors: string[];
|
||||
}
|
||||
| {
|
||||
@@ -92,6 +92,11 @@ svg {
|
||||
background: #fef2f2;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.md-echarts {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
`;
|
||||
|
||||
export const documentGeometryCss = `
|
||||
@@ -293,7 +298,8 @@ ${buildFooterCss(config)}
|
||||
#write pre,
|
||||
#write blockquote,
|
||||
#write .katex-display,
|
||||
#write .mermaid {
|
||||
#write .mermaid,
|
||||
#write .md-echarts {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
@@ -329,16 +335,7 @@ ${
|
||||
html[data-render-target="preview"] {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
html[data-render-target="preview"]::-webkit-scrollbar {
|
||||
width: ${PREVIEW_SCROLLBAR_WIDTH_PX}px;
|
||||
}
|
||||
|
||||
html[data-render-target="preview"]::-webkit-scrollbar:horizontal {
|
||||
height: 0;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
html[data-render-target="preview"] body {
|
||||
@@ -413,10 +410,26 @@ export function isPagedPreviewFrameMessage(
|
||||
}
|
||||
|
||||
const candidate = value as Partial<PagedPreviewFrameMessage>;
|
||||
return (
|
||||
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
|
||||
["ready", "rendering", "rendered", "error"].includes(
|
||||
if (
|
||||
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
|
||||
!["ready", "rendering", "rendered", "error"].includes(
|
||||
candidate.type ?? ""
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.type !== "rendered") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
Number.isInteger(candidate.requestId) &&
|
||||
Number.isInteger(candidate.pageCount) &&
|
||||
typeof candidate.contentHeight === "number" &&
|
||||
Number.isFinite(candidate.contentHeight) &&
|
||||
candidate.contentHeight > 0 &&
|
||||
Array.isArray(candidate.echartsErrors) &&
|
||||
Array.isArray(candidate.mermaidErrors)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PdfExportResult {
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
pageCount: number | undefined;
|
||||
echartsErrorCount: number;
|
||||
mermaidErrorCount: number;
|
||||
}
|
||||
|
||||
@@ -117,6 +118,10 @@ export async function requestPdfExport(
|
||||
pageCount: parseNumericHeader(
|
||||
response.headers.get("x-pdf-page-count")
|
||||
),
|
||||
echartsErrorCount:
|
||||
parseNumericHeader(
|
||||
response.headers.get("x-echarts-error-count")
|
||||
) ?? 0,
|
||||
mermaidErrorCount:
|
||||
parseNumericHeader(
|
||||
response.headers.get("x-mermaid-error-count")
|
||||
|
||||
@@ -3,7 +3,50 @@ export interface PageVerticalRange {
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export const PREVIEW_SCROLLBAR_WIDTH_PX = 14;
|
||||
export function getPreviewPageScrollTop(
|
||||
currentScrollTop: number,
|
||||
pageViewportTop: number,
|
||||
scrollerViewportTop: number
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(currentScrollTop) ||
|
||||
!Number.isFinite(pageViewportTop) ||
|
||||
!Number.isFinite(scrollerViewportTop)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
currentScrollTop + pageViewportTop - scrollerViewportTop
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePreviewPageNumber(
|
||||
value: string,
|
||||
current: number,
|
||||
total: number
|
||||
) {
|
||||
if (!Number.isInteger(total) || total < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim();
|
||||
const fallback = Number.isInteger(current)
|
||||
? Math.min(Math.max(current, 1), total)
|
||||
: 1;
|
||||
|
||||
if (normalizedValue.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(normalizedValue);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(parsed, 1), total);
|
||||
}
|
||||
|
||||
export function getNearestPageNumber(
|
||||
viewportCenter: number,
|
||||
|
||||
+173
-10
@@ -63,6 +63,39 @@ button:disabled {
|
||||
background: rgb(247 248 246 / 92%);
|
||||
}
|
||||
|
||||
.topbar-primary {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.document-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
max-width: min(42vw, 520px);
|
||||
padding-left: 28px;
|
||||
border-left: 1px solid #d4dad7;
|
||||
}
|
||||
|
||||
.document-summary > strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-summary .render-status {
|
||||
max-width: 100%;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: #3b755d;
|
||||
@@ -82,6 +115,7 @@ h1 {
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -104,6 +138,11 @@ h1 {
|
||||
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
transition: grid-template-columns 180ms ease;
|
||||
}
|
||||
|
||||
.editor-layout.is-source-collapsed {
|
||||
grid-template-columns: 48px minmax(520px, 1fr);
|
||||
}
|
||||
|
||||
.editor-panel,
|
||||
@@ -119,6 +158,45 @@ h1 {
|
||||
background: #f6f7f5;
|
||||
}
|
||||
|
||||
.source-panel-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.source-panel-content[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.source-panel-toggle {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border-color: #c9d4ce;
|
||||
background: #f8fbf9;
|
||||
color: #4f695e;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.source-panel-restore {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 14px 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #f0f3f1;
|
||||
color: #4f695e;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.source-panel-restore:hover {
|
||||
background: #e4ebe7;
|
||||
color: #356c55;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -181,12 +259,43 @@ h1 {
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 20px;
|
||||
border-bottom: 1px solid #d9dedb;
|
||||
background: rgb(255 255 255 / 58%);
|
||||
}
|
||||
|
||||
.preview-page-control {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #5d7168;
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-page-control input {
|
||||
width: 48px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #c9d4ce;
|
||||
border-radius: 7px;
|
||||
outline: 0;
|
||||
background: #fff;
|
||||
color: #356c55;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-page-control input:focus {
|
||||
border-color: #74a88f;
|
||||
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
|
||||
}
|
||||
|
||||
.preview-zoom-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -248,14 +357,6 @@ h1 {
|
||||
color: #9ba49f;
|
||||
}
|
||||
|
||||
.preview-page-indicator {
|
||||
flex: none;
|
||||
color: #5d7168;
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-mode-control {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
@@ -362,6 +463,28 @@ textarea:focus {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 30px;
|
||||
scrollbar-color: #8d9993 #dfe4e1;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
.preview-scroll::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.preview-scroll::-webkit-scrollbar-track {
|
||||
background: #dfe4e1;
|
||||
}
|
||||
|
||||
.preview-scroll::-webkit-scrollbar-thumb {
|
||||
min-height: 48px;
|
||||
border: 3px solid #dfe4e1;
|
||||
border-radius: 999px;
|
||||
background: #8d9993;
|
||||
}
|
||||
|
||||
.preview-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #6f7d76;
|
||||
}
|
||||
|
||||
.paper {
|
||||
@@ -808,6 +931,21 @@ textarea:focus {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar-primary {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.document-summary {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding-top: 10px;
|
||||
padding-left: 0;
|
||||
border-top: 1px solid #d4dad7;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -816,6 +954,10 @@ textarea:focus {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.editor-layout.is-source-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.editor-panel,
|
||||
.preview-panel {
|
||||
min-height: 650px;
|
||||
@@ -830,6 +972,19 @@ textarea:focus {
|
||||
border-bottom: 1px solid #cbd2ce;
|
||||
}
|
||||
|
||||
.editor-layout.is-source-collapsed .editor-panel {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.source-panel-restore {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
letter-spacing: 0;
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
.preview-scroll {
|
||||
padding: 0 10px;
|
||||
}
|
||||
@@ -847,11 +1002,13 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.preview-toolbar {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.preview-zoom-control {
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -865,6 +1022,12 @@ textarea:focus {
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.editor-layout {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.settings-drawer {
|
||||
width: 100vw;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ COPY package.json package-lock.json ./
|
||||
COPY apps/server/package.json apps/server/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||
COPY packages/renderer/package.json packages/renderer/package.json
|
||||
RUN npm ci
|
||||
|
||||
@@ -26,6 +27,7 @@ COPY package.json package-lock.json ./
|
||||
COPY apps/server/package.json apps/server/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||
COPY packages/renderer/package.json packages/renderer/package.json
|
||||
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
|
||||
|
||||
@@ -47,6 +49,7 @@ COPY --chown=pwuser:pwuser package.json package-lock.json ./
|
||||
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
|
||||
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
|
||||
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
|
||||
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||
COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json
|
||||
COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
@@ -56,6 +59,7 @@ RUN npx playwright install --with-deps chromium \
|
||||
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
|
||||
COPY --chown=pwuser:pwuser themes ./themes
|
||||
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
image: md-to-pdf:local
|
||||
image: "${MD_TO_PDF_IMAGE:-md-to-pdf:local}"
|
||||
init: true
|
||||
ports:
|
||||
- "${MD_TO_PDF_PORT:-8080}:8080"
|
||||
|
||||
+87
-6
@@ -31,8 +31,8 @@ fa07472 feat: 实现网页实时预览
|
||||
ec48bce chore: 初始化项目骨架
|
||||
```
|
||||
|
||||
最新阶段已完成渲染边界逻辑修复,以及前后端分页载荷、文档类型、
|
||||
物理单位、主题资源加载和 Markdown 请求逻辑的复用收敛。
|
||||
最新阶段已完成 Markdown ECharts YAML 围栏、统一 SVG 渲染与分页,
|
||||
并完善默认示例、源文本收起、顶栏文档状态和快速页码跳转。
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
@@ -248,6 +248,52 @@ ec48bce chore: 初始化项目骨架
|
||||
- 后续分页回归应覆盖 Windows 100%、125% 和 150% 系统显示缩放,分别
|
||||
记录快速预览、精确预览和最终 PDF 的页数及分页边界。
|
||||
|
||||
### 3.9 Markdown ECharts 图表
|
||||
|
||||
- 新增可独立复用的 `packages/markdown-echarts` 工作区,为 Markdown-it
|
||||
提供安全的 `echarts` YAML 围栏。
|
||||
- 围栏支持版本、固定高度或宽高比、主题、图注和 ECharts `option`;
|
||||
高度默认 80mm,主题默认跟随文档。
|
||||
- 当前支持 line、bar、scatter、pie、radar、heatmap、boxplot 和
|
||||
candlestick 系列,并校验各系列的数据结构和必要坐标系。
|
||||
- 每个系列必须使用非空 `series[].data`、`dataset.source` 或具有有效
|
||||
上游数据的内置 transform;缺少数据等错误会显示包含配置路径的局部
|
||||
占位,不中断整篇 Markdown。
|
||||
- YAML 会转换为安全纯数据对象;禁止函数、自定义标签、锚点和别名、
|
||||
外部 URL、HTML formatter、原型污染属性及 `custom` 系列。
|
||||
- 浏览器按需加载 ECharts 模块,以 SVG 渲染,并可冻结为内联 SVG 或
|
||||
SVG Data URL 图片;快速预览与 PDF 使用同一套运行时。
|
||||
- 分页遵循 Mermaid 的不可拆分规则:当前页空间足够时原位放置,不足时
|
||||
移到下一页,超高图表等比例缩放到单页内,不跨页切割。
|
||||
- PDF 输出等待 ECharts、Mermaid、字体和图片全部完成后再分页,并分别
|
||||
返回 ECharts 与 Mermaid 错误数量。
|
||||
- 包内 README 已记录围栏语法、默认值、验证规则、错误行为、安全边界、
|
||||
Markdown-it 接入和浏览器渲染方式。
|
||||
|
||||
### 3.10 工作区布局与页码导航
|
||||
|
||||
- 默认 Markdown 示例新增 ECharts 柱状图,打开网页即可验证 YAML 围栏、
|
||||
SVG 渲染和分页。
|
||||
- 文件名及渲染、分页、导出错误状态从源文本标题栏迁移到顶部应用标题
|
||||
旁,长文件名支持省略和完整悬停提示。
|
||||
- 源文本区域支持收起和展开;桌面端收起为 48px 左侧窄栏,预览区扩展
|
||||
到剩余宽度并保持纸张居中,窄屏收起为 44px 顶部横条。
|
||||
- 折叠内容使用真实 `display:none`,不会残留可聚焦文本框或进入无障碍
|
||||
树;按钮提供 `aria-controls` 和 `aria-expanded`。
|
||||
- 当前页从预览标题行移到缩放工具栏左侧,显示为
|
||||
`第 [n] / 总页数 页`,避免重复信息。
|
||||
- 页码输入支持 Enter 和失焦提交,自动限制到 1~总页数;空值和非法
|
||||
小数恢复当前页。
|
||||
- 快速预览直接定位 iframe 内 `.pagedjs_page`;精确预览定位
|
||||
`.precise-pdf-page`。滚动预览时输入框同步当前页。
|
||||
- 精确预览会动态识别桌面内部滚动容器或窄屏主窗口滚动容器,目标页顶端
|
||||
与实际可视滚动区域对齐。
|
||||
- 快速预览 iframe 只负责生成分页内容并回传自然高度,滚动统一交给外层
|
||||
`.preview-scroll`;滚动条因此与精确预览一样贴在右侧预览容器边缘,
|
||||
不再贴着居中的纸张右边缘。
|
||||
- 快速预览的页码识别和跳转会合并外层滚动位置、iframe 位置、内部分页
|
||||
位置与显示缩放比例,50%~400% 视觉缩放不会破坏页码同步。
|
||||
|
||||
## 4. 已执行验证
|
||||
|
||||
2026-07-27 在当前完整工作区成功执行:
|
||||
@@ -261,9 +307,10 @@ git diff --check
|
||||
|
||||
结果:
|
||||
|
||||
- ECharts 围栏测试:27 项通过;
|
||||
- 共享配置测试:9 项通过;
|
||||
- 渲染器测试:8 项通过;
|
||||
- 前端测试:55 项通过;
|
||||
- 渲染器测试:10 项通过;
|
||||
- 前端测试:63 项通过;
|
||||
- 后端测试:29 项通过;
|
||||
- 全项目类型检查通过;
|
||||
- 生产构建通过;
|
||||
@@ -271,7 +318,26 @@ git diff --check
|
||||
|
||||
已使用浏览器插件完成视觉验证:
|
||||
|
||||
- 重构后的初始 Markdown 和主题资源加载正常,快速预览生成 1 页;
|
||||
- 默认示例中的 ECharts 柱状图生成 1 个内联 SVG,无错误占位;
|
||||
- 顶栏文件名、分页状态与操作按钮在桌面和窄屏均无重叠;
|
||||
- 桌面端源文本收起后宽度从 576px 降为 48px,预览区从 864px 扩展
|
||||
到 1392px;窄屏折叠条为 44px;
|
||||
- 快速预览输入第 2 页后目标页顶端对齐 iframe 视口,滚动回首页和第二页
|
||||
时输入框分别同步为 1 和 2;
|
||||
- 页码输入 0 和 99 时分别限制到 1 和总页数;
|
||||
- 精确预览输入第 2 页后,目标页与桌面滚动容器顶部误差为 0;
|
||||
- 快速预览改用外层滚动容器后,右侧容器边界为 1440px,纸张边界约为
|
||||
1400px,滚动条稳定贴在容器最右侧而非纸张边缘;
|
||||
- 快速预览输入第 1 页后目标页与外层可视区域顶部误差为 0,外层滚动到
|
||||
第 2 页时页码输入框同步更新为 2;
|
||||
- 快速预览在 50% 和 150% 下的可滚动高度分别随内容显示高度缩放,
|
||||
iframe 自身保持无滚动;切换精确/快速模式后仍使用同一右侧滚动条;
|
||||
- 桌面端收起源文本后,预览滚动容器从 48px 延伸至窗口右边缘,纸张仍
|
||||
在扩展后的预览区域内居中;
|
||||
- 窄屏精确预览自动使用主窗口滚动,并正确跳转到第 2 页;
|
||||
- 页码控件在窄屏独占首行,缩放控件在下一行铺满,没有与预览模式或主题
|
||||
选择器重叠;
|
||||
- 当前默认 Markdown 和主题资源加载正常,快速预览生成 2 页;
|
||||
- 编辑 Markdown 后标题、表格、Mermaid 和分页状态均完成防抖重绘;
|
||||
- 从本地 GitHub 主题切换到内置 Typora 风格主题后 iframe 正常重建;
|
||||
- 本轮浏览器回归期间控制台无警告或错误;
|
||||
@@ -290,6 +356,17 @@ git diff --check
|
||||
- Mermaid A/B PDF 页数、逐页文字和抽样视觉结果一致;
|
||||
- 抽查第 2、6、27、53、65 页,Mermaid 尺寸、分页位置和末页内容正常。
|
||||
|
||||
2026-07-27 已重新构建并启动本地发布候选镜像:
|
||||
|
||||
- 使用完整多阶段 `deploy/Dockerfile` 构建
|
||||
`yixiong/md-to-pdf:v0.3.0`,镜像大小约 612 MB;
|
||||
- Compose 强制重建后容器健康检查通过,服务继续监听
|
||||
`http://localhost:8080`;
|
||||
- 首页返回 200,`POST /api/render` 的 ECharts 冒烟请求返回
|
||||
`code`、`echarts` 功能标识和 `md-echarts` 安全占位;
|
||||
- 容器内 `@md-to-pdf/markdown-echarts` 工作区包可正常加载;
|
||||
- 当前镜像已通过发布前手动验收,并作为 `v0.3.0` 发布镜像保留运行。
|
||||
|
||||
此前主题阶段已使用浏览器插件完成视觉验证:
|
||||
|
||||
- 导入并打开 `tmp/数据中台项目周报_2026_W30.md`;
|
||||
@@ -332,7 +409,11 @@ git diff --check
|
||||
- `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。
|
||||
- `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理。
|
||||
- `apps/web/src/PrecisePdfPreview.tsx` 和 `pdf-render-queue.ts` 负责 PDF.js 精确预览和 Canvas 生命周期。
|
||||
- `apps/web/src/PreviewPageControl.tsx` 和 `preview-page.ts` 负责当前页输入、
|
||||
页码边界、滚动页识别和快速跳转。
|
||||
- `apps/web/src/preview-zoom.ts` 和 `PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
|
||||
- `packages/markdown-echarts` 负责 ECharts YAML 协议、验证、安全过滤、
|
||||
Markdown-it 插件、浏览器 SVG 渲染和分页样式。
|
||||
- `apps/server/src/pdf-engine.ts` 负责 Chromium 生命周期、并发、网络限制、分页调用和 PDF 输出。
|
||||
- `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。
|
||||
- `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。
|
||||
@@ -369,7 +450,7 @@ git diff --check
|
||||
## 7. 首版验收目标
|
||||
|
||||
- 可以选择或粘贴 Markdown;
|
||||
- 网页正确预览常用 Markdown、公式和 Mermaid;
|
||||
- 网页正确预览常用 Markdown、公式、Mermaid 和 ECharts;
|
||||
- 可以下载真实 PDF;
|
||||
- PDF 文本可搜索;
|
||||
- 预览与 PDF 基本一致;
|
||||
|
||||
Generated
+157
@@ -39,6 +39,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||
"@mermaid-js/layout-elk": "0.2.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
@@ -1024,6 +1025,10 @@
|
||||
"resolved": "packages/core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@md-to-pdf/markdown-echarts": {
|
||||
"resolved": "packages/markdown-echarts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@md-to-pdf/renderer": {
|
||||
"resolved": "packages/renderer",
|
||||
"link": true
|
||||
@@ -2187,6 +2192,23 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@upsetjs/venn.js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
|
||||
@@ -2492,6 +2514,19 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-image-size": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
|
||||
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
@@ -3340,6 +3375,24 @@
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.396",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz",
|
||||
@@ -3793,6 +3846,25 @@
|
||||
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "20.11.1",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz",
|
||||
"integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"buffer-image-size": "^0.6.4",
|
||||
"entities": "^7.0.1",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -4173,6 +4245,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
|
||||
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
@@ -5373,6 +5446,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -5408,6 +5491,28 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
@@ -5425,6 +5530,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
@@ -5463,6 +5583,23 @@
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@md-to-pdf/core",
|
||||
"version": "0.1.0",
|
||||
@@ -5473,11 +5610,31 @@
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"packages/markdown-echarts": {
|
||||
"name": "@md-to-pdf/markdown-echarts",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"echarts": "6.1.0",
|
||||
"happy-dom": "^20.11.1",
|
||||
"markdown-it": "^14.3.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"echarts": "^6.1.0",
|
||||
"markdown-it": "^14.3.0"
|
||||
}
|
||||
},
|
||||
"packages/renderer": {
|
||||
"name": "@md-to-pdf/renderer",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||
"@mdit/plugin-anchor": "^1.1.1",
|
||||
"@mdit/plugin-footnote": "^1.0.1",
|
||||
"@mdit/plugin-katex": "^1.0.1",
|
||||
|
||||
+5
-4
@@ -8,15 +8,16 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server",
|
||||
"dev": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && concurrently -k -n core,renderer,server,web \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"build": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server",
|
||||
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && concurrently -k -n markdown-echarts,core,renderer,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
|
||||
"dev:core": "npm run dev -w @md-to-pdf/core",
|
||||
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
|
||||
"dev:server": "npm run dev -w @md-to-pdf/server",
|
||||
"dev:web": "npm run dev -w @md-to-pdf/web",
|
||||
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
||||
"test": "npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server",
|
||||
"typecheck": "npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server"
|
||||
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server",
|
||||
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
@@ -52,6 +52,8 @@ export function createPagedDocumentPayload({
|
||||
|
||||
export interface PagedDocumentTimings {
|
||||
setupMs: number;
|
||||
echartsMs: number;
|
||||
echartsFitMs: number;
|
||||
mermaidMs: number;
|
||||
mermaidFitMs: number;
|
||||
mermaidConversionMs: number;
|
||||
@@ -63,6 +65,7 @@ export interface PagedDocumentTimings {
|
||||
|
||||
export interface PagedDocumentRenderResult {
|
||||
pageCount: number;
|
||||
echartsErrors: string[];
|
||||
mermaidErrors: string[];
|
||||
timings: PagedDocumentTimings;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ export const themeFeatureSchema = z.enum([
|
||||
"task-list",
|
||||
"footnote",
|
||||
"katex",
|
||||
"mermaid"
|
||||
"mermaid",
|
||||
"echarts"
|
||||
]);
|
||||
|
||||
export const themeManifestSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# @md-to-pdf/markdown-echarts
|
||||
|
||||
为 Markdown 增加安全、可验证、可复用的 ECharts YAML 围栏,
|
||||
并在浏览器中渲染为 SVG。
|
||||
|
||||
该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的
|
||||
HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。
|
||||
|
||||
## 基本语法
|
||||
|
||||
````markdown
|
||||
```echarts
|
||||
height: 80mm
|
||||
caption: 年度收入
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [2023, 2024]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: bar
|
||||
data: [120, 180]
|
||||
```
|
||||
````
|
||||
|
||||
围栏内容必须使用 YAML,并包含一个 ECharts `option` 对象。YAML
|
||||
会先转换为安全的纯数据对象,再交给 ECharts;不能使用函数、
|
||||
YAML 锚点、别名或自定义标签。
|
||||
|
||||
## 围栏字段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `version` | `1` | `1` | 围栏协议版本 |
|
||||
| `height` | CSS 长度 | `80mm` | 支持 `mm`、`cm`、`in`、`px` |
|
||||
| `aspectRatio` | 正数 | 无 | 与 `height` 二选一;指定后不补默认高度 |
|
||||
| `theme` | `document`、`light`、`dark` | `document` | ECharts 主题 |
|
||||
| `caption` | 字符串 | 无 | 图表标题说明 |
|
||||
| `option` | 对象 | 必填 | 经过安全和语义验证的 ECharts 配置 |
|
||||
|
||||
## 数据来源
|
||||
|
||||
每个系列必须从以下任一位置获得有效数据:
|
||||
|
||||
- 非空的 `series[].data`;
|
||||
- 非空的 `dataset.source`;
|
||||
- 引用具有有效上游数据源的内置 `filter` 或 `sort` transform。
|
||||
|
||||
只有表头的二维数组、所有列均为空的对象以及空数组不属于有效
|
||||
数据。使用 `dataset` 时保留 ECharts 的默认维度映射,也可以通过
|
||||
`series.encode` 显式声明映射。
|
||||
|
||||
```yaml
|
||||
option:
|
||||
dataset:
|
||||
source:
|
||||
- [year, sales]
|
||||
- [2025, 120]
|
||||
- [2026, 180]
|
||||
series:
|
||||
- type: line
|
||||
encode:
|
||||
x: year
|
||||
y: sales
|
||||
```
|
||||
|
||||
## 系列验证规则
|
||||
|
||||
当前首版支持以下系列:
|
||||
|
||||
| 系列 | 主要规则 |
|
||||
| --- | --- |
|
||||
| `line` | 需要有效数据;支持直角坐标系和极坐标系 |
|
||||
| `bar` | 需要有效数据;支持直角坐标系和极坐标系 |
|
||||
| `scatter` | 直接数据至少包含两个坐标值 |
|
||||
| `pie` | 直接数据项的 `value` 必须是有限数值 |
|
||||
| `radar` | 必须提供非空 `radar.indicator`;数据维数不得少于指标数 |
|
||||
| `heatmap` | 直角坐标数据为 `[x, y, value]`;日历数据为 `[date, value]` |
|
||||
| `boxplot` | 直接数据为 `[min, Q1, median, Q3, max]` |
|
||||
| `candlestick` | 直接数据为 `[open, close, lowest, highest]` |
|
||||
|
||||
直角坐标系列缺少 `xAxis` 或 `yAxis` 时会补充空对象,让 ECharts
|
||||
使用自身的轴默认值。极坐标、雷达和日历坐标系表达了明确语义,
|
||||
因此不会猜测配置,缺少对应组件时会报告错误。
|
||||
|
||||
使用 `dataset` 时,验证器只验证数据源是否存在,不重复实现
|
||||
ECharts 的维度推断和 `encode` 映射规则。
|
||||
|
||||
## 错误行为
|
||||
|
||||
配置错误不会中断整篇 Markdown。对应围栏会变成局部错误占位:
|
||||
|
||||
```html
|
||||
<div class="md-echarts-error" role="alert">
|
||||
ECharts 图表配置无效:option.series.0.data 必须是非空数组……
|
||||
</div>
|
||||
```
|
||||
|
||||
错误消息包含配置路径,便于用户或 AI Agent 定位并修正 YAML。
|
||||
宿主还可以通过 `onError` 收集结构化错误。
|
||||
|
||||
```ts
|
||||
markdown.use(markdownItECharts, {
|
||||
onError(error, context) {
|
||||
console.warn(error.code, error.message, context.tokenIndex);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Markdown-it 接入
|
||||
|
||||
```ts
|
||||
import MarkdownIt from "markdown-it";
|
||||
import {
|
||||
markdownItECharts
|
||||
} from "@md-to-pdf/markdown-echarts";
|
||||
|
||||
const markdown = new MarkdownIt().use(markdownItECharts);
|
||||
const html = markdown.render(source);
|
||||
```
|
||||
|
||||
如果宿主还会对 HTML 做安全过滤,需要保留包生成的 `figure`、
|
||||
`figcaption`、隐藏配置节点及 `data-echarts-*` 属性。
|
||||
|
||||
## 浏览器渲染
|
||||
|
||||
```ts
|
||||
import {
|
||||
renderEChartsBlocks
|
||||
} from "@md-to-pdf/markdown-echarts/browser";
|
||||
import "@md-to-pdf/markdown-echarts/styles.css";
|
||||
|
||||
const outcomes = await renderEChartsBlocks(document, {
|
||||
outputMode: "inline-svg",
|
||||
animation: false,
|
||||
concurrency: 2,
|
||||
timeoutMs: 5000
|
||||
});
|
||||
```
|
||||
|
||||
输出模式:
|
||||
|
||||
- `interactive`:保留 ECharts 实例和响应式尺寸监听;
|
||||
- `inline-svg`:冻结为内联 SVG,适合打印和 PDF;
|
||||
- `svg-image`:冻结为 SVG Data URL 图片。
|
||||
|
||||
PDF 输出前应等待所有图表、字体和图片完成,再开始分页或调用
|
||||
Chromium `page.pdf()`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
默认策略会拒绝:
|
||||
|
||||
- `custom` 系列及 `renderItem`;
|
||||
- JavaScript、HTTP、文件和 Data URL 外部资源;
|
||||
- HTML formatter;
|
||||
- 原型污染属性;
|
||||
- 未允许的 toolbox 功能;
|
||||
- 非内置数据转换;
|
||||
- 超过节点数、深度、字符串、数组和系列数量上限的配置。
|
||||
|
||||
这些限制适用于不受信任的 Markdown。宿主如需扩大能力,应在明确
|
||||
风险后扩展验证器,而不是直接执行 YAML 中的 JavaScript。
|
||||
|
||||
## 扩展系列验证
|
||||
|
||||
各系列规则注册在 `echartsSeriesValidators`。新增图形时需要同步:
|
||||
|
||||
1. 将系列类型加入协议支持列表;
|
||||
2. 在浏览器引擎中注册对应 ECharts Chart;
|
||||
3. 注册该系列的语义验证器;
|
||||
4. 增加有效、缺少数据、错误数据结构和真实 SVG 渲染测试。
|
||||
|
||||
参考:
|
||||
|
||||
- [ECharts 配置参考](https://echarts.apache.org/en/option.html)
|
||||
- [ECharts Dataset](https://echarts.apache.org/handbook/en/concepts/dataset/)
|
||||
- [ECharts Data Transform](https://echarts.apache.org/handbook/en/concepts/data-transform/)
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@md-to-pdf/markdown-echarts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./browser": {
|
||||
"types": "./dist/browser.d.ts",
|
||||
"import": "./dist/browser.js"
|
||||
},
|
||||
"./styles.css": {
|
||||
"default": "./styles/echarts.css"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"styles"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"echarts": "^6.1.0",
|
||||
"markdown-it": "^14.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"echarts": "6.1.0",
|
||||
"happy-dom": "^20.11.1",
|
||||
"markdown-it": "^14.3.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.md-echarts {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
margin: 1.5em 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.md-echarts-host {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
min-height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.md-echarts-definition[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.md-echarts figcaption {
|
||||
color: #666;
|
||||
font-size: 0.875em;
|
||||
margin-top: 0.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.md-echarts-error {
|
||||
border: 1px solid #dc2626;
|
||||
color: #991b1b;
|
||||
padding: 0.75em 1em;
|
||||
}
|
||||
|
||||
.md-echarts-svg-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"@mdit/plugin-katex": "^1.0.1",
|
||||
"@mdit/plugin-tasklist": "^1.0.1",
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"markdown-it": "^14.3.0",
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
RenderedMarkdownDocument,
|
||||
ThemeFeature
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
markdownItECharts,
|
||||
type MarkdownItEChartsErrorContext
|
||||
} from "@md-to-pdf/markdown-echarts";
|
||||
import matter from "gray-matter";
|
||||
import hljs from "highlight.js";
|
||||
import MarkdownIt from "markdown-it";
|
||||
@@ -110,6 +114,17 @@ markdown.renderer.rules.fence = (
|
||||
return renderFallbackFence(tokens, index, renderer);
|
||||
};
|
||||
|
||||
markdown.use(markdownItECharts, {
|
||||
onError(error, context: MarkdownItEChartsErrorContext) {
|
||||
const environment = context.environment as
|
||||
| { warnings?: string[] }
|
||||
| undefined;
|
||||
environment?.warnings?.push(
|
||||
`ECharts 图表配置无效:${error.message}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const safeHtmlOptions: sanitizeHtml.IOptions = {
|
||||
allowedTags: [
|
||||
...sanitizeHtml.defaults.allowedTags,
|
||||
@@ -130,10 +145,16 @@ const safeHtmlOptions: sanitizeHtml.IOptions = {
|
||||
"*": ["class", "id", "title", "aria-*", "role"],
|
||||
a: ["href", "name", "target", "rel"],
|
||||
div: ["class", "data-mermaid-pending"],
|
||||
figure: [
|
||||
"class",
|
||||
"data-echarts-pending",
|
||||
"data-echarts-protocol"
|
||||
],
|
||||
img: ["src", "alt", "title", "width", "height"],
|
||||
input: ["type", "checked", "disabled", "class"],
|
||||
li: ["class", "value"],
|
||||
ol: ["class", "start"],
|
||||
pre: ["class", "hidden"],
|
||||
span: ["class", "style"],
|
||||
td: ["colspan", "rowspan", "style"],
|
||||
th: ["colspan", "rowspan", "scope", "style"]
|
||||
@@ -262,7 +283,8 @@ function detectFeatures(html: string): ThemeFeature[] {
|
||||
["task-list", /\bmd-task-list-item\b/u],
|
||||
["footnote", /\bfootnote/u],
|
||||
["katex", /\bkatex\b/u],
|
||||
["mermaid", /\bmermaid\b/u]
|
||||
["mermaid", /\bmermaid\b/u],
|
||||
["echarts", /\bmd-echarts\b/u]
|
||||
];
|
||||
|
||||
return featureChecks
|
||||
|
||||
@@ -139,6 +139,65 @@ flowchart LR
|
||||
expect(result.bodyHtml).toContain("class B important");
|
||||
});
|
||||
|
||||
it("将 ECharts YAML 围栏转换为安全占位并识别能力", () => {
|
||||
const result = renderMarkdown(`
|
||||
\`\`\`echarts
|
||||
version: 1
|
||||
height: 80mm
|
||||
caption: 年度收入
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [2023, 2024]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: bar
|
||||
data: [120, 180]
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.bodyHtml).toContain('class="md-echarts"');
|
||||
expect(result.bodyHtml).toContain(
|
||||
'data-echarts-pending="true"'
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'data-echarts-protocol="1"'
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'class="md-echarts-definition" hidden="hidden"'
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
"<figcaption>年度收入</figcaption>"
|
||||
);
|
||||
expect(result.features).toContain("echarts");
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("将无效 ECharts 配置转为局部错误和文档告警", () => {
|
||||
const result = renderMarkdown(`
|
||||
\`\`\`echarts
|
||||
version: 1
|
||||
height: 80mm
|
||||
option:
|
||||
series:
|
||||
- type: custom
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.bodyHtml).toContain(
|
||||
'class="md-echarts-error"'
|
||||
);
|
||||
expect(result.bodyHtml).not.toContain(
|
||||
"data-echarts-pending"
|
||||
);
|
||||
expect(result.features).toContain("echarts");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain(
|
||||
"ECharts 图表配置无效"
|
||||
);
|
||||
});
|
||||
|
||||
it("阻止原始 HTML 和危险链接形成可执行内容", () => {
|
||||
const result = renderMarkdown(`
|
||||
<script>alert("xss")</script>
|
||||
|
||||
Reference in New Issue
Block a user