diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 552cbc6..fc6fb48 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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) diff --git a/apps/server/src/pdf-engine.ts b/apps/server/src/pdf-engine.ts index 8bcc231..d9ef5d7 100644 --- a/apps/server/src/pdf-engine.ts +++ b/apps/server/src/pdf-engine.ts @@ -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: diff --git a/apps/server/tests/app.test.ts b/apps/server/tests/app.test.ts index e9d88af..6631a15 100644 --- a/apps/server/tests/app.test.ts +++ b/apps/server/tests/app.test.ts @@ -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" ); diff --git a/apps/server/tests/pdf-engine.test.ts b/apps/server/tests/pdf-engine.test.ts index b075a11..89860ed 100644 --- a/apps/server/tests/pdf-engine.test.ts +++ b/apps/server/tests/pdf-engine.test.ts @@ -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, diff --git a/apps/web/package.json b/apps/web/package.json index a25a9db..e7367e9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8d1e2a7..900f240 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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(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(); const [previewFrameReady, setPreviewFrameReady] = useState(false); + const [quickPreviewContentHeight, setQuickPreviewContentHeight] = + useState(); 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 handleWindowScroll() { + if (getOuterPreviewScroller() === document.scrollingElement) { + updatePreviewCurrentPage(); + } } - function handlePreviewScroll() { - synchronizeScroll("preview"); - 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(".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( ".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( + ".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( + ".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,19 +653,29 @@ export function App() { } function handlePreviewScroll() { - if (previewModeRef.current !== "precise") { - return; - } synchronizeScroll("preview"); - updatePrecisePreviewCurrentPage(); + updatePreviewCurrentPage(); } return (
-
-

内网文档工具

-

Markdown PDF 导出器

+
+
+

内网文档工具

+

Markdown PDF 导出器

+
+
+ 源文件 + {fileName} + + {error || status} + +
-
+