refactor: 收敛渲染链路共享抽象
This commit is contained in:
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import {
|
import {
|
||||||
EXPORT_CONFIG_VERSION,
|
EXPORT_CONFIG_VERSION,
|
||||||
|
createPagedDocumentPayload,
|
||||||
defaultExportConfig,
|
defaultExportConfig,
|
||||||
exportConfigSchema,
|
exportConfigSchema,
|
||||||
supportedPaperFormats
|
supportedPaperFormats
|
||||||
@@ -354,17 +355,17 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
const rendered = renderedResult.document;
|
const rendered = renderedResult.document;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const generated = await pdfGenerator.generate({
|
const generated = await pdfGenerator.generate(
|
||||||
articleHtml: rendered.articleHtml,
|
createPagedDocumentPayload({
|
||||||
|
document: rendered,
|
||||||
fileName:
|
fileName:
|
||||||
typeof request.body.fileName === "string"
|
typeof request.body.fileName === "string"
|
||||||
? request.body.fileName
|
? request.body.fileName
|
||||||
: "文档.md",
|
: "文档.md",
|
||||||
metadata: rendered.metadata,
|
|
||||||
features: rendered.features,
|
|
||||||
themeCss,
|
themeCss,
|
||||||
exportConfig: parsedConfig.data
|
exportConfig: parsedConfig.data
|
||||||
});
|
})
|
||||||
|
);
|
||||||
const pdfFileName = createPdfFileName(request.body.fileName);
|
const pdfFileName = createPdfFileName(request.body.fileName);
|
||||||
const requestMs = performance.now() - requestStartedAt;
|
const requestMs = performance.now() - requestStartedAt;
|
||||||
const serverTiming = createPdfServerTiming(
|
const serverTiming = createPdfServerTiming(
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
import { chromium, type Browser } from "playwright";
|
import { chromium, type Browser } from "playwright";
|
||||||
import { performance } from "node:perf_hooks";
|
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 {
|
export type PdfRenderPayload = PagedDocumentPayload;
|
||||||
articleHtml: string;
|
|
||||||
fileName: string;
|
|
||||||
metadata: {
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
subject: string;
|
|
||||||
keywords: string[];
|
|
||||||
language: string;
|
|
||||||
};
|
|
||||||
features: string[];
|
|
||||||
themeCss: string;
|
|
||||||
exportConfig: ExportConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PdfGenerationResult {
|
export interface PdfGenerationResult {
|
||||||
pdf: Buffer;
|
pdf: Buffer;
|
||||||
@@ -62,22 +52,6 @@ export interface PdfEngineRuntimeLimits {
|
|||||||
timeoutMs: number;
|
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
|
interface PdfDocumentResult
|
||||||
extends Omit<PdfGenerationResult, "timings"> {
|
extends Omit<PdfGenerationResult, "timings"> {
|
||||||
timings: Pick<
|
timings: Pick<
|
||||||
@@ -457,13 +431,13 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
|||||||
|
|
||||||
const documentRenderStartedAt = performance.now();
|
const documentRenderStartedAt = performance.now();
|
||||||
const renderResult = await page.evaluate(
|
const renderResult = await page.evaluate(
|
||||||
async (renderPayload): Promise<PagedRuntimeResult> => {
|
async (renderPayload): Promise<PagedDocumentRenderResult> => {
|
||||||
const renderer = (
|
const renderer = (
|
||||||
window as typeof window & {
|
window as typeof window & {
|
||||||
__mdToPdfRender?: (
|
__mdToPdfRender?: (
|
||||||
payload: PdfRenderPayload,
|
payload: PdfRenderPayload,
|
||||||
target: "pdf"
|
target: "pdf"
|
||||||
) => Promise<PagedRuntimeResult>;
|
) => Promise<PagedDocumentRenderResult>;
|
||||||
}
|
}
|
||||||
).__mdToPdfRender;
|
).__mdToPdfRender;
|
||||||
if (!renderer) {
|
if (!renderer) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface ThemeRegistryOptions {
|
|||||||
bundledRoot: string;
|
bundledRoot: string;
|
||||||
localRoot: string;
|
localRoot: string;
|
||||||
onWarning?: (message: string) => void;
|
onWarning?: (message: string) => void;
|
||||||
|
cacheTtlMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetContentTypes: Record<string, string> = {
|
const assetContentTypes: Record<string, string> = {
|
||||||
@@ -275,7 +276,18 @@ async function loadThemeCssFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createThemeRegistry(options: ThemeRegistryOptions) {
|
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([
|
const [bundledThemes, localThemes] = await Promise.all([
|
||||||
readThemeRoot(options.bundledRoot, "bundled"),
|
readThemeRoot(options.bundledRoot, "bundled"),
|
||||||
readThemeRoot(options.localRoot, "local", options.onWarning)
|
readThemeRoot(options.localRoot, "local", options.onWarning)
|
||||||
@@ -293,6 +305,29 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
|||||||
return themes;
|
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) {
|
async function get(themeId: string) {
|
||||||
return (await list()).find((theme) => theme.manifest.id === themeId);
|
return (await list()).find((theme) => theme.manifest.id === themeId);
|
||||||
}
|
}
|
||||||
@@ -340,6 +375,7 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
|||||||
get,
|
get,
|
||||||
getAsset,
|
getAsset,
|
||||||
getCss,
|
getCss,
|
||||||
|
invalidate,
|
||||||
list
|
list
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
it("拒绝主题 CSS 导入外部地址", async () => {
|
||||||
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
||||||
const bundledRoot = join(temporaryDirectory, "bundled");
|
const bundledRoot = join(temporaryDirectory, "bundled");
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ function fakeBrowser(
|
|||||||
waitForFunction: vi.fn(async () => undefined),
|
waitForFunction: vi.fn(async () => undefined),
|
||||||
evaluate: vi.fn(async () => ({
|
evaluate: vi.fn(async () => ({
|
||||||
pageCount: 2,
|
pageCount: 2,
|
||||||
contentHeight: 2200,
|
|
||||||
mermaidErrors: [],
|
mermaidErrors: [],
|
||||||
timings: {
|
timings: {
|
||||||
setupMs: 1,
|
setupMs: 1,
|
||||||
|
|||||||
+52
-144
@@ -10,6 +10,7 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
createPagedDocumentPayload,
|
||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
millimetersToCssPixels,
|
millimetersToCssPixels,
|
||||||
type ExportConfig
|
type ExportConfig
|
||||||
@@ -43,6 +44,11 @@ import {
|
|||||||
loadPreviewZoom,
|
loadPreviewZoom,
|
||||||
savePreviewZoom
|
savePreviewZoom
|
||||||
} from "./preview-zoom";
|
} from "./preview-zoom";
|
||||||
|
import { useMarkdownRender } from "./use-markdown-render";
|
||||||
|
import {
|
||||||
|
type ThemeSummary,
|
||||||
|
useThemeResources
|
||||||
|
} from "./use-theme-resources";
|
||||||
|
|
||||||
const PrecisePdfPreview = lazy(async () => {
|
const PrecisePdfPreview = lazy(async () => {
|
||||||
const module = await import("./PrecisePdfPreview");
|
const module = await import("./PrecisePdfPreview");
|
||||||
@@ -51,28 +57,6 @@ const PrecisePdfPreview = lazy(async () => {
|
|||||||
|
|
||||||
type PreviewMode = "quick" | "precise";
|
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 = `---
|
const sampleMarkdown = `---
|
||||||
title: Markdown PDF 示例
|
title: Markdown PDF 示例
|
||||||
author: 内网文档团队
|
author: 内网文档团队
|
||||||
@@ -113,15 +97,11 @@ flowchart LR
|
|||||||
export function App() {
|
export function App() {
|
||||||
const [markdown, setMarkdown] = useState(sampleMarkdown);
|
const [markdown, setMarkdown] = useState(sampleMarkdown);
|
||||||
const [fileName, setFileName] = useState("示例文档.md");
|
const [fileName, setFileName] = useState("示例文档.md");
|
||||||
const [result, setResult] = useState<RenderedMarkdown | null>(null);
|
|
||||||
const [themes, setThemes] = useState<ThemeSummary[]>([]);
|
|
||||||
const [exportConfig, setExportConfig] =
|
const [exportConfig, setExportConfig] =
|
||||||
useState<ExportConfig>(loadExportConfig);
|
useState<ExportConfig>(loadExportConfig);
|
||||||
const [themeCss, setThemeCss] = useState("");
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [status, setStatus] = useState("正在准备预览…");
|
const [status, setStatus] = useState("正在准备预览…");
|
||||||
const [renderError, setRenderError] = useState("");
|
const [appError, setAppError] = useState("");
|
||||||
const [themeError, setThemeError] = useState("");
|
|
||||||
const [mermaidError, setMermaidError] = useState("");
|
const [mermaidError, setMermaidError] = useState("");
|
||||||
const [paginationError, setPaginationError] = useState("");
|
const [paginationError, setPaginationError] = useState("");
|
||||||
const [pdfError, setPdfError] = useState("");
|
const [pdfError, setPdfError] = useState("");
|
||||||
@@ -146,9 +126,47 @@ export function App() {
|
|||||||
"editor" | "preview" | undefined
|
"editor" | "preview" | undefined
|
||||||
>(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 =
|
const error =
|
||||||
renderError ||
|
renderError ||
|
||||||
themeError ||
|
themeError ||
|
||||||
|
appError ||
|
||||||
mermaidError ||
|
mermaidError ||
|
||||||
paginationError ||
|
paginationError ||
|
||||||
pdfError;
|
pdfError;
|
||||||
@@ -176,14 +194,12 @@ export function App() {
|
|||||||
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
|
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
|
||||||
() =>
|
() =>
|
||||||
result && themeCss
|
result && themeCss
|
||||||
? {
|
? createPagedDocumentPayload({
|
||||||
articleHtml: result.articleHtml,
|
document: result,
|
||||||
fileName,
|
fileName,
|
||||||
metadata: result.metadata,
|
|
||||||
features: result.features,
|
|
||||||
themeCss,
|
themeCss,
|
||||||
exportConfig
|
exportConfig
|
||||||
}
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
[exportConfig, fileName, result, themeCss]
|
[exportConfig, fileName, result, themeCss]
|
||||||
);
|
);
|
||||||
@@ -204,53 +220,12 @@ export function App() {
|
|||||||
previewModeRef.current = previewMode;
|
previewModeRef.current = previewMode;
|
||||||
pdfRequestKeyRef.current = pdfCacheKey;
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
saveExportConfig(exportConfig);
|
saveExportConfig(exportConfig);
|
||||||
|
setAppError("");
|
||||||
} catch {
|
} catch {
|
||||||
setRenderError("无法保存导出设置,当前设置仅在本次页面中有效");
|
setAppError("无法保存导出设置,当前设置仅在本次页面中有效");
|
||||||
}
|
}
|
||||||
}, [exportConfig]);
|
}, [exportConfig]);
|
||||||
|
|
||||||
@@ -271,77 +246,9 @@ export function App() {
|
|||||||
}, [pdfCache, pdfCacheKey]);
|
}, [pdfCache, pdfCacheKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
|
||||||
setPreviewFrameReady(false);
|
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]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
function handlePreviewFrameMessage(event: MessageEvent<unknown>) {
|
function handlePreviewFrameMessage(event: MessageEvent<unknown>) {
|
||||||
const frame = previewFrameRef.current;
|
const frame = previewFrameRef.current;
|
||||||
@@ -552,10 +459,11 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setAppError("");
|
||||||
setMarkdown(await file.text());
|
setMarkdown(await file.text());
|
||||||
setFileName(file.name);
|
setFileName(file.name);
|
||||||
} catch {
|
} catch {
|
||||||
setRenderError("无法读取所选 Markdown 文件");
|
setAppError("无法读取所选 Markdown 文件");
|
||||||
} finally {
|
} finally {
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||||
import katexCss from "katex/dist/katex.min.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 { Previewer } from "pagedjs";
|
||||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
import { createMermaidSiteConfig } from "./mermaid-config";
|
||||||
import {
|
import {
|
||||||
@@ -20,23 +24,10 @@ import {
|
|||||||
} from "./paged-preview";
|
} from "./paged-preview";
|
||||||
import type { PagedRenderTarget } from "./paged-render-target";
|
import type { PagedRenderTarget } from "./paged-render-target";
|
||||||
|
|
||||||
export interface PagedDocumentRenderResult {
|
export type {
|
||||||
pageCount: number;
|
PagedDocumentRenderResult,
|
||||||
contentHeight: number;
|
PagedDocumentTimings
|
||||||
mermaidErrors: string[];
|
} from "@md-to-pdf/core";
|
||||||
timings: PagedDocumentTimings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagedDocumentTimings {
|
|
||||||
setupMs: number;
|
|
||||||
mermaidMs: number;
|
|
||||||
mermaidFitMs: number;
|
|
||||||
mermaidConversionMs: number;
|
|
||||||
resourceWaitMs: number;
|
|
||||||
paginationMs: number;
|
|
||||||
finalizeMs: number;
|
|
||||||
totalMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagedDocumentRenderOptions {
|
export interface PagedDocumentRenderOptions {
|
||||||
target: PagedRenderTarget;
|
target: PagedRenderTarget;
|
||||||
@@ -364,13 +355,6 @@ export class PagedDocumentRuntime {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
pageCount: flow.total,
|
pageCount: flow.total,
|
||||||
contentHeight: Math.ceil(
|
|
||||||
Math.max(
|
|
||||||
documentRef.documentElement.scrollHeight,
|
|
||||||
documentRef.body.scrollHeight,
|
|
||||||
this.root.scrollHeight
|
|
||||||
)
|
|
||||||
),
|
|
||||||
mermaidErrors,
|
mermaidErrors,
|
||||||
timings: {
|
timings: {
|
||||||
setupMs,
|
setupMs,
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
type ExportConfig
|
type ExportConfig,
|
||||||
|
type MarkdownDocumentMetadata,
|
||||||
|
type PagedDocumentPayload
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
|
import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
|
||||||
|
|
||||||
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
||||||
|
|
||||||
export interface PreviewMetadata {
|
export type PreviewMetadata = MarkdownDocumentMetadata;
|
||||||
title: string;
|
export type PagedPreviewPayload = PagedDocumentPayload;
|
||||||
author: string;
|
|
||||||
subject: string;
|
|
||||||
keywords: string[];
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagedPreviewPayload {
|
|
||||||
articleHtml: string;
|
|
||||||
fileName: string;
|
|
||||||
metadata: PreviewMetadata;
|
|
||||||
features: string[];
|
|
||||||
themeCss: string;
|
|
||||||
exportConfig: ExportConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagedPreviewRenderRequest {
|
export interface PagedPreviewRenderRequest {
|
||||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||||
@@ -45,7 +33,6 @@ export type PagedPreviewFrameMessage =
|
|||||||
type: "rendered";
|
type: "rendered";
|
||||||
requestId: number;
|
requestId: number;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
contentHeight: number;
|
|
||||||
mermaidErrors: string[];
|
mermaidErrors: string[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export const PDF_POINTS_PER_INCH = 72;
|
import {
|
||||||
export const CSS_PIXELS_PER_INCH = 96;
|
CSS_PIXELS_PER_INCH,
|
||||||
|
PDF_POINTS_PER_INCH
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
export const PDF_PAGE_CSS_SCALE =
|
export const PDF_PAGE_CSS_SCALE =
|
||||||
CSS_PIXELS_PER_INCH / PDF_POINTS_PER_INCH;
|
CSS_PIXELS_PER_INCH / PDF_POINTS_PER_INCH;
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -178,7 +178,6 @@ describe("分页预览协议", () => {
|
|||||||
type: "rendered",
|
type: "rendered",
|
||||||
requestId: 3,
|
requestId: 3,
|
||||||
pageCount: 2,
|
pageCount: 2,
|
||||||
contentHeight: 2300,
|
|
||||||
mermaidErrors: []
|
mermaidErrors: []
|
||||||
})
|
})
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|||||||
+36
-6
@@ -1,6 +1,6 @@
|
|||||||
# Markdown PDF 导出器进度
|
# Markdown PDF 导出器进度
|
||||||
|
|
||||||
最后更新:2026-07-26
|
最后更新:2026-07-27
|
||||||
|
|
||||||
## 1. 当前概况
|
## 1. 当前概况
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ main
|
|||||||
已有提交:
|
已有提交:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
b459def fix: 修复预览与 PDF 渲染边界问题
|
||||||
|
d93728b feat: 完善 Mermaid 配置与预览缩放
|
||||||
d1b5880 feat: 实现 Chromium PDF 导出与精确预览
|
d1b5880 feat: 实现 Chromium PDF 导出与精确预览
|
||||||
d625523 fix: 修复长文档分页与滚动同步
|
d625523 fix: 修复长文档分页与滚动同步
|
||||||
1d93f31 feat: 实现真实分页预览
|
1d93f31 feat: 实现真实分页预览
|
||||||
@@ -29,7 +31,8 @@ fa07472 feat: 实现网页实时预览
|
|||||||
ec48bce chore: 初始化项目骨架
|
ec48bce chore: 初始化项目骨架
|
||||||
```
|
```
|
||||||
|
|
||||||
最新阶段已完成 Mermaid 全局导出配置、ELK 布局、代码块配置覆盖、重绘隐藏和两种预览模式共用的显示缩放。
|
最新阶段已完成渲染边界逻辑修复,以及前后端分页载荷、文档类型、
|
||||||
|
物理单位、主题资源加载和 Markdown 请求逻辑的复用收敛。
|
||||||
|
|
||||||
## 2. 已完成
|
## 2. 已完成
|
||||||
|
|
||||||
@@ -209,9 +212,27 @@ ec48bce chore: 初始化项目骨架
|
|||||||
- 缩放不进入导出配置,不改变分页或 PDF;精确预览会按显示宽度重绘
|
- 缩放不进入导出配置,不改变分页或 PDF;精确预览会按显示宽度重绘
|
||||||
邻近 Canvas,保持文字清晰。
|
邻近 Canvas,保持文字清晰。
|
||||||
|
|
||||||
|
### 3.7 渲染边界修复与复用重构
|
||||||
|
|
||||||
|
- PDF 总超时覆盖 Chromium 启动、上下文创建、页面渲染和有界清理,
|
||||||
|
浏览器预热也受同一超时约束。
|
||||||
|
- 当前预览模式的重复点击不再触发无意义的状态重置和 PDF 请求。
|
||||||
|
- Mermaid 分页测量使用实际主题、字体和盒模型,静态 SVG 图片锁定实际
|
||||||
|
小数尺寸,避免测量容器宽度与真实纸张不一致。
|
||||||
|
- 非法 Front Matter 在预览和 PDF API 中统一返回 400;无效本地主题
|
||||||
|
会被隔离并记录警告,不影响其他主题。
|
||||||
|
- `packages/core` 统一维护 Markdown 文档、分页载荷、分页结果和耗时类型,
|
||||||
|
前端快速预览与后端 PDF 使用同一个载荷构造函数。
|
||||||
|
- 前端 Markdown 防抖渲染与主题清单/CSS 加载已从 `App.tsx` 抽取为独立
|
||||||
|
Hook,保留原有取消请求和错误隔离行为。
|
||||||
|
- 主题注册表增加 1 秒目录扫描缓存和主动失效接口,减少同一请求链路中
|
||||||
|
重复遍历主题目录,同时兼顾本地主题热更新。
|
||||||
|
- CSS 像素与 PDF 点数换算只使用 core 中的共享常量;删除已经不参与协议
|
||||||
|
或逻辑的 `contentHeight` 字段。
|
||||||
|
|
||||||
## 4. 已执行验证
|
## 4. 已执行验证
|
||||||
|
|
||||||
2026-07-26 在当前完整工作区成功执行:
|
2026-07-27 在当前完整工作区成功执行:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
npm test
|
npm test
|
||||||
@@ -222,16 +243,20 @@ git diff --check
|
|||||||
|
|
||||||
结果:
|
结果:
|
||||||
|
|
||||||
- 共享配置测试:8 项通过;
|
- 共享配置测试:9 项通过;
|
||||||
- 渲染器测试:7 项通过;
|
- 渲染器测试:8 项通过;
|
||||||
- 前端测试:55 项通过;
|
- 前端测试:55 项通过;
|
||||||
- 后端测试:23 项通过;
|
- 后端测试:29 项通过;
|
||||||
- 全项目类型检查通过;
|
- 全项目类型检查通过;
|
||||||
- 生产构建通过;
|
- 生产构建通过;
|
||||||
- `git diff --check` 通过。
|
- `git diff --check` 通过。
|
||||||
|
|
||||||
已使用浏览器插件完成视觉验证:
|
已使用浏览器插件完成视觉验证:
|
||||||
|
|
||||||
|
- 重构后的初始 Markdown 和主题资源加载正常,快速预览生成 1 页;
|
||||||
|
- 编辑 Markdown 后标题、表格、Mermaid 和分页状态均完成防抖重绘;
|
||||||
|
- 从本地 GitHub 主题切换到内置 Typora 风格主题后 iframe 正常重建;
|
||||||
|
- 本轮浏览器回归期间控制台无警告或错误;
|
||||||
- 预览缩放在 50%、100%、150% 和 400% 下均按比例显示,快速与精确
|
- 预览缩放在 50%、100%、150% 和 400% 下均按比例显示,快速与精确
|
||||||
预览的分页结果不受缩放影响;
|
预览的分页结果不受缩放影响;
|
||||||
- 包含 ELK、Base 主题、Hand-drawn 外观、自定义字体、
|
- 包含 ELK、Base 主题、Hand-drawn 外观、自定义字体、
|
||||||
@@ -276,6 +301,9 @@ git diff --check
|
|||||||
|
|
||||||
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
|
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
|
||||||
- `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。
|
- `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。
|
||||||
|
- `apps/web/src/use-markdown-render.ts` 负责 Markdown 防抖请求、取消和
|
||||||
|
渲染错误状态。
|
||||||
|
- `apps/web/src/use-theme-resources.ts` 负责主题清单、主题 CSS 和请求取消。
|
||||||
- `apps/web/src/paged-document-runtime.ts` 是快速预览和 PDF 共用的 Mermaid、资源等待和 Paged.js 分页运行时。
|
- `apps/web/src/paged-document-runtime.ts` 是快速预览和 PDF 共用的 Mermaid、资源等待和 Paged.js 分页运行时。
|
||||||
- `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。
|
- `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。
|
||||||
- `apps/web/src/paged-preview.ts` 负责分页协议、共享文档 CSS、页眉和页码 CSS。
|
- `apps/web/src/paged-preview.ts` 负责分页协议、共享文档 CSS、页眉和页码 CSS。
|
||||||
@@ -288,6 +316,8 @@ git diff --check
|
|||||||
- `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。
|
- `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。
|
||||||
- `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。
|
- `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。
|
||||||
- `packages/core/src/export-config.ts` 是纸张、页边距、页眉页脚和页码的共享模型。
|
- `packages/core/src/export-config.ts` 是纸张、页边距、页眉页脚和页码的共享模型。
|
||||||
|
- `packages/core/src/document.ts` 是渲染文档、分页载荷、运行结果和耗时的
|
||||||
|
共享模型。
|
||||||
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
|
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
|
||||||
- `apps/server/src/theme-registry.ts` 负责内置和本地主题发现、CSS 处理及资源安全。
|
- `apps/server/src/theme-registry.ts` 负责内置和本地主题发现、CSS 处理及资源安全。
|
||||||
- `.local/themes/typora-*` 是用户本机副本,已被 Git 忽略,不得提交。
|
- `.local/themes/typora-*` 是用户本机副本,已被 Git 忽略,不得提交。
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ExportConfig } from "./export-config.js";
|
||||||
|
import type { ThemeFeature } from "./theme.js";
|
||||||
|
|
||||||
|
export interface MarkdownDocumentMetadata {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
subject: string;
|
||||||
|
keywords: string[];
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedMarkdownDocument {
|
||||||
|
rendererVersion: number;
|
||||||
|
articleHtml: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
metadata: MarkdownDocumentMetadata;
|
||||||
|
features: ThemeFeature[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedDocumentPayload {
|
||||||
|
articleHtml: string;
|
||||||
|
fileName: string;
|
||||||
|
metadata: MarkdownDocumentMetadata;
|
||||||
|
features: ThemeFeature[];
|
||||||
|
themeCss: string;
|
||||||
|
exportConfig: ExportConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePagedDocumentPayloadOptions {
|
||||||
|
document: RenderedMarkdownDocument;
|
||||||
|
fileName: string;
|
||||||
|
themeCss: string;
|
||||||
|
exportConfig: ExportConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPagedDocumentPayload({
|
||||||
|
document,
|
||||||
|
fileName,
|
||||||
|
themeCss,
|
||||||
|
exportConfig
|
||||||
|
}: CreatePagedDocumentPayloadOptions): PagedDocumentPayload {
|
||||||
|
return {
|
||||||
|
articleHtml: document.articleHtml,
|
||||||
|
fileName,
|
||||||
|
metadata: document.metadata,
|
||||||
|
features: document.features,
|
||||||
|
themeCss,
|
||||||
|
exportConfig
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedDocumentTimings {
|
||||||
|
setupMs: number;
|
||||||
|
mermaidMs: number;
|
||||||
|
mermaidFitMs: number;
|
||||||
|
mermaidConversionMs: number;
|
||||||
|
resourceWaitMs: number;
|
||||||
|
paginationMs: number;
|
||||||
|
finalizeMs: number;
|
||||||
|
totalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedDocumentRenderResult {
|
||||||
|
pageCount: number;
|
||||||
|
mermaidErrors: string[];
|
||||||
|
timings: PagedDocumentTimings;
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export * from "./document.js";
|
||||||
export * from "./export-config.js";
|
export * from "./export-config.js";
|
||||||
export * from "./theme.js";
|
export * from "./theme.js";
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createPagedDocumentPayload,
|
||||||
|
defaultExportConfig,
|
||||||
|
type RenderedMarkdownDocument
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
describe("分页文档载荷", () => {
|
||||||
|
it("从统一渲染结果提取预览与 PDF 共用字段", () => {
|
||||||
|
const document: RenderedMarkdownDocument = {
|
||||||
|
rendererVersion: 1,
|
||||||
|
articleHtml: '<article id="write"><h1>测试</h1></article>',
|
||||||
|
bodyHtml: "<h1>测试</h1>",
|
||||||
|
metadata: {
|
||||||
|
title: "测试",
|
||||||
|
author: "",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
features: ["table"],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createPagedDocumentPayload({
|
||||||
|
document,
|
||||||
|
fileName: "测试.md",
|
||||||
|
themeCss: "#write { color: #333; }",
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
articleHtml: document.articleHtml,
|
||||||
|
fileName: "测试.md",
|
||||||
|
metadata: document.metadata,
|
||||||
|
features: document.features,
|
||||||
|
themeCss: "#write { color: #333; }",
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,11 @@ import { anchor } from "@mdit/plugin-anchor";
|
|||||||
import { footnote } from "@mdit/plugin-footnote";
|
import { footnote } from "@mdit/plugin-footnote";
|
||||||
import { katex } from "@mdit/plugin-katex";
|
import { katex } from "@mdit/plugin-katex";
|
||||||
import { tasklist } from "@mdit/plugin-tasklist";
|
import { tasklist } from "@mdit/plugin-tasklist";
|
||||||
import type { ThemeFeature } from "@md-to-pdf/core";
|
import type {
|
||||||
|
MarkdownDocumentMetadata,
|
||||||
|
RenderedMarkdownDocument,
|
||||||
|
ThemeFeature
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
import matter from "gray-matter";
|
import matter from "gray-matter";
|
||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js";
|
||||||
import MarkdownIt from "markdown-it";
|
import MarkdownIt from "markdown-it";
|
||||||
@@ -22,25 +26,15 @@ export class MarkdownDocumentParseError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarkdownDocumentMetadata {
|
export type { MarkdownDocumentMetadata } from "@md-to-pdf/core";
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
subject: string;
|
|
||||||
keywords: string[];
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RenderMarkdownOptions {
|
export interface RenderMarkdownOptions {
|
||||||
language?: string;
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderedMarkdown {
|
export interface RenderedMarkdown
|
||||||
|
extends Omit<RenderedMarkdownDocument, "rendererVersion"> {
|
||||||
rendererVersion: typeof RENDERER_VERSION;
|
rendererVersion: typeof RENDERER_VERSION;
|
||||||
articleHtml: string;
|
|
||||||
bodyHtml: string;
|
|
||||||
metadata: MarkdownDocumentMetadata;
|
|
||||||
features: ThemeFeature[];
|
|
||||||
warnings: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownOptions: MarkdownItOptions = {
|
const markdownOptions: MarkdownItOptions = {
|
||||||
|
|||||||
Reference in New Issue
Block a user