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

- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染
- 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略
- 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步
- 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放
- 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
This commit is contained in:
SkyJourney
2026-07-27 16:05:14 +08:00
parent 1db21c80fc
commit f5a46f79f1
53 changed files with 3998 additions and 221 deletions
+6
View File
@@ -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)
+8
View File
@@ -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:
+7
View File
@@ -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"
);
+5
View File
@@ -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,
+1
View File
@@ -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",
+228 -63
View File
@@ -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 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<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,19 +653,29 @@ export function App() {
}
function handlePreviewScroll() {
if (previewModeRef.current !== "precise") {
return;
}
synchronizeScroll("preview");
updatePrecisePreviewCurrentPage();
updatePreviewCurrentPage();
}
return (
<main className="workspace">
<header className="topbar">
<div>
<p className="eyebrow"></p>
<h1>Markdown PDF </h1>
<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">
@@ -565,25 +699,52 @@ 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 className="panel-heading">
<div>
<span className="panel-kicker"></span>
<strong>{fileName}</strong>
<div
id="markdown-source-content"
className="source-panel-content"
hidden={sourcePanelCollapsed}
>
<div className="panel-heading">
<div>
<span className="panel-kicker"></span>
<strong>Markdown </strong>
</div>
<button
type="button"
className="source-panel-toggle"
aria-controls="markdown-source-content"
aria-expanded="true"
onClick={() => setSourcePanelCollapsed(true)}
>
</button>
</div>
<span className={`render-status${error ? " is-error" : ""}`}>
{error || status}
</span>
<textarea
ref={editorRef}
aria-label="Markdown 内容"
value={markdown}
spellCheck={false}
onChange={(event) => setMarkdown(event.target.value)}
onScroll={() => synchronizeScroll("editor")}
/>
</div>
<textarea
ref={editorRef}
aria-label="Markdown 内容"
value={markdown}
spellCheck={false}
onChange={(event) => setMarkdown(event.target.value)}
onScroll={() => synchronizeScroll("editor")}
/>
{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}
+62
View File
@@ -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>
);
}
+178
View File
@@ -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);
}
}
+47
View File
@@ -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");
}
});
}
+18 -115
View File
@@ -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"
});
}
+70 -1
View File
@@ -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,
+1
View File
@@ -62,6 +62,7 @@ async function renderPagedPreview(
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId,
contentHeight: Math.ceil(document.documentElement.scrollHeight),
...result
});
} finally {
+29 -16
View File
@@ -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)
);
}
+5
View File
@@ -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")
+44 -1
View File
@@ -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
View File
@@ -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;
+57
View File
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit";
describe("ECharts 单页高度适配", () => {
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
const root = document.createElement("div");
root.innerHTML = `
<figure class="md-echarts">
<div class="md-echarts-host" style="height: 2000px">
<svg width="1000" height="2000"></svg>
</div>
</figure>
`;
fitOversizedEChartsToPage(root, defaultExportConfig);
const figure = root.querySelector<HTMLElement>(".md-echarts");
const host = root.querySelector<HTMLElement>(
".md-echarts-host"
);
const svg = root.querySelector<SVGSVGElement>("svg");
expect(figure?.dataset.pageHeightFitted).toBe("true");
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
1000.5748,
3
);
expect(host?.style.aspectRatio).toBe("");
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
1000.5748,
3
);
});
it("普通高度图表不改变作者设置的宿主高度", () => {
const root = document.createElement("div");
root.innerHTML = `
<figure class="md-echarts">
<div class="md-echarts-host" style="height: 300px">
<svg viewBox="0 0 1000 400"></svg>
</div>
</figure>
`;
fitOversizedEChartsToPage(root, defaultExportConfig);
const figure = root.querySelector<HTMLElement>(".md-echarts");
const host = root.querySelector<HTMLElement>(
".md-echarts-host"
);
expect(figure?.dataset.pageHeightFitted).toBeUndefined();
expect(host?.style.height).toBe("300px");
});
});
+14 -1
View File
@@ -41,7 +41,8 @@ describe("分页预览协议", () => {
expect(css).toContain("padding: 34px 0");
expect(css).toContain('html[data-render-target="preview"]');
expect(css).toContain("overflow-x: hidden");
expect(css).toContain("overflow-y: scroll");
expect(css).toContain("overflow-y: hidden");
expect(css).not.toContain("scrollbar-color");
expect(css).toContain('html[data-render-target="pdf"]');
expect(css).toContain("height: auto !important");
expect(css).toContain("overflow: visible !important");
@@ -178,9 +179,21 @@ describe("分页预览协议", () => {
type: "rendered",
requestId: 3,
pageCount: 2,
contentHeight: 2400,
echartsErrors: [],
mermaidErrors: []
})
).toBe(true);
expect(
isPagedPreviewFrameMessage({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId: 3,
pageCount: 2,
echartsErrors: [],
mermaidErrors: []
})
).toBe(false);
expect(
isPagedPreviewFrameMessage({
scope: "other",
+5
View File
@@ -23,6 +23,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-test"]),
fileName: "测试.pdf",
pageCount: 1,
echartsErrorCount: 0,
mermaidErrorCount: 0
};
const cache: CachedPdfExport = { key, result };
@@ -65,6 +66,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-cached"]),
fileName: "测试.pdf",
pageCount: 1,
echartsErrorCount: 0,
mermaidErrorCount: 0
};
const cache: CachedPdfExport = {
@@ -95,6 +97,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-new"]),
fileName: "测试.pdf",
pageCount: 2,
echartsErrorCount: 0,
mermaidErrorCount: 0
};
const requester = vi.fn(async () => result);
@@ -128,6 +131,7 @@ describe("PDF 导出客户端", () => {
"content-disposition":
"attachment; filename=\"document.pdf\"",
"x-pdf-page-count": "4",
"x-echarts-error-count": "2",
"x-mermaid-error-count": "1"
}
});
@@ -149,6 +153,7 @@ describe("PDF 导出客户端", () => {
);
expect(result.fileName).toBe("document.pdf");
expect(result.pageCount).toBe(4);
expect(result.echartsErrorCount).toBe(2);
expect(result.mermaidErrorCount).toBe(1);
expect(await result.blob.text()).toBe("%PDF-test");
});
+37 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { getNearestPageNumber } from "../src/preview-page";
import {
getNearestPageNumber,
getPreviewPageScrollTop,
normalizePreviewPageNumber
} from "../src/preview-page";
const pages = [
{ top: 0, bottom: 100 },
@@ -22,3 +26,35 @@ describe("getNearestPageNumber", () => {
expect(getNearestPageNumber(Number.NaN, pages)).toBe(0);
});
});
describe("normalizePreviewPageNumber", () => {
it("保留有效的整数页码", () => {
expect(normalizePreviewPageNumber("12", 3, 65)).toBe(12);
});
it("将越界页码限制在有效范围", () => {
expect(normalizePreviewPageNumber("0", 3, 65)).toBe(1);
expect(normalizePreviewPageNumber("99", 3, 65)).toBe(65);
});
it("无效输入恢复当前页", () => {
expect(normalizePreviewPageNumber("", 7, 65)).toBe(7);
expect(normalizePreviewPageNumber("3.5", 7, 65)).toBe(7);
expect(normalizePreviewPageNumber("abc", 7, 65)).toBe(7);
});
it("没有可用页面时返回 0", () => {
expect(normalizePreviewPageNumber("1", 0, 0)).toBe(0);
});
});
describe("getPreviewPageScrollTop", () => {
it("按目标页相对滚动容器的位置计算滚动量", () => {
expect(getPreviewPageScrollTop(200, 900, 120)).toBe(980);
});
it("避免返回负数或无效滚动量", () => {
expect(getPreviewPageScrollTop(20, 10, 100)).toBe(0);
expect(getPreviewPageScrollTop(Number.NaN, 10, 0)).toBe(0);
});
});