318 lines
8.4 KiB
TypeScript
318 lines
8.4 KiB
TypeScript
import highlightCss from "highlight.js/styles/github.css?inline";
|
||
import katexCss from "katex/dist/katex.min.css?inline";
|
||
import { Previewer } from "pagedjs";
|
||
import { createMermaidSiteConfig } from "./mermaid-config";
|
||
import { fitOversizedMermaidToPage } from "./mermaid-page-fit";
|
||
import { renderMermaidDefinitions } from "./mermaid-renderer";
|
||
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
|
||
import type { MermaidOutputMode } from "./mermaid-static-image";
|
||
import "./paged-table-handler";
|
||
import {
|
||
buildPagedMediaCss,
|
||
documentBaseCss,
|
||
documentGeometryCss,
|
||
formatPageNumber,
|
||
type PagedPreviewPayload
|
||
} from "./paged-preview";
|
||
import type { PagedRenderTarget } from "./paged-render-target";
|
||
|
||
export interface PagedDocumentRenderResult {
|
||
pageCount: number;
|
||
contentHeight: number;
|
||
mermaidErrors: string[];
|
||
timings: PagedDocumentTimings;
|
||
}
|
||
|
||
export interface PagedDocumentTimings {
|
||
setupMs: number;
|
||
mermaidMs: number;
|
||
mermaidFitMs: number;
|
||
mermaidConversionMs: number;
|
||
resourceWaitMs: number;
|
||
paginationMs: number;
|
||
finalizeMs: number;
|
||
totalMs: number;
|
||
}
|
||
|
||
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 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;
|
||
|
||
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 = import("mermaid").then(
|
||
({ default: mermaid }) => {
|
||
mermaid.initialize(createMermaidSiteConfig());
|
||
return mermaid;
|
||
}
|
||
);
|
||
}
|
||
|
||
return this.mermaidPromise;
|
||
}
|
||
|
||
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();
|
||
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;
|
||
|
||
const mermaidFitStartedAt = performance.now();
|
||
fitOversizedMermaidToPage(content, payload.exportConfig);
|
||
const mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
||
|
||
const mermaidConversionStartedAt = performance.now();
|
||
if (options.mermaidOutput === "svg-image") {
|
||
replaceMermaidSvgWithImages(content);
|
||
}
|
||
const mermaidConversionMs =
|
||
performance.now() - mermaidConversionStartedAt;
|
||
|
||
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, 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,
|
||
contentHeight: Math.ceil(
|
||
Math.max(
|
||
documentRef.documentElement.scrollHeight,
|
||
documentRef.body.scrollHeight,
|
||
this.root.scrollHeight
|
||
)
|
||
),
|
||
mermaidErrors,
|
||
timings: {
|
||
setupMs,
|
||
mermaidMs,
|
||
mermaidFitMs,
|
||
mermaidConversionMs,
|
||
resourceWaitMs,
|
||
paginationMs,
|
||
finalizeMs,
|
||
totalMs: performance.now() - totalStartedAt
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
__mdToPdfRender?: (
|
||
payload: PagedPreviewPayload,
|
||
target?: PagedRenderTarget
|
||
) => Promise<PagedDocumentRenderResult>;
|
||
}
|
||
}
|