refactor: 收敛渲染链路共享抽象

This commit is contained in:
SkyJourney
2026-07-27 00:46:16 +08:00
parent b459def232
commit ad18149c10
17 changed files with 480 additions and 255 deletions
+12 -11
View File
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
import Fastify from "fastify";
import {
EXPORT_CONFIG_VERSION,
createPagedDocumentPayload,
defaultExportConfig,
exportConfigSchema,
supportedPaperFormats
@@ -354,17 +355,17 @@ export function buildApp(options: BuildAppOptions = {}) {
const rendered = renderedResult.document;
try {
const generated = await pdfGenerator.generate({
articleHtml: rendered.articleHtml,
fileName:
typeof request.body.fileName === "string"
? request.body.fileName
: "文档.md",
metadata: rendered.metadata,
features: rendered.features,
themeCss,
exportConfig: parsedConfig.data
});
const generated = await pdfGenerator.generate(
createPagedDocumentPayload({
document: rendered,
fileName:
typeof request.body.fileName === "string"
? request.body.fileName
: "文档.md",
themeCss,
exportConfig: parsedConfig.data
})
);
const pdfFileName = createPdfFileName(request.body.fileName);
const requestMs = performance.now() - requestStartedAt;
const serverTiming = createPdfServerTiming(
+7 -33
View File
@@ -1,21 +1,11 @@
import { chromium, type Browser } from "playwright";
import { performance } from "node:perf_hooks";
import type { ExportConfig } from "@md-to-pdf/core";
import type {
PagedDocumentPayload,
PagedDocumentRenderResult
} from "@md-to-pdf/core";
export interface PdfRenderPayload {
articleHtml: string;
fileName: string;
metadata: {
title: string;
author: string;
subject: string;
keywords: string[];
language: string;
};
features: string[];
themeCss: string;
exportConfig: ExportConfig;
}
export type PdfRenderPayload = PagedDocumentPayload;
export interface PdfGenerationResult {
pdf: Buffer;
@@ -62,22 +52,6 @@ export interface PdfEngineRuntimeLimits {
timeoutMs: number;
}
interface PagedRuntimeResult {
pageCount: number;
contentHeight: number;
mermaidErrors: string[];
timings: {
setupMs: number;
mermaidMs: number;
mermaidFitMs: number;
mermaidConversionMs: number;
resourceWaitMs: number;
paginationMs: number;
finalizeMs: number;
totalMs: number;
};
}
interface PdfDocumentResult
extends Omit<PdfGenerationResult, "timings"> {
timings: Pick<
@@ -457,13 +431,13 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
const documentRenderStartedAt = performance.now();
const renderResult = await page.evaluate(
async (renderPayload): Promise<PagedRuntimeResult> => {
async (renderPayload): Promise<PagedDocumentRenderResult> => {
const renderer = (
window as typeof window & {
__mdToPdfRender?: (
payload: PdfRenderPayload,
target: "pdf"
) => Promise<PagedRuntimeResult>;
) => Promise<PagedDocumentRenderResult>;
}
).__mdToPdfRender;
if (!renderer) {
+37 -1
View File
@@ -27,6 +27,7 @@ export interface ThemeRegistryOptions {
bundledRoot: string;
localRoot: string;
onWarning?: (message: string) => void;
cacheTtlMs?: number;
}
const assetContentTypes: Record<string, string> = {
@@ -275,7 +276,18 @@ async function loadThemeCssFile(
}
export function createThemeRegistry(options: ThemeRegistryOptions) {
async function list() {
const cacheTtlMs = options.cacheTtlMs ?? 1_000;
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
throw new Error("主题缓存有效期必须是非负有限数值");
}
let cachedThemes:
| {
expiresAt: number;
promise: Promise<ThemeRecord[]>;
}
| undefined;
async function scanThemes() {
const [bundledThemes, localThemes] = await Promise.all([
readThemeRoot(options.bundledRoot, "bundled"),
readThemeRoot(options.localRoot, "local", options.onWarning)
@@ -293,6 +305,29 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
return themes;
}
function list() {
const now = Date.now();
if (cachedThemes && now < cachedThemes.expiresAt) {
return cachedThemes.promise;
}
const promise = scanThemes();
cachedThemes = {
expiresAt: now + cacheTtlMs,
promise
};
void promise.catch(() => {
if (cachedThemes?.promise === promise) {
cachedThemes = undefined;
}
});
return promise;
}
function invalidate() {
cachedThemes = undefined;
}
async function get(themeId: string) {
return (await list()).find((theme) => theme.manifest.id === themeId);
}
@@ -340,6 +375,7 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
get,
getAsset,
getCss,
invalidate,
list
};
}
+52
View File
@@ -287,6 +287,58 @@ describe("预览 API", () => {
);
});
it("缓存主题目录扫描并支持主动失效", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
const bundledRoot = join(temporaryDirectory, "bundled");
const localRoot = join(temporaryDirectory, "local");
const firstThemeRoot = join(localRoot, "first-theme");
const secondThemeRoot = join(localRoot, "second-theme");
await mkdir(bundledRoot, { recursive: true });
await mkdir(firstThemeRoot, { recursive: true });
await writeFile(
join(firstThemeRoot, "theme.json"),
JSON.stringify(localThemeManifest("first-theme")),
"utf8"
);
await writeFile(
join(firstThemeRoot, "theme.css"),
"#write { color: #333; }",
"utf8"
);
const registry = createThemeRegistry({
bundledRoot,
localRoot,
cacheTtlMs: 60_000
});
await expect(registry.list()).resolves.toHaveLength(1);
await mkdir(secondThemeRoot, { recursive: true });
await writeFile(
join(secondThemeRoot, "theme.json"),
JSON.stringify(localThemeManifest("second-theme")),
"utf8"
);
await writeFile(
join(secondThemeRoot, "theme.css"),
"#write { color: #666; }",
"utf8"
);
await expect(registry.list()).resolves.toHaveLength(1);
registry.invalidate();
await expect(registry.list()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
manifest: expect.objectContaining({ id: "first-theme" })
}),
expect.objectContaining({
manifest: expect.objectContaining({ id: "second-theme" })
})
])
);
});
it("拒绝主题 CSS 导入外部地址", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
const bundledRoot = join(temporaryDirectory, "bundled");
-1
View File
@@ -47,7 +47,6 @@ function fakeBrowser(
waitForFunction: vi.fn(async () => undefined),
evaluate: vi.fn(async () => ({
pageCount: 2,
contentHeight: 2200,
mermaidErrors: [],
timings: {
setupMs: 1,
+52 -144
View File
@@ -10,6 +10,7 @@ import {
useState
} from "react";
import {
createPagedDocumentPayload,
getPaperDimensionsMm,
millimetersToCssPixels,
type ExportConfig
@@ -43,6 +44,11 @@ import {
loadPreviewZoom,
savePreviewZoom
} from "./preview-zoom";
import { useMarkdownRender } from "./use-markdown-render";
import {
type ThemeSummary,
useThemeResources
} from "./use-theme-resources";
const PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview");
@@ -51,28 +57,6 @@ const PrecisePdfPreview = lazy(async () => {
type PreviewMode = "quick" | "precise";
interface RenderedMarkdown {
articleHtml: string;
metadata: {
title: string;
author: string;
subject: string;
keywords: string[];
language: string;
};
features: string[];
warnings: string[];
}
interface ThemeSummary {
id: string;
name: string;
version: string;
description: string;
bundled: boolean;
source: "bundled" | "local";
}
const sampleMarkdown = `---
title: Markdown PDF 示例
author: 内网文档团队
@@ -113,15 +97,11 @@ flowchart LR
export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown);
const [fileName, setFileName] = useState("示例文档.md");
const [result, setResult] = useState<RenderedMarkdown | null>(null);
const [themes, setThemes] = useState<ThemeSummary[]>([]);
const [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig);
const [themeCss, setThemeCss] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
const [status, setStatus] = useState("正在准备预览…");
const [renderError, setRenderError] = useState("");
const [themeError, setThemeError] = useState("");
const [appError, setAppError] = useState("");
const [mermaidError, setMermaidError] = useState("");
const [paginationError, setPaginationError] = useState("");
const [pdfError, setPdfError] = useState("");
@@ -146,9 +126,47 @@ export function App() {
"editor" | "preview" | undefined
>(undefined);
const handleThemesLoaded = useCallback(
(availableThemes: ThemeSummary[]) => {
setExportConfig((currentConfig) => {
if (
availableThemes.some(
(theme) => theme.id === currentConfig.themeId
)
) {
return currentConfig;
}
const preferredTheme =
availableThemes.find((theme) => theme.id === "typora-github") ??
availableThemes.find((theme) => theme.id === "typora-like") ??
availableThemes[0];
return preferredTheme
? { ...currentConfig, themeId: preferredTheme.id }
: currentConfig;
});
},
[]
);
const handleRenderStart = useCallback(() => {
setMermaidError("");
}, []);
const { error: renderError, result } = useMarkdownRender(
markdown,
"zh-CN",
{
onRenderStart: handleRenderStart,
onStatusChange: setStatus
}
);
const {
error: themeError,
themeCss,
themes
} = useThemeResources(exportConfig.themeId, handleThemesLoaded);
const error =
renderError ||
themeError ||
appError ||
mermaidError ||
paginationError ||
pdfError;
@@ -176,14 +194,12 @@ export function App() {
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
() =>
result && themeCss
? {
articleHtml: result.articleHtml,
? createPagedDocumentPayload({
document: result,
fileName,
metadata: result.metadata,
features: result.features,
themeCss,
exportConfig
}
})
: undefined,
[exportConfig, fileName, result, themeCss]
);
@@ -204,53 +220,12 @@ export function App() {
previewModeRef.current = previewMode;
pdfRequestKeyRef.current = pdfCacheKey;
useEffect(() => {
const controller = new AbortController();
void fetch("/api/themes", {
signal: controller.signal
})
.then(async (response) => {
if (!response.ok) {
throw new Error("无法加载主题清单");
}
return response.json() as Promise<{ themes: ThemeSummary[] }>;
})
.then(({ themes: availableThemes }) => {
setThemes(availableThemes);
setExportConfig((currentConfig) => {
if (
availableThemes.some(
(theme) => theme.id === currentConfig.themeId
)
) {
return currentConfig;
}
const preferredTheme =
availableThemes.find((theme) => theme.id === "typora-github") ??
availableThemes.find((theme) => theme.id === "typora-like") ??
availableThemes[0];
return preferredTheme
? { ...currentConfig, themeId: preferredTheme.id }
: currentConfig;
});
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setThemeError(
reason instanceof Error ? reason.message : "无法加载主题清单"
);
}
});
return () => controller.abort();
}, []);
useEffect(() => {
try {
saveExportConfig(exportConfig);
setAppError("");
} catch {
setRenderError("无法保存导出设置,当前设置仅在本次页面中有效");
setAppError("无法保存导出设置,当前设置仅在本次页面中有效");
}
}, [exportConfig]);
@@ -271,77 +246,9 @@ export function App() {
}, [pdfCache, pdfCacheKey]);
useEffect(() => {
const controller = new AbortController();
setPreviewFrameReady(false);
setThemeCss("");
setThemeError("");
void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, {
signal: controller.signal
})
.then(async (response) => {
if (!response.ok) {
throw new Error("无法加载主题");
}
return response.text();
})
.then(setThemeCss)
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setThemeError(
reason instanceof Error ? reason.message : "无法加载主题"
);
}
});
return () => controller.abort();
}, [themeId]);
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => {
setStatus("正在渲染…");
setRenderError("");
setMermaidError("");
void fetch("/api/render", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
markdown,
language: "zh-CN"
}),
signal: controller.signal
})
.then(async (response) => {
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.message ?? "渲染失败");
}
return payload as RenderedMarkdown;
})
.then((payload) => {
setResult(payload);
setStatus("预览已更新");
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setRenderError(
reason instanceof Error ? reason.message : "渲染失败"
);
setStatus("预览失败");
}
});
}, 250);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [markdown]);
useEffect(() => {
function handlePreviewFrameMessage(event: MessageEvent<unknown>) {
const frame = previewFrameRef.current;
@@ -552,10 +459,11 @@ export function App() {
}
try {
setAppError("");
setMarkdown(await file.text());
setFileName(file.name);
} catch {
setRenderError("无法读取所选 Markdown 文件");
setAppError("无法读取所选 Markdown 文件");
} finally {
event.target.value = "";
}
+8 -24
View File
@@ -1,5 +1,9 @@
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
import { Previewer } from "pagedjs";
import { createMermaidSiteConfig } from "./mermaid-config";
import {
@@ -20,23 +24,10 @@ import {
} 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 type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
export interface PagedDocumentRenderOptions {
target: PagedRenderTarget;
@@ -364,13 +355,6 @@ export class PagedDocumentRuntime {
return {
pageCount: flow.total,
contentHeight: Math.ceil(
Math.max(
documentRef.documentElement.scrollHeight,
documentRef.body.scrollHeight,
this.root.scrollHeight
)
),
mermaidErrors,
timings: {
setupMs,
+5 -18
View File
@@ -1,27 +1,15 @@
import {
getPaperDimensionsMm,
type ExportConfig
type ExportConfig,
type MarkdownDocumentMetadata,
type PagedDocumentPayload
} from "@md-to-pdf/core";
import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
export interface PreviewMetadata {
title: string;
author: string;
subject: string;
keywords: string[];
language: string;
}
export interface PagedPreviewPayload {
articleHtml: string;
fileName: string;
metadata: PreviewMetadata;
features: string[];
themeCss: string;
exportConfig: ExportConfig;
}
export type PreviewMetadata = MarkdownDocumentMetadata;
export type PagedPreviewPayload = PagedDocumentPayload;
export interface PagedPreviewRenderRequest {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
@@ -45,7 +33,6 @@ export type PagedPreviewFrameMessage =
type: "rendered";
requestId: number;
pageCount: number;
contentHeight: number;
mermaidErrors: string[];
}
| {
+5 -2
View File
@@ -1,5 +1,8 @@
export const PDF_POINTS_PER_INCH = 72;
export const CSS_PIXELS_PER_INCH = 96;
import {
CSS_PIXELS_PER_INCH,
PDF_POINTS_PER_INCH
} from "@md-to-pdf/core";
export const PDF_PAGE_CSS_SCALE =
CSS_PIXELS_PER_INCH / PDF_POINTS_PER_INCH;
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
export interface MarkdownRenderCallbacks {
onRenderStart?: () => void;
onStatusChange?: (status: string) => void;
}
export function useMarkdownRender(
markdown: string,
language: string,
callbacks: MarkdownRenderCallbacks = {}
) {
const [result, setResult] =
useState<RenderedMarkdownDocument | null>(null);
const [error, setError] = useState("");
const { onRenderStart, onStatusChange } = callbacks;
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => {
onStatusChange?.("正在渲染…");
onRenderStart?.();
setError("");
void fetch("/api/render", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({ markdown, language }),
signal: controller.signal
})
.then(async (response) => {
const payload = (await response.json()) as
| RenderedMarkdownDocument
| { message?: string };
if (!response.ok) {
throw new Error(
"message" in payload
? payload.message ?? "渲染失败"
: "渲染失败"
);
}
return payload as RenderedMarkdownDocument;
})
.then((payload) => {
setResult(payload);
onStatusChange?.("预览已更新");
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setError(
reason instanceof Error ? reason.message : "渲染失败"
);
onStatusChange?.("预览失败");
}
});
}, 250);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [language, markdown, onRenderStart, onStatusChange]);
return { error, result };
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
export interface ThemeSummary {
id: string;
name: string;
version: string;
description: string;
bundled: boolean;
source: "bundled" | "local";
}
export function useThemeResources(
themeId: string,
onThemesLoaded: (themes: ThemeSummary[]) => void
) {
const [themes, setThemes] = useState<ThemeSummary[]>([]);
const [themeCss, setThemeCss] = useState("");
const [catalogError, setCatalogError] = useState("");
const [cssError, setCssError] = useState("");
useEffect(() => {
const controller = new AbortController();
void fetch("/api/themes", {
signal: controller.signal
})
.then(async (response) => {
if (!response.ok) {
throw new Error("无法加载主题清单");
}
return response.json() as Promise<{ themes: ThemeSummary[] }>;
})
.then(({ themes: availableThemes }) => {
setThemes(availableThemes);
setCatalogError("");
onThemesLoaded(availableThemes);
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setCatalogError(
reason instanceof Error ? reason.message : "无法加载主题清单"
);
}
});
return () => controller.abort();
}, [onThemesLoaded]);
useEffect(() => {
const controller = new AbortController();
setThemeCss("");
setCssError("");
void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, {
signal: controller.signal
})
.then(async (response) => {
if (!response.ok) {
throw new Error("无法加载主题");
}
return response.text();
})
.then(setThemeCss)
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setCssError(
reason instanceof Error ? reason.message : "无法加载主题"
);
}
});
return () => controller.abort();
}, [themeId]);
return {
error: catalogError || cssError,
themeCss,
themes
};
}
-1
View File
@@ -178,7 +178,6 @@ describe("分页预览协议", () => {
type: "rendered",
requestId: 3,
pageCount: 2,
contentHeight: 2300,
mermaidErrors: []
})
).toBe(true);