- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染 - 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略 - 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步 - 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放 - 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
450 lines
12 KiB
TypeScript
450 lines
12 KiB
TypeScript
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,
|
||
getPageContentDimensions
|
||
} from "./mermaid-page-fit";
|
||
import { renderMermaidDefinitions } from "./mermaid-renderer";
|
||
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
|
||
import type { MermaidOutputMode } from "./mermaid-static-image";
|
||
import { enablePrintMediaForPreview } from "./preview-styles";
|
||
import "./paged-table-handler";
|
||
import {
|
||
buildPagedMediaCss,
|
||
documentBaseCss,
|
||
documentGeometryCss,
|
||
formatPageNumber,
|
||
type PagedPreviewPayload
|
||
} from "./paged-preview";
|
||
import type { PagedRenderTarget } from "./paged-render-target";
|
||
|
||
export type {
|
||
PagedDocumentRenderResult,
|
||
PagedDocumentTimings
|
||
} from "@md-to-pdf/core";
|
||
|
||
export interface PagedDocumentRenderOptions {
|
||
target: PagedRenderTarget;
|
||
shouldContinue?: () => boolean;
|
||
mermaidOutput?: MermaidOutputMode;
|
||
}
|
||
|
||
function waitForImages(container: ParentNode) {
|
||
const images = Array.from(container.querySelectorAll("img"));
|
||
return Promise.all(
|
||
images.map(async (image) => {
|
||
if (image.complete) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await image.decode();
|
||
} catch {
|
||
await new Promise<void>((resolve) => {
|
||
image.addEventListener("load", () => resolve(), { once: true });
|
||
image.addEventListener("error", () => resolve(), { once: true });
|
||
});
|
||
}
|
||
})
|
||
);
|
||
}
|
||
|
||
function stylesheet(
|
||
documentRef: Document,
|
||
css: string,
|
||
name: string
|
||
) {
|
||
return {
|
||
[`${documentRef.location.href}#${name}`]: css
|
||
};
|
||
}
|
||
|
||
function mountMeasurementContainer(
|
||
documentRef: Document,
|
||
content: DocumentFragment,
|
||
payload: PagedPreviewPayload,
|
||
target: PagedRenderTarget
|
||
) {
|
||
const pageContent = getPageContentDimensions(payload.exportConfig);
|
||
const style = documentRef.createElement("style");
|
||
const themeCss =
|
||
target === "preview"
|
||
? enablePrintMediaForPreview(payload.themeCss)
|
||
: payload.themeCss;
|
||
style.dataset.pagedMeasurementStyle = "true";
|
||
style.textContent = [
|
||
documentBaseCss,
|
||
highlightCss,
|
||
katexCss,
|
||
echartsCss,
|
||
themeCss,
|
||
documentGeometryCss
|
||
].join("\n");
|
||
|
||
const host = documentRef.createElement("div");
|
||
host.dataset.pagedMeasurementHost = "true";
|
||
host.style.cssText = [
|
||
"position: fixed",
|
||
"left: -100000px",
|
||
"top: 0",
|
||
`width: ${pageContent.width}px`,
|
||
"height: auto",
|
||
"opacity: 0",
|
||
"pointer-events: none",
|
||
"contain: layout style paint"
|
||
].join(";");
|
||
|
||
documentRef.head.append(style);
|
||
host.append(content);
|
||
documentRef.body.append(host);
|
||
|
||
return {
|
||
host,
|
||
restore() {
|
||
content.append(...Array.from(host.childNodes));
|
||
host.remove();
|
||
style.remove();
|
||
}
|
||
};
|
||
}
|
||
|
||
function applyPageNumbers(
|
||
container: ParentNode,
|
||
payload: PagedPreviewPayload,
|
||
totalPages: number
|
||
) {
|
||
if (!payload.exportConfig.footer.enabled) {
|
||
return;
|
||
}
|
||
|
||
const alignment = payload.exportConfig.footer.alignment;
|
||
const pages = Array.from(
|
||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||
);
|
||
|
||
for (const [pageIndex, page] of pages.entries()) {
|
||
const content = page.querySelector<HTMLElement>(
|
||
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
|
||
);
|
||
if (!content) {
|
||
continue;
|
||
}
|
||
|
||
content.textContent = formatPageNumber(
|
||
payload.exportConfig.footer,
|
||
pageIndex,
|
||
totalPages
|
||
);
|
||
content.setAttribute("data-page-number-rendered", "true");
|
||
}
|
||
}
|
||
|
||
function removeTrailingPdfPageBreak(container: ParentNode) {
|
||
const pages = Array.from(
|
||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||
);
|
||
pages
|
||
.at(-1)
|
||
?.style.setProperty("break-after", "auto", "important");
|
||
}
|
||
|
||
export class PagedDocumentRuntime {
|
||
private activePreviewer: Previewer | undefined;
|
||
private mermaidRenderSequence = 0;
|
||
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) {}
|
||
|
||
destroy() {
|
||
if (!this.activePreviewer) {
|
||
return;
|
||
}
|
||
|
||
this.activePreviewer.chunker.destroy();
|
||
this.activePreviewer.polisher.destroy();
|
||
this.activePreviewer = undefined;
|
||
}
|
||
|
||
private loadMermaid() {
|
||
if (!this.mermaidPromise) {
|
||
this.mermaidPromise = Promise.all([
|
||
import("mermaid"),
|
||
import("@mermaid-js/layout-elk")
|
||
]).then(
|
||
([{ default: mermaid }, { default: elkLayouts }]) => {
|
||
mermaid.registerLayoutLoaders(elkLayouts);
|
||
return mermaid;
|
||
}
|
||
);
|
||
}
|
||
|
||
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
|
||
) {
|
||
if (!payload.features.includes("mermaid")) {
|
||
return [];
|
||
}
|
||
|
||
const nodes = Array.from(
|
||
container.querySelectorAll<HTMLElement>(
|
||
".mermaid[data-mermaid-pending]"
|
||
)
|
||
);
|
||
if (nodes.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const mermaid = await this.loadMermaid();
|
||
mermaid.initialize(
|
||
createMermaidSiteConfig(payload.exportConfig.mermaid)
|
||
);
|
||
const outcomes = await renderMermaidDefinitions(
|
||
nodes.map((node) => node.textContent ?? ""),
|
||
async (definition) => {
|
||
this.mermaidRenderSequence += 1;
|
||
return mermaid.render(
|
||
`mermaid-paged-document-${this.mermaidRenderSequence}`,
|
||
definition
|
||
);
|
||
}
|
||
);
|
||
const errors: string[] = [];
|
||
|
||
for (const [index, outcome] of outcomes.entries()) {
|
||
const node = nodes[index];
|
||
if (!node) {
|
||
continue;
|
||
}
|
||
|
||
node.removeAttribute("data-mermaid-pending");
|
||
if (outcome.success) {
|
||
node.innerHTML = outcome.svg;
|
||
outcome.bindFunctions?.(node);
|
||
continue;
|
||
}
|
||
|
||
const message =
|
||
outcome.error instanceof Error
|
||
? outcome.error.message
|
||
: "未知错误";
|
||
node.classList.add("mermaid-error");
|
||
node.textContent =
|
||
`Mermaid 图表 ${index + 1} 渲染失败:${message}`;
|
||
errors.push(`图表 ${index + 1}:${message}`);
|
||
}
|
||
|
||
return errors;
|
||
}
|
||
|
||
async render(
|
||
payload: PagedPreviewPayload,
|
||
options: PagedDocumentRenderOptions
|
||
): Promise<PagedDocumentRenderResult | undefined> {
|
||
const totalStartedAt = performance.now();
|
||
const shouldContinue = options.shouldContinue ?? (() => true);
|
||
if (!shouldContinue()) {
|
||
return undefined;
|
||
}
|
||
|
||
const setupStartedAt = performance.now();
|
||
const documentRef = this.root.ownerDocument;
|
||
this.destroy();
|
||
this.root.replaceChildren();
|
||
documentRef.documentElement.dataset.renderTarget = options.target;
|
||
documentRef.documentElement.lang =
|
||
payload.metadata.language || "zh-CN";
|
||
documentRef.title =
|
||
payload.metadata.title || "Markdown 分页文档";
|
||
|
||
const template = documentRef.createElement("template");
|
||
template.innerHTML = payload.articleHtml;
|
||
const content = template.content;
|
||
const setupMs = performance.now() - setupStartedAt;
|
||
|
||
const mermaidStartedAt = performance.now();
|
||
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 (
|
||
payload.features.includes("echarts") ||
|
||
content.querySelector(".mermaid svg")
|
||
) {
|
||
const measurement = mountMeasurementContainer(
|
||
documentRef,
|
||
content,
|
||
payload,
|
||
options.target
|
||
);
|
||
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,
|
||
payload.exportConfig
|
||
);
|
||
mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
||
|
||
const mermaidConversionStartedAt = performance.now();
|
||
if (options.mermaidOutput === "svg-image") {
|
||
replaceMermaidSvgWithImages(measurement.host);
|
||
await waitForImages(measurement.host);
|
||
}
|
||
mermaidConversionMs =
|
||
performance.now() - mermaidConversionStartedAt;
|
||
} finally {
|
||
measurement.restore();
|
||
}
|
||
}
|
||
|
||
if (!shouldContinue()) {
|
||
return undefined;
|
||
}
|
||
|
||
const resourceWaitStartedAt = performance.now();
|
||
await documentRef.fonts.ready;
|
||
await waitForImages(content);
|
||
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
|
||
|
||
const paginationStartedAt = performance.now();
|
||
const previewer = new Previewer();
|
||
this.activePreviewer = previewer;
|
||
const flow = await previewer.preview(
|
||
content,
|
||
[
|
||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
||
stylesheet(documentRef, highlightCss, "highlight"),
|
||
stylesheet(documentRef, katexCss, "katex"),
|
||
stylesheet(documentRef, echartsCss, "echarts"),
|
||
stylesheet(documentRef, payload.themeCss, "theme"),
|
||
stylesheet(
|
||
documentRef,
|
||
documentGeometryCss,
|
||
"document-geometry"
|
||
),
|
||
stylesheet(
|
||
documentRef,
|
||
buildPagedMediaCss(payload.exportConfig, payload),
|
||
"paged-media"
|
||
)
|
||
],
|
||
this.root
|
||
);
|
||
const paginationMs = performance.now() - paginationStartedAt;
|
||
|
||
if (!shouldContinue()) {
|
||
return undefined;
|
||
}
|
||
|
||
const finalizeStartedAt = performance.now();
|
||
applyPageNumbers(this.root, payload, flow.total);
|
||
if (options.target === "pdf") {
|
||
removeTrailingPdfPageBreak(this.root);
|
||
}
|
||
const finalizeMs = performance.now() - finalizeStartedAt;
|
||
|
||
return {
|
||
pageCount: flow.total,
|
||
echartsErrors,
|
||
mermaidErrors,
|
||
timings: {
|
||
setupMs,
|
||
echartsMs,
|
||
echartsFitMs,
|
||
mermaidMs,
|
||
mermaidFitMs,
|
||
mermaidConversionMs,
|
||
resourceWaitMs,
|
||
paginationMs,
|
||
finalizeMs,
|
||
totalMs: performance.now() - totalStartedAt
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
__mdToPdfRender?: (
|
||
payload: PagedPreviewPayload,
|
||
target?: PagedRenderTarget
|
||
) => Promise<PagedDocumentRenderResult>;
|
||
}
|
||
}
|