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((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(".pagedjs_page") ); for (const [pageIndex, page] of pages.entries()) { const content = page.querySelector( `.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(".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( ".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 { 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; } }