feat: 实现 Chromium PDF 导出与精确预览
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user