feat: 实现 Chromium PDF 导出与精确预览

This commit is contained in:
SkyJourney
2026-07-26 13:21:17 +08:00
parent d62552384f
commit d1b5880a5b
26 changed files with 3328 additions and 316 deletions
+29 -197
View File
@@ -1,20 +1,12 @@
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 "./paged-table-handler";
import { PagedDocumentRuntime } from "./paged-document-runtime";
import {
buildPagedMediaCss,
documentBaseCss,
documentGeometryCss,
formatPageNumber,
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
type PagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
import { resolvePagedRenderTarget } from "./paged-render-target";
import { resolveMermaidOutputMode } from "./mermaid-static-image";
function getPreviewRoot() {
const element = document.querySelector<HTMLElement>("#preview-root");
@@ -25,151 +17,18 @@ function getPreviewRoot() {
}
const previewRoot = getPreviewRoot();
document.documentElement.dataset.renderTarget = "preview";
const renderTarget = resolvePagedRenderTarget(window.location.search);
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
const runtime = new PagedDocumentRuntime(previewRoot);
document.documentElement.dataset.renderTarget = renderTarget;
let latestRequestId = 0;
let renderQueue = Promise.resolve();
let activePreviewer: Previewer | undefined;
let mermaidRenderSequence = 0;
let mermaidPromise: Promise<
(typeof import("mermaid"))["default"]
> | undefined;
function post(message: PagedPreviewFrameMessage) {
window.parent.postMessage(message, window.location.origin);
}
function destroyActivePreviewer() {
if (!activePreviewer) {
return;
}
activePreviewer.chunker.destroy();
activePreviewer.polisher.destroy();
activePreviewer = undefined;
}
function loadMermaid() {
if (!mermaidPromise) {
mermaidPromise = import("mermaid").then(({ default: mermaid }) => {
mermaid.initialize(createMermaidSiteConfig());
return mermaid;
});
}
return mermaidPromise;
}
async function 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 loadMermaid();
const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""),
async (definition) => {
mermaidRenderSequence += 1;
return mermaid.render(
`mermaid-paged-preview-${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 function waitForImages(container: ParentNode) {
const images = Array.from(container.querySelectorAll("img"));
await 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(css: string, name: string) {
return {
[`${window.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");
}
}
async function renderPagedPreview(
requestId: number,
payload: PagedPreviewPayload
@@ -184,65 +43,38 @@ async function renderPagedPreview(
requestId
});
destroyActivePreviewer();
previewRoot.replaceChildren();
document.documentElement.lang = payload.metadata.language || "zh-CN";
document.title = payload.metadata.title || "Markdown 分页预览";
const template = document.createElement("template");
template.innerHTML = payload.articleHtml;
const content = template.content;
const mermaidErrors = await renderMermaid(content, payload);
fitOversizedMermaidToPage(content, payload.exportConfig);
if (requestId !== latestRequestId) {
const result = await runtime.render(payload, {
target: renderTarget,
mermaidOutput,
shouldContinue: () => requestId === latestRequestId
});
if (!result) {
return;
}
await document.fonts.ready;
await waitForImages(content);
const previewer = new Previewer();
activePreviewer = previewer;
const flow = await previewer.preview(
content,
[
stylesheet(documentBaseCss, "document-base"),
stylesheet(highlightCss, "highlight"),
stylesheet(katexCss, "katex"),
stylesheet(payload.themeCss, "theme"),
stylesheet(documentGeometryCss, "document-geometry"),
stylesheet(
buildPagedMediaCss(payload.exportConfig, payload),
"paged-media"
)
],
previewRoot
);
applyPageNumbers(previewRoot, payload, flow.total);
if (requestId !== latestRequestId) {
return;
}
const contentHeight = Math.ceil(
Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
previewRoot.scrollHeight
)
);
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId,
pageCount: flow.total,
contentHeight,
mermaidErrors
...result
});
}
window.__mdToPdfRender = async (
payload,
target = renderTarget
) => {
const result = await runtime.render(payload, {
target,
mermaidOutput
});
if (!result) {
throw new Error("分页文档渲染已取消");
}
return result;
};
document.documentElement.dataset.runtimeReady = "true";
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (
event.origin !== window.location.origin ||