diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 0032303..552cbc6 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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( diff --git a/apps/server/src/pdf-engine.ts b/apps/server/src/pdf-engine.ts index 1cc1eee..8bcc231 100644 --- a/apps/server/src/pdf-engine.ts +++ b/apps/server/src/pdf-engine.ts @@ -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 { timings: Pick< @@ -457,13 +431,13 @@ export class PlaywrightPdfGenerator implements PdfGenerator { const documentRenderStartedAt = performance.now(); const renderResult = await page.evaluate( - async (renderPayload): Promise => { + async (renderPayload): Promise => { const renderer = ( window as typeof window & { __mdToPdfRender?: ( payload: PdfRenderPayload, target: "pdf" - ) => Promise; + ) => Promise; } ).__mdToPdfRender; if (!renderer) { diff --git a/apps/server/src/theme-registry.ts b/apps/server/src/theme-registry.ts index 6ebb5ff..fe7a33f 100644 --- a/apps/server/src/theme-registry.ts +++ b/apps/server/src/theme-registry.ts @@ -27,6 +27,7 @@ export interface ThemeRegistryOptions { bundledRoot: string; localRoot: string; onWarning?: (message: string) => void; + cacheTtlMs?: number; } const assetContentTypes: Record = { @@ -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; + } + | 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 }; } diff --git a/apps/server/tests/app.test.ts b/apps/server/tests/app.test.ts index 08d17af..e9d88af 100644 --- a/apps/server/tests/app.test.ts +++ b/apps/server/tests/app.test.ts @@ -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"); diff --git a/apps/server/tests/pdf-engine.test.ts b/apps/server/tests/pdf-engine.test.ts index a9a5f2e..b075a11 100644 --- a/apps/server/tests/pdf-engine.test.ts +++ b/apps/server/tests/pdf-engine.test.ts @@ -47,7 +47,6 @@ function fakeBrowser( waitForFunction: vi.fn(async () => undefined), evaluate: vi.fn(async () => ({ pageCount: 2, - contentHeight: 2200, mermaidErrors: [], timings: { setupMs: 1, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f203727..8d1e2a7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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(null); - const [themes, setThemes] = useState([]); const [exportConfig, setExportConfig] = useState(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( () => 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) { 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 = ""; } diff --git a/apps/web/src/paged-document-runtime.ts b/apps/web/src/paged-document-runtime.ts index 71ec3f1..fcf04f0 100644 --- a/apps/web/src/paged-document-runtime.ts +++ b/apps/web/src/paged-document-runtime.ts @@ -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, diff --git a/apps/web/src/paged-preview.ts b/apps/web/src/paged-preview.ts index a9df118..24a783e 100644 --- a/apps/web/src/paged-preview.ts +++ b/apps/web/src/paged-preview.ts @@ -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[]; } | { diff --git a/apps/web/src/pdf-page-geometry.ts b/apps/web/src/pdf-page-geometry.ts index 8be05bf..1c839e6 100644 --- a/apps/web/src/pdf-page-geometry.ts +++ b/apps/web/src/pdf-page-geometry.ts @@ -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; diff --git a/apps/web/src/use-markdown-render.ts b/apps/web/src/use-markdown-render.ts new file mode 100644 index 0000000..9f5781b --- /dev/null +++ b/apps/web/src/use-markdown-render.ts @@ -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(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 }; +} diff --git a/apps/web/src/use-theme-resources.ts b/apps/web/src/use-theme-resources.ts new file mode 100644 index 0000000..4bd5f6a --- /dev/null +++ b/apps/web/src/use-theme-resources.ts @@ -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([]); + 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 + }; +} diff --git a/apps/web/tests/paged-preview.test.ts b/apps/web/tests/paged-preview.test.ts index 2cc3301..d5ffe47 100644 --- a/apps/web/tests/paged-preview.test.ts +++ b/apps/web/tests/paged-preview.test.ts @@ -178,7 +178,6 @@ describe("分页预览协议", () => { type: "rendered", requestId: 3, pageCount: 2, - contentHeight: 2300, mermaidErrors: [] }) ).toBe(true); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 4e8ab8a..c78b047 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Markdown PDF 导出器进度 -最后更新:2026-07-26 +最后更新:2026-07-27 ## 1. 当前概况 @@ -19,6 +19,8 @@ main 已有提交: ```text +b459def fix: 修复预览与 PDF 渲染边界问题 +d93728b feat: 完善 Mermaid 配置与预览缩放 d1b5880 feat: 实现 Chromium PDF 导出与精确预览 d625523 fix: 修复长文档分页与滚动同步 1d93f31 feat: 实现真实分页预览 @@ -29,7 +31,8 @@ fa07472 feat: 实现网页实时预览 ec48bce chore: 初始化项目骨架 ``` -最新阶段已完成 Mermaid 全局导出配置、ELK 布局、代码块配置覆盖、重绘隐藏和两种预览模式共用的显示缩放。 +最新阶段已完成渲染边界逻辑修复,以及前后端分页载荷、文档类型、 +物理单位、主题资源加载和 Markdown 请求逻辑的复用收敛。 ## 2. 已完成 @@ -209,9 +212,27 @@ ec48bce chore: 初始化项目骨架 - 缩放不进入导出配置,不改变分页或 PDF;精确预览会按显示宽度重绘 邻近 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. 已执行验证 -2026-07-26 在当前完整工作区成功执行: +2026-07-27 在当前完整工作区成功执行: ```text npm test @@ -222,16 +243,20 @@ git diff --check 结果: -- 共享配置测试:8 项通过; -- 渲染器测试:7 项通过; +- 共享配置测试:9 项通过; +- 渲染器测试:8 项通过; - 前端测试:55 项通过; -- 后端测试:23 项通过; +- 后端测试:29 项通过; - 全项目类型检查通过; - 生产构建通过; - `git diff --check` 通过。 已使用浏览器插件完成视觉验证: +- 重构后的初始 Markdown 和主题资源加载正常,快速预览生成 1 页; +- 编辑 Markdown 后标题、表格、Mermaid 和分页状态均完成防抖重绘; +- 从本地 GitHub 主题切换到内置 Typora 风格主题后 iframe 正常重建; +- 本轮浏览器回归期间控制台无警告或错误; - 预览缩放在 50%、100%、150% 和 400% 下均按比例显示,快速与精确 预览的分页结果不受缩放影响; - 包含 ELK、Base 主题、Hand-drawn 外观、自定义字体、 @@ -276,6 +301,9 @@ git diff --check - `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。 - `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-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。 - `apps/web/src/paged-preview.ts` 负责分页协议、共享文档 CSS、页眉和页码 CSS。 @@ -288,6 +316,8 @@ git diff --check - `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。 - `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。 - `packages/core/src/export-config.ts` 是纸张、页边距、页眉页脚和页码的共享模型。 +- `packages/core/src/document.ts` 是渲染文档、分页载荷、运行结果和耗时的 + 共享模型。 - `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。 - `apps/server/src/theme-registry.ts` 负责内置和本地主题发现、CSS 处理及资源安全。 - `.local/themes/typora-*` 是用户本机副本,已被 Git 忽略,不得提交。 diff --git a/packages/core/src/document.ts b/packages/core/src/document.ts new file mode 100644 index 0000000..0aee31d --- /dev/null +++ b/packages/core/src/document.ts @@ -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; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 65b441d..d71f12f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,3 @@ +export * from "./document.js"; export * from "./export-config.js"; export * from "./theme.js"; diff --git a/packages/core/tests/document.test.ts b/packages/core/tests/document.test.ts new file mode 100644 index 0000000..0a8c615 --- /dev/null +++ b/packages/core/tests/document.test.ts @@ -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: '

测试

', + bodyHtml: "

测试

", + 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 + }); + }); +}); diff --git a/packages/renderer/src/render-markdown.ts b/packages/renderer/src/render-markdown.ts index 53a6b55..18b58e1 100644 --- a/packages/renderer/src/render-markdown.ts +++ b/packages/renderer/src/render-markdown.ts @@ -2,7 +2,11 @@ import { anchor } from "@mdit/plugin-anchor"; import { footnote } from "@mdit/plugin-footnote"; import { katex } from "@mdit/plugin-katex"; 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 hljs from "highlight.js"; import MarkdownIt from "markdown-it"; @@ -22,25 +26,15 @@ export class MarkdownDocumentParseError extends Error { } } -export interface MarkdownDocumentMetadata { - title: string; - author: string; - subject: string; - keywords: string[]; - language: string; -} +export type { MarkdownDocumentMetadata } from "@md-to-pdf/core"; export interface RenderMarkdownOptions { language?: string; } -export interface RenderedMarkdown { +export interface RenderedMarkdown + extends Omit { rendererVersion: typeof RENDERER_VERSION; - articleHtml: string; - bodyHtml: string; - metadata: MarkdownDocumentMetadata; - features: ThemeFeature[]; - warnings: string[]; } const markdownOptions: MarkdownItOptions = {