3 Commits
38 changed files with 1758 additions and 391 deletions
+31 -3
View File
@@ -14,7 +14,8 @@
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid - 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid
- iframe 隔离的真实分页预览、双向滚动同步与主题切换; - iframe 隔离的真实分页预览、双向滚动同步与主题切换;
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测; - Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
- 五种纸张、页边距、页眉、页码和浏览器配置缓存; - 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存;
- 快速预览与精确预览共用的 50%~400% 显示缩放;
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具; - 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具;
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。 - 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
@@ -39,22 +40,49 @@ npm run dev
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。 编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。
## Mermaid 长标签 预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 PDF,并会单独保存在浏览器中。
流程图节点标签的默认最大宽度为 320px。单个 Mermaid 区块可以通过 config frontmatter 覆盖: ## Mermaid 配置与样式
导出设置可以统一控制整篇文档的 Mermaid 布局、主题、外观和字体:
- 布局:Dagre、ELK
- 主题:Mermaid 11.16.0 提供的 Default、Base、Dark、Forest、Neutral、Neo 和 Redux 系列;
- 外观:Classic、Hand-drawn、Neo
- 字体:任意本地 CSS 字体族。
未设置时使用 `dagre + default + classic`。单个 Mermaid 区块的 `config` Front Matter 优先于全局导出设置:
````markdown ````markdown
```mermaid ```mermaid
--- ---
config: config:
layout: elk
theme: base
look: handDrawn
fontFamily: Microsoft YaHei
themeVariables:
primaryColor: "#dbeafe"
primaryBorderColor: "#2563eb"
flowchart: flowchart:
wrappingWidth: 360 wrappingWidth: 360
--- ---
flowchart TB flowchart TB
classDef important fill:#fee2e2,stroke:#dc2626
A["较长的节点名称<br/>第二行说明"] --> B["下一个节点"] A["较长的节点名称<br/>第二行说明"] --> B["下一个节点"]
style A fill:#dcfce7,stroke:#16a34a
class B important
``` ```
```` ````
`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。
ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。
### Mermaid 长标签
流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。
需要自动换行时可以使用 Mermaid Markdown String 需要自动换行时可以使用 Mermaid Markdown String
````markdown ````markdown
+64 -19
View File
@@ -4,11 +4,16 @@ 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
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { RENDERER_VERSION, renderMarkdown } from "@md-to-pdf/renderer"; import {
MarkdownDocumentParseError,
RENDERER_VERSION,
renderMarkdown
} from "@md-to-pdf/renderer";
import { import {
createPdfGenerator, createPdfGenerator,
PdfEngineClosedError, PdfEngineClosedError,
@@ -66,6 +71,29 @@ function validateMarkdownRequest(
}; };
} }
function renderMarkdownRequest(
markdown: string,
language: string | undefined
) {
try {
return {
success: true as const,
document: renderMarkdown(markdown, {
...(language ? { language } : {})
})
};
} catch (error) {
if (error instanceof MarkdownDocumentParseError) {
return {
success: false as const,
error: error.code,
message: error.message
};
}
throw error;
}
}
function encodeRfc5987(value: string) { function encodeRfc5987(value: string) {
return encodeURIComponent(value).replace( return encodeURIComponent(value).replace(
/[!'()*]/g, /[!'()*]/g,
@@ -142,7 +170,8 @@ export function buildApp(options: BuildAppOptions = {}) {
const themes = createThemeRegistry({ const themes = createThemeRegistry({
bundledRoot: resolve(projectRoot, "themes"), bundledRoot: resolve(projectRoot, "themes"),
localRoot: localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes") options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
onWarning: (message) => app.log.warn(message)
}); });
const pdfGenerator = const pdfGenerator =
options.pdfGenerator ?? createPdfGenerator(); options.pdfGenerator ?? createPdfGenerator();
@@ -254,9 +283,17 @@ export function buildApp(options: BuildAppOptions = {}) {
}); });
} }
return renderMarkdown(validated.markdown, { const rendered = renderMarkdownRequest(
...(validated.language ? { language: validated.language } : {}) validated.markdown,
}); validated.language
);
if (!rendered.success) {
return reply.code(400).send({
error: rendered.error,
message: rendered.message
});
}
return rendered.document;
} }
); );
@@ -304,23 +341,31 @@ export function buildApp(options: BuildAppOptions = {}) {
} }
const markdownStartedAt = performance.now(); const markdownStartedAt = performance.now();
const rendered = renderMarkdown(validated.markdown, { const renderedResult = renderMarkdownRequest(
...(validated.language ? { language: validated.language } : {}) validated.markdown,
}); validated.language
);
const markdownMs = performance.now() - markdownStartedAt; const markdownMs = performance.now() - markdownStartedAt;
if (!renderedResult.success) {
return reply.code(400).send({
error: renderedResult.error,
message: renderedResult.message
});
}
const rendered = renderedResult.document;
try { try {
const generated = await pdfGenerator.generate({ const generated = await pdfGenerator.generate(
articleHtml: rendered.articleHtml, createPagedDocumentPayload({
fileName: document: rendered,
typeof request.body.fileName === "string" fileName:
? request.body.fileName typeof request.body.fileName === "string"
: "文档.md", ? request.body.fileName
metadata: rendered.metadata, : "文档.md",
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(
+102 -78
View File
@@ -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<
@@ -232,6 +206,46 @@ export function isAllowedPdfRequestUrl(
return url.origin === renderOrigin; return url.origin === renderOrigin;
} }
async function waitWithPdfTimeout<T>(
operation: Promise<T>,
timeoutMs: number
) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new PdfRenderTimeoutError(timeoutMs));
}, timeoutMs);
})
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
async function closeContextWithin(
context: Awaited<ReturnType<Browser["newContext"]>>,
timeoutMs: number
) {
let timeout: NodeJS.Timeout | undefined;
try {
await Promise.race([
context.close().catch(() => undefined),
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, Math.min(timeoutMs, 5_000));
})
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
export class PlaywrightPdfGenerator implements PdfGenerator { export class PlaywrightPdfGenerator implements PdfGenerator {
private readonly renderOrigin: string; private readonly renderOrigin: string;
private readonly renderUrl: string; private readonly renderUrl: string;
@@ -284,7 +298,7 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
if (this.closed) { if (this.closed) {
throw new PdfEngineClosedError(); throw new PdfEngineClosedError();
} }
await this.getBrowser(); await waitWithPdfTimeout(this.getBrowser(), this.timeoutMs);
} }
async close() { async close() {
@@ -330,57 +344,67 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
queuedAt: number, queuedAt: number,
queueMs: number queueMs: number
): Promise<PdfGenerationResult> { ): Promise<PdfGenerationResult> {
const browserStartedAt = performance.now(); let context:
const browser = await this.getBrowser(); | Awaited<ReturnType<Browser["newContext"]>>
const browserMs = performance.now() - browserStartedAt; | undefined;
const contextStartedAt = performance.now(); let expired = false;
const context = await browser.newContext({
locale: payload.metadata.language || "zh-CN",
serviceWorkers: "block"
});
const contextMs = performance.now() - contextStartedAt;
let timeout: NodeJS.Timeout | undefined;
try { try {
await context.route("**/*", async (route) => { const operation = (async (): Promise<PdfGenerationResult> => {
if ( const browserStartedAt = performance.now();
isAllowedPdfRequestUrl( const browser = await this.getBrowser();
route.request().url(), const browserMs = performance.now() - browserStartedAt;
this.renderOrigin if (expired) {
) throw new PdfRenderTimeoutError(this.timeoutMs);
) {
await route.continue();
return;
} }
await route.abort("blockedbyclient");
});
const operation = this.renderWithContext(context, payload); const contextStartedAt = performance.now();
const timeoutPromise = new Promise<never>((_, reject) => { const createdContext = await browser.newContext({
timeout = setTimeout(() => { locale: payload.metadata.language || "zh-CN",
reject(new PdfRenderTimeoutError(this.timeoutMs)); serviceWorkers: "block"
}, this.timeoutMs); });
}); context = createdContext;
const contextMs = performance.now() - contextStartedAt;
const generated = await Promise.race([ if (expired) {
operation, await closeContextWithin(createdContext, this.timeoutMs);
timeoutPromise throw new PdfRenderTimeoutError(this.timeoutMs);
]);
return {
...generated,
timings: {
queueMs,
browserMs,
contextMs,
...generated.timings,
totalMs: performance.now() - queuedAt
} }
};
await createdContext.route("**/*", async (route) => {
if (
isAllowedPdfRequestUrl(
route.request().url(),
this.renderOrigin
)
) {
await route.continue();
return;
}
await route.abort("blockedbyclient");
});
const generated = await this.renderWithContext(
createdContext,
payload
);
return {
...generated,
timings: {
queueMs,
browserMs,
contextMs,
...generated.timings,
totalMs: performance.now() - queuedAt
}
};
})();
return await waitWithPdfTimeout(operation, this.timeoutMs);
} finally { } finally {
if (timeout) { expired = true;
clearTimeout(timeout); if (context) {
await closeContextWithin(context, this.timeoutMs);
} }
await context.close().catch(() => undefined);
} }
} }
@@ -407,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) {
+45 -3
View File
@@ -26,6 +26,8 @@ export interface ThemeRecord {
export interface ThemeRegistryOptions { export interface ThemeRegistryOptions {
bundledRoot: string; bundledRoot: string;
localRoot: string; localRoot: string;
onWarning?: (message: string) => void;
cacheTtlMs?: number;
} }
const assetContentTypes: Record<string, string> = { const assetContentTypes: Record<string, string> = {
@@ -92,7 +94,8 @@ async function resolveThemeFile(directory: string, relativePath: string) {
async function readThemeRoot( async function readThemeRoot(
root: string, root: string,
source: ThemeRecord["source"] source: ThemeRecord["source"],
onWarning?: (message: string) => void
): Promise<ThemeRecord[]> { ): Promise<ThemeRecord[]> {
let entries; let entries;
try { try {
@@ -139,6 +142,10 @@ async function readThemeRoot(
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (source === "local") {
onWarning?.(`已忽略无效本地主题 ${entry.name}${message}`);
continue;
}
throw new Error(`主题 ${entry.name} 无效:${message}`); throw new Error(`主题 ${entry.name} 无效:${message}`);
} }
} }
@@ -269,10 +276,21 @@ 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") readThemeRoot(options.localRoot, "local", options.onWarning)
]); ]);
const themes = [...bundledThemes, ...localThemes]; const themes = [...bundledThemes, ...localThemes];
@@ -287,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);
} }
@@ -334,6 +375,7 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
get, get,
getAsset, getAsset,
getCss, getCss,
invalidate,
list list
}; };
} }
+125
View File
@@ -110,6 +110,20 @@ describe("预览 API", () => {
expect(response.json().error).toBe("INVALID_MARKDOWN"); expect(response.json().error).toBe("INVALID_MARKDOWN");
}); });
it("将无效 Front Matter 映射为文档请求错误", async () => {
app = buildApp({ logger: false });
const response = await app.inject({
method: "POST",
url: "/api/render",
payload: {
markdown: "---\ntitle: [未闭合\n---\n\n# 文档"
}
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("INVALID_FRONT_MATTER");
});
it("返回内置主题 CSS", async () => { it("返回内置主题 CSS", async () => {
app = buildApp(); app = buildApp();
const response = await app.inject({ const response = await app.inject({
@@ -237,6 +251,94 @@ describe("预览 API", () => {
); );
}); });
it("忽略无效本地主题而不影响其他主题", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
const bundledRoot = join(temporaryDirectory, "bundled");
const localRoot = join(temporaryDirectory, "local");
const validThemeRoot = join(localRoot, "valid-theme");
const invalidThemeRoot = join(localRoot, "invalid-theme");
await mkdir(bundledRoot, { recursive: true });
await mkdir(validThemeRoot, { recursive: true });
await mkdir(invalidThemeRoot, { recursive: true });
await writeFile(
join(validThemeRoot, "theme.json"),
JSON.stringify(localThemeManifest("valid-theme")),
"utf8"
);
await writeFile(
join(validThemeRoot, "theme.css"),
"#write { color: #333; }",
"utf8"
);
const onWarning = vi.fn();
const registry = createThemeRegistry({
bundledRoot,
localRoot,
onWarning
});
await expect(registry.list()).resolves.toEqual([
expect.objectContaining({
manifest: expect.objectContaining({ id: "valid-theme" })
})
]);
expect(onWarning).toHaveBeenCalledWith(
expect.stringContaining("invalid-theme")
);
});
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");
@@ -357,6 +459,29 @@ describe("PDF API", () => {
expect(response.json().error).toBe("INVALID_EXPORT_CONFIG"); expect(response.json().error).toBe("INVALID_EXPORT_CONFIG");
}); });
it("在 PDF 生成前拒绝无效 Front Matter", async () => {
const generate = vi.fn();
app = buildApp({
logger: false,
pdfGenerator: {
generate,
close: vi.fn(async () => undefined)
}
});
const response = await app.inject({
method: "POST",
url: "/api/pdf",
payload: {
markdown: "---\ntitle: [未闭合\n---\n",
exportConfig: defaultExportConfig
}
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("INVALID_FRONT_MATTER");
expect(generate).not.toHaveBeenCalled();
});
it("映射 PDF 队列已满错误", async () => { it("映射 PDF 队列已满错误", async () => {
app = buildApp({ app = buildApp({
logger: false, logger: false,
+50 -3
View File
@@ -29,7 +29,10 @@ function payload(): PdfRenderPayload {
}; };
} }
function fakeBrowser(options: { hangOnGoto?: boolean } = {}) { function fakeBrowser(
options: { hangOnGoto?: boolean; hangOnNewContext?: boolean } = {}
) {
let releaseNewContext: (() => void) | undefined;
const route = vi.fn(); const route = vi.fn();
const closeContext = vi.fn(async () => undefined); const closeContext = vi.fn(async () => undefined);
const closeBrowser = vi.fn(async () => undefined); const closeBrowser = vi.fn(async () => undefined);
@@ -44,7 +47,6 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
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,
@@ -65,7 +67,14 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
close: closeContext close: closeContext
}; };
const browser = { const browser = {
newContext: vi.fn(async () => context), newContext: options.hangOnNewContext
? vi.fn(
() =>
new Promise<typeof context>((resolve) => {
releaseNewContext = () => resolve(context);
})
)
: vi.fn(async () => context),
close: closeBrowser, close: closeBrowser,
on: vi.fn() on: vi.fn()
} as unknown as Browser; } as unknown as Browser;
@@ -77,6 +86,9 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
context, context,
page, page,
pdfBuffer, pdfBuffer,
releaseNewContext() {
releaseNewContext?.();
},
route route
}; };
} }
@@ -240,4 +252,39 @@ describe("Playwright PDF 生成器", () => {
expect(fake.closeContext).toHaveBeenCalledTimes(1); expect(fake.closeContext).toHaveBeenCalledTimes(1);
await generator.close(); await generator.close();
}); });
it("浏览器启动阶段也受生成超时限制", async () => {
const fake = fakeBrowser();
let releaseBrowser: (() => void) | undefined;
const generator = new PlaywrightPdfGenerator({
launchBrowser: () =>
new Promise<Browser>((resolve) => {
releaseBrowser = () => resolve(fake.browser);
}),
timeoutMs: 10
});
await expect(generator.generate(payload())).rejects.toBeInstanceOf(
PdfRenderTimeoutError
);
releaseBrowser?.();
await new Promise((resolve) => setTimeout(resolve, 0));
await generator.close();
});
it("上下文创建阶段也受生成超时限制并清理迟到上下文", async () => {
const fake = fakeBrowser({ hangOnNewContext: true });
const generator = new PlaywrightPdfGenerator({
launchBrowser: async () => fake.browser,
timeoutMs: 10
});
await expect(generator.generate(payload())).rejects.toBeInstanceOf(
PdfRenderTimeoutError
);
fake.releaseNewContext();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fake.closeContext).toHaveBeenCalledTimes(1);
await generator.close();
});
}); });
+2 -1
View File
@@ -11,9 +11,10 @@
}, },
"dependencies": { "dependencies": {
"@md-to-pdf/core": "0.1.0", "@md-to-pdf/core": "0.1.0",
"@mermaid-js/layout-elk": "0.2.2",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"mermaid": "^11.12.3", "mermaid": "11.16.0",
"pagedjs": "^0.4.3", "pagedjs": "^0.4.3",
"pdfjs-dist": "6.1.200", "pdfjs-dist": "6.1.200",
"react": "^19.2.0", "react": "^19.2.0",
+5
View File
@@ -7,6 +7,11 @@
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';" content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
html[data-rendering="true"] body {
visibility: hidden;
}
</style>
<title>分页预览</title> <title>分页预览</title>
</head> </head>
<body> <body>
+92 -146
View File
@@ -10,10 +10,13 @@ import {
useState useState
} from "react"; } from "react";
import { import {
createPagedDocumentPayload,
getPaperDimensionsMm, getPaperDimensionsMm,
millimetersToCssPixels,
type ExportConfig type ExportConfig
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { ExportSettingsDrawer } from "./ExportSettingsDrawer"; import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
import { PreviewZoomControl } from "./PreviewZoomControl";
import { import {
loadExportConfig, loadExportConfig,
resetExportConfig, resetExportConfig,
@@ -37,6 +40,15 @@ import {
type PdfExportRequest type PdfExportRequest
} from "./pdf-export"; } from "./pdf-export";
import { getSyncedScrollTop } from "./scroll-sync"; import { getSyncedScrollTop } from "./scroll-sync";
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 PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview"); const module = await import("./PrecisePdfPreview");
@@ -45,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: 内网文档团队
@@ -107,21 +97,18 @@ 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("");
const [exportingPdf, setExportingPdf] = useState(false); const [exportingPdf, setExportingPdf] = useState(false);
const [previewMode, setPreviewMode] = const [previewMode, setPreviewMode] =
useState<PreviewMode>("quick"); useState<PreviewMode>("quick");
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
const [pdfCache, setPdfCache] = useState<CachedPdfExport>(); const [pdfCache, setPdfCache] = useState<CachedPdfExport>();
const [previewFrameReady, setPreviewFrameReady] = useState(false); const [previewFrameReady, setPreviewFrameReady] = useState(false);
const [previewPagination, setPreviewPagination] = useState({ const [previewPagination, setPreviewPagination] = useState({
@@ -139,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;
@@ -151,21 +176,30 @@ export function App() {
exportConfig.paper.format, exportConfig.paper.format,
exportConfig.paper.orientation exportConfig.paper.orientation
); );
const previewScale = previewZoom / 100;
const naturalPaperWidth =
millimetersToCssPixels(paperDimensions.width) +
PREVIEW_SCROLLBAR_WIDTH_PX;
const scaledPaperWidth = naturalPaperWidth * previewScale;
const paperStyle = { const paperStyle = {
width: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)`, width: `${scaledPaperWidth}px`,
minWidth: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)` minWidth: `${scaledPaperWidth}px`
} satisfies CSSProperties;
const previewFrameStyle = {
width: `${100 / previewScale}%`,
height: `${100 / previewScale}%`,
transform: `scale(${previewScale})`,
transformOrigin: "top left"
} satisfies CSSProperties; } satisfies CSSProperties;
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]
); );
@@ -186,56 +220,24 @@ 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]);
useEffect(() => {
savePreviewZoom(previewZoom);
const frame = window.requestAnimationFrame(() => {
synchronizeScroll("editor");
updatePreviewCurrentPage();
});
return () => window.cancelAnimationFrame(frame);
}, [previewZoom]);
useEffect(() => { useEffect(() => {
if (pdfCache && pdfCache.key !== pdfCacheKey) { if (pdfCache && pdfCache.key !== pdfCacheKey) {
setPdfCache(undefined); setPdfCache(undefined);
@@ -244,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;
@@ -525,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 = "";
} }
@@ -581,6 +516,9 @@ export function App() {
} }
function handlePreviewModeChange(mode: PreviewMode) { function handlePreviewModeChange(mode: PreviewMode) {
if (mode === previewMode) {
return;
}
setPreviewMode(mode); setPreviewMode(mode);
setPreviewFrameReady(false); setPreviewFrameReady(false);
setPreviewPagination({ current: 0, total: 0 }); setPreviewPagination({ current: 0, total: 0 });
@@ -715,6 +653,12 @@ export function App() {
</label> </label>
</span> </span>
</div> </div>
<div className="preview-toolbar">
<PreviewZoomControl
value={previewZoom}
onChange={setPreviewZoom}
/>
</div>
<div <div
ref={previewScrollRef} ref={previewScrollRef}
className="preview-scroll" className="preview-scroll"
@@ -726,6 +670,7 @@ export function App() {
<iframe <iframe
ref={previewFrameRef} ref={previewFrameRef}
className="preview-frame" className="preview-frame"
style={previewFrameStyle}
title="文档内容预览" title="文档内容预览"
sandbox="allow-same-origin allow-scripts" sandbox="allow-same-origin allow-scripts"
src="/preview-frame.html" src="/preview-frame.html"
@@ -750,6 +695,7 @@ export function App() {
> >
<PrecisePdfPreview <PrecisePdfPreview
blob={cachedPdf.blob} blob={cachedPdf.blob}
zoomPercent={previewZoom}
onPageCount={handlePrecisePageCount} onPageCount={handlePrecisePageCount}
/> />
</Suspense> </Suspense>
+193 -15
View File
@@ -1,10 +1,15 @@
import { import {
getPaperDimensionsMm, getPaperDimensionsMm,
lengthToMillimeters,
paperFormatLabels, paperFormatLabels,
supportedMermaidLayouts,
supportedMermaidLooks,
supportedMermaidThemes,
supportedPaperFormats, supportedPaperFormats,
type ExportConfig, type ExportConfig,
type FooterConfig, type FooterConfig,
type HeaderConfig type HeaderConfig,
type MermaidExportConfig
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -30,9 +35,47 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
custom: "自定义模板" custom: "自定义模板"
}; };
function millimeters(value: string) { const mermaidLayoutLabels: Record<
return Number.parseFloat(value.replace(/mm$/i, "")); MermaidExportConfig["layout"],
} string
> = {
dagre: "Dagre",
elk: "ELK"
};
const mermaidThemeLabels: Record<
MermaidExportConfig["theme"],
string
> = {
default: "Default",
base: "Base(可定制基础主题)",
dark: "Dark",
forest: "Forest",
neutral: "Neutral",
neo: "Neo",
"neo-dark": "Neo Dark",
redux: "Redux",
"redux-dark": "Redux Dark",
"redux-color": "Redux Color",
"redux-dark-color": "Redux Dark Color"
};
const mermaidLookLabels: Record<
MermaidExportConfig["look"],
string
> = {
classic: "Classic",
handDrawn: "Hand-drawn",
neo: "Neo"
};
const mermaidFontFamilies = [
"Segoe UI, Microsoft YaHei, sans-serif",
"Microsoft YaHei, sans-serif",
"Arial, Helvetica, sans-serif",
"Georgia, Times New Roman, serif",
"Consolas, Microsoft YaHei, monospace"
] as const;
function fitMarginPair( function fitMarginPair(
first: number, first: number,
@@ -57,16 +100,18 @@ function LengthInput({
maximum, maximum,
onChange onChange
}: LengthInputProps) { }: LengthInputProps) {
const [draft, setDraft] = useState(String(millimeters(value))); const [draft, setDraft] = useState(
String(lengthToMillimeters(value))
);
useEffect(() => { useEffect(() => {
setDraft(String(millimeters(value))); setDraft(String(lengthToMillimeters(value)));
}, [value]); }, [value]);
function commit() { function commit() {
const number = Number.parseFloat(draft); const number = Number.parseFloat(draft);
if (!Number.isFinite(number)) { if (!Number.isFinite(number)) {
setDraft(String(millimeters(value))); setDraft(String(lengthToMillimeters(value)));
return; return;
} }
const normalized = Math.min(Math.max(number, 0), maximum); const normalized = Math.min(Math.max(number, 0), maximum);
@@ -99,6 +144,54 @@ function LengthInput({
); );
} }
function MermaidFontInput({
value,
onChange
}: {
value: string;
onChange: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
function commit() {
const normalized = draft.trim();
if (!normalized) {
setDraft(value);
return;
}
setDraft(normalized);
onChange(normalized);
}
return (
<label className="setting-field stacked">
<span></span>
<input
type="text"
list="mermaid-font-family-options"
maxLength={300}
value={draft}
onBlur={commit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<datalist id="mermaid-font-family-options">
{mermaidFontFamilies.map((fontFamily) => (
<option key={fontFamily} value={fontFamily} />
))}
</datalist>
</label>
);
}
function updateHeaderSlot( function updateHeaderSlot(
header: HeaderConfig, header: HeaderConfig,
slot: "left" | "center" | "right", slot: "left" | "center" | "right",
@@ -137,13 +230,13 @@ export function ExportSettingsDrawer({
nextPaper.orientation nextPaper.orientation
); );
const [left, right] = fitMarginPair( const [left, right] = fitMarginPair(
millimeters(nextPaper.margins.left), lengthToMillimeters(nextPaper.margins.left),
millimeters(nextPaper.margins.right), lengthToMillimeters(nextPaper.margins.right),
nextDimensions.width - 1 nextDimensions.width - 1
); );
const [top, bottom] = fitMarginPair( const [top, bottom] = fitMarginPair(
millimeters(nextPaper.margins.top), lengthToMillimeters(nextPaper.margins.top),
millimeters(nextPaper.margins.bottom), lengthToMillimeters(nextPaper.margins.bottom),
nextDimensions.height - 1 nextDimensions.height - 1
); );
@@ -193,6 +286,16 @@ export function ExportSettingsDrawer({
}); });
} }
function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
onChange({
...config,
mermaid: {
...config.mermaid,
...mermaid
}
});
}
return ( return (
<div className="settings-layer"> <div className="settings-layer">
<button <button
@@ -276,7 +379,9 @@ export function ExportSettingsDrawer({
label="上" label="上"
value={margins.top} value={margins.top}
maximum={ maximum={
dimensions.height - millimeters(margins.bottom) - verticalGap dimensions.height -
lengthToMillimeters(margins.bottom) -
verticalGap
} }
onChange={(value) => updateMargin("top", value)} onChange={(value) => updateMargin("top", value)}
/> />
@@ -284,7 +389,9 @@ export function ExportSettingsDrawer({
label="右" label="右"
value={margins.right} value={margins.right}
maximum={ maximum={
dimensions.width - millimeters(margins.left) - horizontalGap dimensions.width -
lengthToMillimeters(margins.left) -
horizontalGap
} }
onChange={(value) => updateMargin("right", value)} onChange={(value) => updateMargin("right", value)}
/> />
@@ -292,7 +399,9 @@ export function ExportSettingsDrawer({
label="下" label="下"
value={margins.bottom} value={margins.bottom}
maximum={ maximum={
dimensions.height - millimeters(margins.top) - verticalGap dimensions.height -
lengthToMillimeters(margins.top) -
verticalGap
} }
onChange={(value) => updateMargin("bottom", value)} onChange={(value) => updateMargin("bottom", value)}
/> />
@@ -300,13 +409,82 @@ export function ExportSettingsDrawer({
label="左" label="左"
value={margins.left} value={margins.left}
maximum={ maximum={
dimensions.width - millimeters(margins.right) - horizontalGap dimensions.width -
lengthToMillimeters(margins.right) -
horizontalGap
} }
onChange={(value) => updateMargin("left", value)} onChange={(value) => updateMargin("left", value)}
/> />
</div> </div>
</section> </section>
<section className="settings-section">
<h3>Mermaid </h3>
<label className="setting-field">
<span></span>
<select
value={config.mermaid.layout}
onChange={(event) =>
updateMermaid({
layout: event.target
.value as MermaidExportConfig["layout"]
})
}
>
{supportedMermaidLayouts.map((layout) => (
<option key={layout} value={layout}>
{mermaidLayoutLabels[layout]}
</option>
))}
</select>
</label>
<label className="setting-field">
<span></span>
<select
value={config.mermaid.theme}
onChange={(event) =>
updateMermaid({
theme: event.target
.value as MermaidExportConfig["theme"]
})
}
>
{supportedMermaidThemes.map((theme) => (
<option key={theme} value={theme}>
{mermaidThemeLabels[theme]}
</option>
))}
</select>
</label>
<label className="setting-field">
<span></span>
<select
value={config.mermaid.look}
onChange={(event) =>
updateMermaid({
look: event.target
.value as MermaidExportConfig["look"]
})
}
>
{supportedMermaidLooks.map((look) => (
<option key={look} value={look}>
{mermaidLookLabels[look]}
</option>
))}
</select>
</label>
<MermaidFontInput
value={config.mermaid.fontFamily}
onChange={(fontFamily) => updateMermaid({ fontFamily })}
/>
<p className="setting-hint">
Mermaid config frontmatter
styleclassDef themeVariables
Mermaid
</p>
</section>
<section className="settings-section"> <section className="settings-section">
<div className="setting-section-title"> <div className="setting-section-title">
<h3></h3> <h3></h3>
+9 -2
View File
@@ -32,6 +32,7 @@ interface PdfPageDescriptor {
export interface PrecisePdfPreviewProps { export interface PrecisePdfPreviewProps {
blob: Blob; blob: Blob;
zoomPercent: number;
onPageCount?: (pageCount: number) => void; onPageCount?: (pageCount: number) => void;
} }
@@ -39,6 +40,7 @@ interface PrecisePdfPageProps {
document: PDFDocumentProxy; document: PDFDocumentProxy;
descriptor: PdfPageDescriptor; descriptor: PdfPageDescriptor;
renderQueue: PdfPageRenderQueue; renderQueue: PdfPageRenderQueue;
zoomPercent: number;
} }
function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) { function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
@@ -90,7 +92,8 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
function PrecisePdfPage({ function PrecisePdfPage({
document, document,
descriptor, descriptor,
renderQueue renderQueue,
zoomPercent
}: PrecisePdfPageProps) { }: PrecisePdfPageProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -103,10 +106,12 @@ function PrecisePdfPage({
const nearViewport = useNearViewport(containerRef); const nearViewport = useNearViewport(containerRef);
const dimensions = getPdfPageCssDimensions( const dimensions = getPdfPageCssDimensions(
descriptor.width, descriptor.width,
descriptor.height descriptor.height,
zoomPercent / 100
); );
const pageStyle = { const pageStyle = {
width: `${dimensions.width}px`, width: `${dimensions.width}px`,
maxWidth: `${zoomPercent}%`,
aspectRatio: `${descriptor.width} / ${descriptor.height}` aspectRatio: `${descriptor.width} / ${descriptor.height}`
} satisfies CSSProperties; } satisfies CSSProperties;
@@ -293,6 +298,7 @@ function PrecisePdfPage({
export function PrecisePdfPreview({ export function PrecisePdfPreview({
blob, blob,
zoomPercent,
onPageCount onPageCount
}: PrecisePdfPreviewProps) { }: PrecisePdfPreviewProps) {
const [document, setDocument] = useState<PDFDocumentProxy>(); const [document, setDocument] = useState<PDFDocumentProxy>();
@@ -381,6 +387,7 @@ export function PrecisePdfPreview({
document={document} document={document}
descriptor={page} descriptor={page}
renderQueue={renderQueue} renderQueue={renderQueue}
zoomPercent={zoomPercent}
/> />
))} ))}
</div> </div>
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import {
DEFAULT_PREVIEW_ZOOM,
MAXIMUM_PREVIEW_ZOOM,
MINIMUM_PREVIEW_ZOOM,
normalizePreviewZoom,
parsePreviewZoom,
PREVIEW_ZOOM_SLIDER_STEP
} from "./preview-zoom";
interface PreviewZoomControlProps {
value: number;
onChange: (value: number) => void;
}
export function PreviewZoomControl({
value,
onChange
}: PreviewZoomControlProps) {
const [draft, setDraft] = useState(String(value));
useEffect(() => {
setDraft(String(value));
}, [value]);
function commitDraft() {
const normalized = parsePreviewZoom(draft, value);
setDraft(String(normalized));
onChange(normalized);
}
return (
<div
className="preview-zoom-control"
role="group"
aria-label="预览缩放"
>
<span></span>
<input
className="preview-zoom-slider"
type="range"
aria-label="预览缩放滑块"
min={MINIMUM_PREVIEW_ZOOM}
max={MAXIMUM_PREVIEW_ZOOM}
step={PREVIEW_ZOOM_SLIDER_STEP}
value={value}
onChange={(event) =>
onChange(normalizePreviewZoom(Number(event.target.value)))
}
/>
<label className="preview-zoom-number">
<span className="sr-only"></span>
<input
type="number"
aria-label="预览缩放百分比"
min={MINIMUM_PREVIEW_ZOOM}
max={MAXIMUM_PREVIEW_ZOOM}
step="1"
value={draft}
onBlur={commitDraft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<span>%</span>
</label>
<button
type="button"
className="preview-zoom-reset"
disabled={value === DEFAULT_PREVIEW_ZOOM}
onClick={() => onChange(DEFAULT_PREVIEW_ZOOM)}
>
100%
</button>
</div>
);
}
+27 -3
View File
@@ -4,7 +4,9 @@ import {
type ExportConfig type ExportConfig
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v2"; export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3";
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY =
"md-to-pdf.export-config.v2";
type SettingsStorage = Pick<Storage, "getItem" | "setItem">; type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
@@ -18,7 +20,19 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
} }
try { try {
const parsed = exportConfigSchema.safeParse(JSON.parse(rawValue)); const value: unknown = JSON.parse(rawValue);
const migratedValue =
typeof value === "object" &&
value !== null &&
"version" in value &&
value.version === 2
? {
...value,
version: defaultExportConfig.version,
mermaid: structuredClone(defaultExportConfig.mermaid)
}
: value;
const parsed = exportConfigSchema.safeParse(migratedValue);
return parsed.success ? parsed.data : cloneDefaultExportConfig(); return parsed.success ? parsed.data : cloneDefaultExportConfig();
} catch { } catch {
return cloneDefaultExportConfig(); return cloneDefaultExportConfig();
@@ -28,7 +42,17 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
export function loadExportConfig( export function loadExportConfig(
storage: SettingsStorage = window.localStorage storage: SettingsStorage = window.localStorage
) { ) {
return parseStoredExportConfig(storage.getItem(EXPORT_CONFIG_STORAGE_KEY)); const currentValue = storage.getItem(EXPORT_CONFIG_STORAGE_KEY);
if (currentValue) {
return parseStoredExportConfig(currentValue);
}
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY);
const config = parseStoredExportConfig(legacyValue);
if (legacyValue) {
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
}
return config;
} }
export function saveExportConfig( export function saveExportConfig(
+8 -4
View File
@@ -1,3 +1,4 @@
import type { MermaidExportConfig } from "@md-to-pdf/core";
import type { MermaidConfig } from "mermaid"; import type { MermaidConfig } from "mermaid";
export const MERMAID_SECURE_CONFIG_KEYS = [ export const MERMAID_SECURE_CONFIG_KEYS = [
@@ -10,13 +11,16 @@ export const MERMAID_SECURE_CONFIG_KEYS = [
"themeCSS" "themeCSS"
]; ];
export function createMermaidSiteConfig(): MermaidConfig { export function createMermaidSiteConfig(
config: MermaidExportConfig
): MermaidConfig {
return { return {
startOnLoad: false, startOnLoad: false,
securityLevel: "strict", securityLevel: "strict",
theme: "default", layout: config.layout,
look: "classic", theme: config.theme,
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif", look: config.look,
fontFamily: config.fontFamily,
flowchart: { flowchart: {
wrappingWidth: 320 wrappingWidth: 320
}, },
+99 -36
View File
@@ -1,11 +1,19 @@
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 { fitOversizedMermaidToPage } from "./mermaid-page-fit"; import {
fitOversizedMermaidToPage,
getPageContentDimensions
} from "./mermaid-page-fit";
import { renderMermaidDefinitions } from "./mermaid-renderer"; import { renderMermaidDefinitions } from "./mermaid-renderer";
import { replaceMermaidSvgWithImages } from "./mermaid-static-image"; import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
import type { MermaidOutputMode } from "./mermaid-static-image"; import type { MermaidOutputMode } from "./mermaid-static-image";
import { enablePrintMediaForPreview } from "./preview-styles";
import "./paged-table-handler"; import "./paged-table-handler";
import { import {
buildPagedMediaCss, buildPagedMediaCss,
@@ -16,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;
@@ -70,6 +65,54 @@ function stylesheet(
}; };
} }
function mountMeasurementContainer(
documentRef: Document,
content: DocumentFragment,
payload: PagedPreviewPayload,
target: PagedRenderTarget
) {
const pageContent = getPageContentDimensions(payload.exportConfig);
const style = documentRef.createElement("style");
const themeCss =
target === "preview"
? enablePrintMediaForPreview(payload.themeCss)
: payload.themeCss;
style.dataset.pagedMeasurementStyle = "true";
style.textContent = [
documentBaseCss,
highlightCss,
katexCss,
themeCss,
documentGeometryCss
].join("\n");
const host = documentRef.createElement("div");
host.dataset.pagedMeasurementHost = "true";
host.style.cssText = [
"position: fixed",
"left: -100000px",
"top: 0",
`width: ${pageContent.width}px`,
"height: auto",
"opacity: 0",
"pointer-events: none",
"contain: layout style paint"
].join(";");
documentRef.head.append(style);
host.append(content);
documentRef.body.append(host);
return {
host,
restore() {
content.append(...Array.from(host.childNodes));
host.remove();
style.remove();
}
};
}
function applyPageNumbers( function applyPageNumbers(
container: ParentNode, container: ParentNode,
payload: PagedPreviewPayload, payload: PagedPreviewPayload,
@@ -131,9 +174,12 @@ export class PagedDocumentRuntime {
private loadMermaid() { private loadMermaid() {
if (!this.mermaidPromise) { if (!this.mermaidPromise) {
this.mermaidPromise = import("mermaid").then( this.mermaidPromise = Promise.all([
({ default: mermaid }) => { import("mermaid"),
mermaid.initialize(createMermaidSiteConfig()); import("@mermaid-js/layout-elk")
]).then(
([{ default: mermaid }, { default: elkLayouts }]) => {
mermaid.registerLayoutLoaders(elkLayouts);
return mermaid; return mermaid;
} }
); );
@@ -160,6 +206,9 @@ export class PagedDocumentRuntime {
} }
const mermaid = await this.loadMermaid(); const mermaid = await this.loadMermaid();
mermaid.initialize(
createMermaidSiteConfig(payload.exportConfig.mermaid)
);
const outcomes = await renderMermaidDefinitions( const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""), nodes.map((node) => node.textContent ?? ""),
async (definition) => { async (definition) => {
@@ -227,16 +276,37 @@ export class PagedDocumentRuntime {
const mermaidErrors = await this.renderMermaid(content, payload); const mermaidErrors = await this.renderMermaid(content, payload);
const mermaidMs = performance.now() - mermaidStartedAt; const mermaidMs = performance.now() - mermaidStartedAt;
const mermaidFitStartedAt = performance.now(); let mermaidFitMs = 0;
fitOversizedMermaidToPage(content, payload.exportConfig); let mermaidConversionMs = 0;
const mermaidFitMs = performance.now() - mermaidFitStartedAt; if (content.querySelector(".mermaid svg")) {
const measurement = mountMeasurementContainer(
documentRef,
content,
payload,
options.target
);
try {
await documentRef.fonts.ready;
await waitForImages(measurement.host);
const mermaidConversionStartedAt = performance.now(); const mermaidFitStartedAt = performance.now();
if (options.mermaidOutput === "svg-image") { fitOversizedMermaidToPage(
replaceMermaidSvgWithImages(content); measurement.host,
payload.exportConfig
);
mermaidFitMs = performance.now() - mermaidFitStartedAt;
const mermaidConversionStartedAt = performance.now();
if (options.mermaidOutput === "svg-image") {
replaceMermaidSvgWithImages(measurement.host);
await waitForImages(measurement.host);
}
mermaidConversionMs =
performance.now() - mermaidConversionStartedAt;
} finally {
measurement.restore();
}
} }
const mermaidConversionMs =
performance.now() - mermaidConversionStartedAt;
if (!shouldContinue()) { if (!shouldContinue()) {
return undefined; return undefined;
@@ -285,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,
+25 -14
View File
@@ -7,6 +7,10 @@ import {
} from "./paged-preview"; } from "./paged-preview";
import { resolvePagedRenderTarget } from "./paged-render-target"; import { resolvePagedRenderTarget } from "./paged-render-target";
import { resolveMermaidOutputMode } from "./mermaid-static-image"; import { resolveMermaidOutputMode } from "./mermaid-static-image";
import {
beginPreviewFrameRender,
completePreviewFrameRender
} from "./preview-frame-visibility";
function getPreviewRoot() { function getPreviewRoot() {
const element = document.querySelector<HTMLElement>("#preview-root"); const element = document.querySelector<HTMLElement>("#preview-root");
@@ -37,27 +41,34 @@ async function renderPagedPreview(
return; return;
} }
beginPreviewFrameRender(document.documentElement, requestId);
post({ post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE, scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendering", type: "rendering",
requestId requestId
}); });
const result = await runtime.render(payload, { try {
target: renderTarget, const result = await runtime.render(payload, {
mermaidOutput, target: renderTarget,
shouldContinue: () => requestId === latestRequestId mermaidOutput,
}); shouldContinue: () => requestId === latestRequestId
if (!result) { });
return; if (!result) {
} return;
}
post({ post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE, scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered", type: "rendered",
requestId, requestId,
...result ...result
}); });
} finally {
if (requestId === latestRequestId) {
completePreviewFrameRender(document.documentElement, requestId);
}
}
} }
window.__mdToPdfRender = async ( window.__mdToPdfRender = async (
+5 -18
View File
@@ -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[];
} }
| { | {
+9 -5
View File
@@ -1,15 +1,19 @@
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;
export function getPdfPageCssDimensions( export function getPdfPageCssDimensions(
widthPoints: number, widthPoints: number,
heightPoints: number heightPoints: number,
displayScale = 1
) { ) {
return { return {
width: widthPoints * PDF_PAGE_CSS_SCALE, width: widthPoints * PDF_PAGE_CSS_SCALE * displayScale,
height: heightPoints * PDF_PAGE_CSS_SCALE height: heightPoints * PDF_PAGE_CSS_SCALE * displayScale
}; };
} }
+24
View File
@@ -0,0 +1,24 @@
interface PreviewFrameVisibilityTarget {
dataset: DOMStringMap;
}
export function beginPreviewFrameRender(
target: PreviewFrameVisibilityTarget,
requestId: number
) {
target.dataset.rendering = "true";
target.dataset.renderingRequestId = String(requestId);
}
export function completePreviewFrameRender(
target: PreviewFrameVisibilityTarget,
requestId: number
) {
if (target.dataset.renderingRequestId !== String(requestId)) {
return false;
}
delete target.dataset.rendering;
delete target.dataset.renderingRequestId;
return true;
}
+54
View File
@@ -0,0 +1,54 @@
export const PREVIEW_ZOOM_STORAGE_KEY = "md-to-pdf.preview-zoom.v1";
export const DEFAULT_PREVIEW_ZOOM = 100;
export const MINIMUM_PREVIEW_ZOOM = 50;
export const MAXIMUM_PREVIEW_ZOOM = 400;
export const PREVIEW_ZOOM_SLIDER_STEP = 5;
type PreviewZoomStorage = Pick<Storage, "getItem" | "setItem">;
export function normalizePreviewZoom(value: number) {
if (!Number.isFinite(value)) {
return DEFAULT_PREVIEW_ZOOM;
}
return Math.min(
MAXIMUM_PREVIEW_ZOOM,
Math.max(MINIMUM_PREVIEW_ZOOM, Math.round(value))
);
}
export function parsePreviewZoom(
value: string | null,
fallback = DEFAULT_PREVIEW_ZOOM
) {
if (!value?.trim()) {
return normalizePreviewZoom(fallback);
}
const parsed = Number(value);
return Number.isFinite(parsed)
? normalizePreviewZoom(parsed)
: normalizePreviewZoom(fallback);
}
export function loadPreviewZoom(
storage: PreviewZoomStorage = window.localStorage
) {
try {
return parsePreviewZoom(storage.getItem(PREVIEW_ZOOM_STORAGE_KEY));
} catch {
return DEFAULT_PREVIEW_ZOOM;
}
}
export function savePreviewZoom(
value: number,
storage: PreviewZoomStorage = window.localStorage
) {
try {
storage.setItem(
PREVIEW_ZOOM_STORAGE_KEY,
String(normalizePreviewZoom(value))
);
} catch {
// 预览缩放是非关键偏好,浏览器禁用缓存时保持当前会话值即可。
}
}
+87 -2
View File
@@ -176,6 +176,78 @@ h1 {
max-width: 68%; max-width: 68%;
} }
.preview-toolbar {
display: flex;
flex: none;
min-height: 44px;
align-items: center;
justify-content: flex-end;
padding: 6px 20px;
border-bottom: 1px solid #d9dedb;
background: rgb(255 255 255 / 58%);
}
.preview-zoom-control {
display: flex;
align-items: center;
gap: 9px;
color: #5d7168;
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
}
.preview-zoom-slider {
width: 150px;
accent-color: #4f8a6f;
}
.preview-zoom-number {
display: flex;
width: 68px;
height: 30px;
align-items: center;
border: 1px solid #c9d4ce;
border-radius: 7px;
background: #fff;
}
.preview-zoom-number:focus-within {
border-color: #74a88f;
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
}
.preview-zoom-number input {
min-width: 0;
width: 100%;
height: 28px;
padding: 0 2px 0 8px;
border: 0;
outline: 0;
background: transparent;
color: #356c55;
font: inherit;
font-weight: 700;
text-align: right;
}
.preview-zoom-number > span:last-child {
padding-right: 7px;
color: #7a8580;
}
.preview-zoom-reset {
min-height: 30px;
padding: 0 8px;
border-color: #c9d4ce;
background: #f8fbf9;
color: #4f695e;
font-size: 0.68rem;
}
.preview-zoom-reset:disabled {
color: #9ba49f;
}
.preview-page-indicator { .preview-page-indicator {
flex: none; flex: none;
color: #5d7168; color: #5d7168;
@@ -319,7 +391,7 @@ textarea:focus {
display: flex; display: flex;
min-width: 100%; min-width: 100%;
flex-direction: column; flex-direction: column;
align-items: center; align-items: safe center;
gap: 24px; gap: 24px;
padding: 34px 30px; padding: 34px 30px;
} }
@@ -327,7 +399,6 @@ textarea:focus {
.precise-pdf-page { .precise-pdf-page {
position: relative; position: relative;
flex: none; flex: none;
max-width: 100%;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
box-shadow: 0 18px 48px rgb(37 49 43 / 16%); box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
@@ -775,6 +846,20 @@ textarea:focus {
flex-wrap: wrap; flex-wrap: wrap;
} }
.preview-toolbar {
justify-content: center;
padding-inline: 10px;
}
.preview-zoom-control {
width: 100%;
}
.preview-zoom-slider {
flex: 1;
width: auto;
}
.theme-control select { .theme-control select {
max-width: 150px; max-width: 150px;
} }
+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
};
}
+37 -7
View File
@@ -2,24 +2,32 @@ import { describe, expect, it } from "vitest";
import { defaultExportConfig } from "@md-to-pdf/core"; import { defaultExportConfig } from "@md-to-pdf/core";
import { import {
EXPORT_CONFIG_STORAGE_KEY, EXPORT_CONFIG_STORAGE_KEY,
LEGACY_EXPORT_CONFIG_STORAGE_KEY,
loadExportConfig, loadExportConfig,
parseStoredExportConfig, parseStoredExportConfig,
saveExportConfig saveExportConfig
} from "../src/export-settings"; } from "../src/export-settings";
function createMemoryStorage(initialValue: string | null = null) { function createMemoryStorage(
let value = initialValue; initialValue: string | null = null,
legacyValue: string | null = null
) {
const values = new Map<string, string>();
if (initialValue) {
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
}
if (legacyValue) {
values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue);
}
return { return {
getItem(key: string) { getItem(key: string) {
return key === EXPORT_CONFIG_STORAGE_KEY ? value : null; return values.get(key) ?? null;
}, },
setItem(key: string, nextValue: string) { setItem(key: string, nextValue: string) {
if (key === EXPORT_CONFIG_STORAGE_KEY) { values.set(key, nextValue);
value = nextValue;
}
}, },
read() { read() {
return value; return values.get(EXPORT_CONFIG_STORAGE_KEY) ?? null;
} }
}; };
} }
@@ -44,6 +52,28 @@ describe("导出设置缓存", () => {
).toEqual(defaultExportConfig); ).toEqual(defaultExportConfig);
}); });
it("将 v2 缓存迁移为 v3 并保留已有设置", () => {
const legacyConfig = {
...defaultExportConfig,
version: 2,
mermaid: undefined,
paper: {
...defaultExportConfig.paper,
format: "A5"
}
};
const storage = createMemoryStorage(
null,
JSON.stringify(legacyConfig)
);
const migrated = loadExportConfig(storage);
expect(migrated.version).toBe(3);
expect(migrated.paper.format).toBe("A5");
expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid);
expect(storage.read()).toContain('"version":3');
});
it("保存并恢复经过校验的配置", () => { it("保存并恢复经过校验的配置", () => {
const storage = createMemoryStorage(); const storage = createMemoryStorage();
const config = { const config = {
+20 -3
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { defaultExportConfig } from "@md-to-pdf/core";
import { import {
createMermaidSiteConfig, createMermaidSiteConfig,
MERMAID_SECURE_CONFIG_KEYS MERMAID_SECURE_CONFIG_KEYS
@@ -6,7 +7,7 @@ import {
describe("Mermaid 站点配置", () => { describe("Mermaid 站点配置", () => {
it("默认使用 default 主题和 classic 外观", () => { it("默认使用 default 主题和 classic 外观", () => {
const config = createMermaidSiteConfig(); const config = createMermaidSiteConfig(defaultExportConfig.mermaid);
expect(config.theme).toBe("default"); expect(config.theme).toBe("default");
expect(config.look).toBe("classic"); expect(config.look).toBe("classic");
expect(config.securityLevel).toBe("strict"); expect(config.securityLevel).toBe("strict");
@@ -29,9 +30,25 @@ describe("Mermaid 站点配置", () => {
}); });
it("每次创建独立的 secure 配置数组", () => { it("每次创建独立的 secure 配置数组", () => {
const first = createMermaidSiteConfig(); const first = createMermaidSiteConfig(defaultExportConfig.mermaid);
const second = createMermaidSiteConfig(); const second = createMermaidSiteConfig(defaultExportConfig.mermaid);
expect(first.secure).toEqual(second.secure); expect(first.secure).toEqual(second.secure);
expect(first.secure).not.toBe(second.secure); expect(first.secure).not.toBe(second.secure);
}); });
it("应用导出配置中的布局、主题、外观和字体", () => {
const config = createMermaidSiteConfig({
layout: "elk",
theme: "forest",
look: "handDrawn",
fontFamily: "Microsoft YaHei, sans-serif"
});
expect(config).toMatchObject({
layout: "elk",
theme: "forest",
look: "handDrawn",
fontFamily: "Microsoft YaHei, sans-serif"
});
});
}); });
-1
View File
@@ -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);
+9
View File
@@ -16,6 +16,15 @@ describe("精确 PDF 页面尺寸", () => {
}); });
}); });
it("按预览缩放比例调整 PDF 页面显示尺寸", () => {
expect(
getPdfPageCssDimensions(595.275591, 841.889764, 1.5)
).toEqual({
width: expect.closeTo(1190.551182, 5),
height: expect.closeTo(1683.779528, 5)
});
});
it("结合显示宽度和设备像素比计算 Canvas 比例", () => { it("结合显示宽度和设备像素比计算 Canvas 比例", () => {
expect(getPdfCanvasRenderScale(600, 800, 2)).toBeCloseTo( expect(getPdfCanvasRenderScale(600, 800, 2)).toBeCloseTo(
8 / 3 8 / 3
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
beginPreviewFrameRender,
completePreviewFrameRender
} from "../src/preview-frame-visibility";
function createTarget() {
return {
dataset: {} as DOMStringMap
};
}
describe("分页 iframe 渲染可见性", () => {
it("渲染期间隐藏,当前请求完成后恢复显示", () => {
const target = createTarget();
beginPreviewFrameRender(target, 12);
expect(target.dataset).toMatchObject({
rendering: "true",
renderingRequestId: "12"
});
expect(completePreviewFrameRender(target, 12)).toBe(true);
expect(target.dataset.rendering).toBeUndefined();
expect(target.dataset.renderingRequestId).toBeUndefined();
});
it("旧请求完成时不会提前显示新请求的半成品", () => {
const target = createTarget();
beginPreviewFrameRender(target, 12);
beginPreviewFrameRender(target, 13);
expect(completePreviewFrameRender(target, 12)).toBe(false);
expect(target.dataset).toMatchObject({
rendering: "true",
renderingRequestId: "13"
});
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_PREVIEW_ZOOM,
MAXIMUM_PREVIEW_ZOOM,
MINIMUM_PREVIEW_ZOOM,
PREVIEW_ZOOM_STORAGE_KEY,
loadPreviewZoom,
normalizePreviewZoom,
parsePreviewZoom,
savePreviewZoom
} from "../src/preview-zoom";
function createMemoryStorage(initialValue: string | null = null) {
let value = initialValue;
return {
getItem(key: string) {
return key === PREVIEW_ZOOM_STORAGE_KEY ? value : null;
},
setItem(key: string, nextValue: string) {
if (key === PREVIEW_ZOOM_STORAGE_KEY) {
value = nextValue;
}
},
read() {
return value;
}
};
}
describe("预览缩放", () => {
it("默认使用 100%,并限制在 50% 到 400%", () => {
expect(parsePreviewZoom(null)).toBe(DEFAULT_PREVIEW_ZOOM);
expect(normalizePreviewZoom(20)).toBe(MINIMUM_PREVIEW_ZOOM);
expect(normalizePreviewZoom(450)).toBe(MAXIMUM_PREVIEW_ZOOM);
expect(normalizePreviewZoom(124.6)).toBe(125);
});
it("无效手动输入回退到当前缩放值", () => {
expect(parsePreviewZoom("", 135)).toBe(135);
expect(parsePreviewZoom("not-a-number", 135)).toBe(135);
});
it("保存并恢复浏览器缩放偏好", () => {
const storage = createMemoryStorage();
savePreviewZoom(145, storage);
expect(storage.read()).toBe("145");
expect(loadPreviewZoom(storage)).toBe(145);
});
});
+64 -9
View File
@@ -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: 初始化项目骨架
``` ```
最新阶段已完成 Chromium PDF 导出、精确预览、PDF.js 渲染缓冲和 Mermaid 分页性能优化。 最新阶段已完成渲染边界逻辑修复,以及前后端分页载荷、文档类型、
物理单位、主题资源加载和 Markdown 请求逻辑的复用收敛。
## 2. 已完成 ## 2. 已完成
@@ -46,7 +49,7 @@ ec48bce chore: 初始化项目骨架
`packages/core/src/export-config.ts` 已包含: `packages/core/src/export-config.ts` 已包含:
- 配置版本号 - 版本 3 配置模型
- A3、A4、A5、Letter、Legal、Tabloid 和自定义纸张; - A3、A4、A5、Letter、Legal、Tabloid 和自定义纸张;
- 横向、纵向; - 横向、纵向;
- 四边页边距; - 四边页边距;
@@ -54,6 +57,7 @@ ec48bce chore: 初始化项目骨架
- 页码位置和格式; - 页码位置和格式;
- PDF 元数据; - PDF 元数据;
- 打印背景、缩放和一级标题分页选项; - 打印背景、缩放和一级标题分页选项;
- Mermaid 布局、主题、外观和字体;
- Zod 数据校验; - Zod 数据校验;
- 默认 A4 技术文档预设。 - 默认 A4 技术文档预设。
@@ -127,7 +131,7 @@ ec48bce chore: 初始化项目骨架
提交 `52cf816` 已完成: 提交 `52cf816` 已完成:
- 导出配置升级到版本 2 - 导出配置升级到版本 3,并兼容迁移版本 2 浏览器缓存
- 纸张收敛为 A3、A4、A5、US-Letter 和 US-Legal - 纸张收敛为 A3、A4、A5、US-Letter 和 US-Legal
- 默认 A4 纵向和 16mm 四边页边距; - 默认 A4 纵向和 16mm 四边页边距;
- 统一 96 CSS px/in、72 PDF pt/in 和 25.4mm/in 的物理单位换算; - 统一 96 CSS px/in、72 PDF pt/in 和 25.4mm/in 的物理单位换算;
@@ -136,6 +140,7 @@ ec48bce chore: 初始化项目骨架
- 页眉左中右内容和变量; - 页眉左中右内容和变量;
- 五种页码样式、对齐和起始页码; - 五种页码样式、对齐和起始页码;
- Mermaid 官方配置 frontmatter、默认主题与错误隔离; - Mermaid 官方配置 frontmatter、默认主题与错误隔离;
- Mermaid 全局 Dagre/ELK 布局、官方主题、外观和字体设置;
- Markdown 表格对齐属性保留和自适应列宽; - Markdown 表格对齐属性保留和自适应列宽;
- 纸张尺寸和页边距实时预览。 - 纸张尺寸和页边距实时预览。
@@ -190,9 +195,44 @@ ec48bce chore: 初始化项目骨架
- 增加容器健康检查和统一进程退出处理。 - 增加容器健康检查和统一进程退出处理。
- Nginx 显式以 JavaScript MIME 类型提供 PDF.js `.mjs` Worker。 - Nginx 显式以 JavaScript MIME 类型提供 PDF.js `.mjs` Worker。
### 3.6 Mermaid 配置与预览缩放
- Mermaid 固定为 `11.16.0`ELK 使用官方
`@mermaid-js/layout-elk@0.2.2` 并按需加载核心布局代码。
- 全局导出设置支持 Dagre、ELK、官方主题、Classic、Hand-drawn、Neo
外观和自定义本地字体。
- 单图 `config` Front Matter 可以覆盖全局布局、主题、外观、字体、
`themeVariables` 和图表专属配置。
- `style``classDef``linkStyle` 保持 Mermaid 原生语义。
- `themeCSS`、严格安全模式和资源限制不能由 Markdown 覆盖。
- Mermaid 与 Paged.js 计算期间隐藏分页 iframe,避免显示官方渲染器的
临时测量节点。
- 快速预览和精确预览共用 50%~400% 显示缩放,支持滑块、手动输入、
100% 重置和独立浏览器缓存。
- 缩放不进入导出配置,不改变分页或 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. 已执行验证 ## 4. 已执行验证
2026-07-26 在当前完整工作区成功执行: 2026-07-27 在当前完整工作区成功执行:
```text ```text
npm test npm test
@@ -203,16 +243,25 @@ git diff --check
结果: 结果:
- 共享配置测试:6 项通过; - 共享配置测试:9 项通过;
- 渲染器测试:7 项通过; - 渲染器测试:8 项通过;
- 前端测试:47 项通过; - 前端测试:55 项通过;
- 后端测试:17 项通过; - 后端测试:29 项通过;
- 全项目类型检查通过; - 全项目类型检查通过;
- 生产构建通过; - 生产构建通过;
- `git diff --check` 通过。 - `git diff --check` 通过。
已使用浏览器插件完成视觉验证: 已使用浏览器插件完成视觉验证:
- 重构后的初始 Markdown 和主题资源加载正常,快速预览生成 1 页;
- 编辑 Markdown 后标题、表格、Mermaid 和分页状态均完成防抖重绘;
- 从本地 GitHub 主题切换到内置 Typora 风格主题后 iframe 正常重建;
- 本轮浏览器回归期间控制台无警告或错误;
- 预览缩放在 50%、100%、150% 和 400% 下均按比例显示,快速与精确
预览的分页结果不受缩放影响;
- 包含 ELK、Base 主题、Hand-drawn 外观、自定义字体、
`themeVariables``style``classDef` 的 Mermaid 样例在快速预览
与精确预览中均成功渲染;
- 65 页附件的快速预览生成 64 页,67 个 Mermaid 全部使用静态 SVG - 65 页附件的快速预览生成 64 页,67 个 Mermaid 全部使用静态 SVG
- 同一附件的精确预览和最终 PDF 均为 65 页; - 同一附件的精确预览和最终 PDF 均为 65 页;
- 精确预览快速跳转至第 46、47、65 页,无黑色 Canvas、空白页或渲染错误; - 精确预览快速跳转至第 46、47、65 页,无黑色 Canvas、空白页或渲染错误;
@@ -252,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。
@@ -259,10 +311,13 @@ git diff --check
- `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。 - `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。
- `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理。 - `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理。
- `apps/web/src/PrecisePdfPreview.tsx``pdf-render-queue.ts` 负责 PDF.js 精确预览和 Canvas 生命周期。 - `apps/web/src/PrecisePdfPreview.tsx``pdf-render-queue.ts` 负责 PDF.js 精确预览和 Canvas 生命周期。
- `apps/web/src/preview-zoom.ts``PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
- `apps/server/src/pdf-engine.ts` 负责 Chromium 生命周期、并发、网络限制、分页调用和 PDF 输出。 - `apps/server/src/pdf-engine.ts` 负责 Chromium 生命周期、并发、网络限制、分页调用和 PDF 输出。
- `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 忽略,不得提交。
+21 -1
View File
@@ -39,9 +39,10 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@md-to-pdf/core": "0.1.0", "@md-to-pdf/core": "0.1.0",
"@mermaid-js/layout-elk": "0.2.2",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"mermaid": "^11.12.3", "mermaid": "11.16.0",
"pagedjs": "^0.4.3", "pagedjs": "^0.4.3",
"pdfjs-dist": "6.1.200", "pdfjs-dist": "6.1.200",
"react": "^19.2.0", "react": "^19.2.0",
@@ -1145,6 +1146,19 @@
} }
} }
}, },
"node_modules/@mermaid-js/layout-elk": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@mermaid-js/layout-elk/-/layout-elk-0.2.2.tgz",
"integrity": "sha512-vnH3gtqfhyBiRVKNpT8iDENTw18q/OF0GF/SfYfHN43KZpu+6eZDEOMHTfNYAkpmUWJNgtRQFIS6BTc7vH/DYQ==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
"elkjs": "^0.9.3"
},
"peerDependencies": {
"mermaid": "^11.0.2"
}
},
"node_modules/@mermaid-js/parser": { "node_modules/@mermaid-js/parser": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
@@ -3333,6 +3347,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/elkjs": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz",
"integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==",
"license": "EPL-2.0"
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+68
View File
@@ -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;
}
+36 -1
View File
@@ -1,10 +1,30 @@
import { z } from "zod"; import { z } from "zod";
export const EXPORT_CONFIG_VERSION = 2; export const EXPORT_CONFIG_VERSION = 3;
export const CSS_PIXELS_PER_INCH = 96; export const CSS_PIXELS_PER_INCH = 96;
export const PDF_POINTS_PER_INCH = 72; export const PDF_POINTS_PER_INCH = 72;
export const MILLIMETERS_PER_INCH = 25.4; export const MILLIMETERS_PER_INCH = 25.4;
export const supportedMermaidLayouts = ["dagre", "elk"] as const;
export const supportedMermaidThemes = [
"default",
"base",
"dark",
"forest",
"neutral",
"neo",
"neo-dark",
"redux",
"redux-dark",
"redux-color",
"redux-dark-color"
] as const;
export const supportedMermaidLooks = [
"classic",
"handDrawn",
"neo"
] as const;
export const supportedPaperFormats = [ export const supportedPaperFormats = [
"A3", "A3",
"A4", "A4",
@@ -147,10 +167,18 @@ const paperSchema = z
} }
}); });
export const mermaidConfigSchema = z.object({
layout: z.enum(supportedMermaidLayouts),
theme: z.enum(supportedMermaidThemes),
look: z.enum(supportedMermaidLooks),
fontFamily: z.string().trim().min(1).max(300)
});
export const exportConfigSchema = z.object({ export const exportConfigSchema = z.object({
version: z.literal(EXPORT_CONFIG_VERSION), version: z.literal(EXPORT_CONFIG_VERSION),
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
themeId: z.string().min(1), themeId: z.string().min(1),
mermaid: mermaidConfigSchema,
paper: paperSchema, paper: paperSchema,
header: headerSchema, header: headerSchema,
footer: footerSchema, footer: footerSchema,
@@ -169,6 +197,7 @@ export const exportConfigSchema = z.object({
}); });
export type ExportConfig = z.infer<typeof exportConfigSchema>; export type ExportConfig = z.infer<typeof exportConfigSchema>;
export type MermaidExportConfig = ExportConfig["mermaid"];
export type PaperFormat = ExportConfig["paper"]["format"]; export type PaperFormat = ExportConfig["paper"]["format"];
export type PaperOrientation = ExportConfig["paper"]["orientation"]; export type PaperOrientation = ExportConfig["paper"]["orientation"];
export type HeaderConfig = ExportConfig["header"]; export type HeaderConfig = ExportConfig["header"];
@@ -193,6 +222,12 @@ export const defaultExportConfig: ExportConfig = {
version: EXPORT_CONFIG_VERSION, version: EXPORT_CONFIG_VERSION,
name: "默认 A4 文档", name: "默认 A4 文档",
themeId: "typora-github", themeId: "typora-github",
mermaid: {
layout: "dagre",
theme: "default",
look: "classic",
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif"
},
paper: { paper: {
format: "A4", format: "A4",
orientation: "portrait", orientation: "portrait",
+1
View File
@@ -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";
+41
View File
@@ -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
});
});
});
+40
View File
@@ -7,6 +7,9 @@ import {
millimetersToCssPixels, millimetersToCssPixels,
millimetersToPdfPoints, millimetersToPdfPoints,
paperDimensionsMm, paperDimensionsMm,
supportedMermaidLayouts,
supportedMermaidLooks,
supportedMermaidThemes,
supportedPaperFormats supportedPaperFormats
} from "../src/export-config.js"; } from "../src/export-config.js";
@@ -42,6 +45,43 @@ describe("导出配置", () => {
expect(exportConfigSchema.safeParse(defaultExportConfig).success).toBe(true); expect(exportConfigSchema.safeParse(defaultExportConfig).success).toBe(true);
}); });
it("提供 Mermaid 官方布局、主题、外观和字体默认值", () => {
expect(supportedMermaidLayouts).toEqual(["dagre", "elk"]);
expect(supportedMermaidLooks).toEqual([
"classic",
"handDrawn",
"neo"
]);
expect(supportedMermaidThemes).toContain("redux-dark-color");
expect(defaultExportConfig.mermaid).toEqual({
layout: "dagre",
theme: "default",
look: "classic",
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif"
});
});
it("拒绝 Mermaid 未支持的导出配置", () => {
expect(
exportConfigSchema.safeParse({
...defaultExportConfig,
mermaid: {
...defaultExportConfig.mermaid,
layout: "unknown"
}
}).success
).toBe(false);
expect(
exportConfigSchema.safeParse({
...defaultExportConfig,
mermaid: {
...defaultExportConfig.mermaid,
fontFamily: ""
}
}).success
).toBe(false);
});
it("使用统一的 96 CSS px/in 和 72 PDF pt/in 换算物理尺寸", () => { it("使用统一的 96 CSS px/in 和 72 PDF pt/in 换算物理尺寸", () => {
expect(millimetersToCssPixels(25.4)).toBeCloseTo(96); expect(millimetersToCssPixels(25.4)).toBeCloseTo(96);
expect(cssPixelsToMillimeters(96)).toBeCloseTo(25.4); expect(cssPixelsToMillimeters(96)).toBeCloseTo(25.4);
+20 -16
View File
@@ -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";
@@ -13,25 +17,24 @@ import sanitizeHtml from "sanitize-html";
export const RENDERER_VERSION = 1; export const RENDERER_VERSION = 1;
export interface MarkdownDocumentMetadata { export class MarkdownDocumentParseError extends Error {
title: string; readonly code = "INVALID_FRONT_MATTER";
author: string;
subject: string; constructor(message: string, options?: ErrorOptions) {
keywords: string[]; super(message, options);
language: string; this.name = "MarkdownDocumentParseError";
}
} }
export type { MarkdownDocumentMetadata } from "@md-to-pdf/core";
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 = {
@@ -198,9 +201,10 @@ function parseFrontMatter(source: string) {
return matter(source); return matter(source);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "未知错误"; const message = error instanceof Error ? error.message : "未知错误";
throw new Error(`无法解析 Markdown Front Matter${message}`, { throw new MarkdownDocumentParseError(
cause: error `无法解析 Markdown Front Matter${message}`,
}); { cause: error }
);
} }
} }
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { renderMarkdown } from "../src/render-markdown.js"; import {
MarkdownDocumentParseError,
renderMarkdown
} from "../src/render-markdown.js";
describe("renderMarkdown", () => { describe("renderMarkdown", () => {
it("渲染常用 Markdown 扩展并识别能力", () => { it("渲染常用 Markdown 扩展并识别能力", () => {
@@ -71,6 +74,17 @@ lang: zh-CN
}); });
}); });
it("使用稳定错误类型报告无效 Front Matter", () => {
expect(() =>
renderMarkdown(`---
title: [未闭合
---
# 文档
`)
).toThrow(MarkdownDocumentParseError);
});
it("服务端渲染公式并保留 Mermaid 安全占位", () => { it("服务端渲染公式并保留 Mermaid 安全占位", () => {
const result = renderMarkdown(` const result = renderMarkdown(`
内联公式 $E=mc^2$ 内联公式 $E=mc^2$
@@ -95,21 +109,34 @@ flowchart LR
--- ---
title: Frontmatter 示例 title: Frontmatter 示例
config: config:
layout: elk
theme: forest theme: forest
look: handDrawn look: handDrawn
fontFamily: Microsoft YaHei
themeVariables:
primaryColor: "#dbeafe"
flowchart: flowchart:
curve: basis curve: basis
--- ---
flowchart LR flowchart LR
classDef important fill:#fee2e2,stroke:#dc2626
A[开始] --> B[结束] A[开始] --> B[结束]
style A fill:#dcfce7,stroke:#16a34a
class B important
\`\`\` \`\`\`
`); `);
expect(result.bodyHtml).toContain("title: Frontmatter 示例"); expect(result.bodyHtml).toContain("title: Frontmatter 示例");
expect(result.bodyHtml).toContain("layout: elk");
expect(result.bodyHtml).toContain("theme: forest"); expect(result.bodyHtml).toContain("theme: forest");
expect(result.bodyHtml).toContain("look: handDrawn"); expect(result.bodyHtml).toContain("look: handDrawn");
expect(result.bodyHtml).toContain("fontFamily: Microsoft YaHei");
expect(result.bodyHtml).toContain("primaryColor:");
expect(result.bodyHtml).toContain("curve: basis"); expect(result.bodyHtml).toContain("curve: basis");
expect(result.bodyHtml).toContain("flowchart LR"); expect(result.bodyHtml).toContain("flowchart LR");
expect(result.bodyHtml).toContain("classDef important");
expect(result.bodyHtml).toContain("style A fill:");
expect(result.bodyHtml).toContain("class B important");
}); });
it("阻止原始 HTML 和危险链接形成可执行内容", () => { it("阻止原始 HTML 和危险链接形成可执行内容", () => {