fix: 修复预览与 PDF 渲染边界问题
This commit is contained in:
+51
-7
@@ -8,7 +8,11 @@ import {
|
|||||||
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 +70,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 +169,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,10 +282,18 @@ 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;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
app.post<{ Body: PdfRequestBody }>(
|
app.post<{ Body: PdfRequestBody }>(
|
||||||
@@ -304,10 +340,18 @@ 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({
|
||||||
|
|||||||
@@ -232,6 +232,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 +324,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,19 +370,33 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
|||||||
queuedAt: number,
|
queuedAt: number,
|
||||||
queueMs: number
|
queueMs: number
|
||||||
): Promise<PdfGenerationResult> {
|
): Promise<PdfGenerationResult> {
|
||||||
|
let context:
|
||||||
|
| Awaited<ReturnType<Browser["newContext"]>>
|
||||||
|
| undefined;
|
||||||
|
let expired = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const operation = (async (): Promise<PdfGenerationResult> => {
|
||||||
const browserStartedAt = performance.now();
|
const browserStartedAt = performance.now();
|
||||||
const browser = await this.getBrowser();
|
const browser = await this.getBrowser();
|
||||||
const browserMs = performance.now() - browserStartedAt;
|
const browserMs = performance.now() - browserStartedAt;
|
||||||
|
if (expired) {
|
||||||
|
throw new PdfRenderTimeoutError(this.timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
const contextStartedAt = performance.now();
|
const contextStartedAt = performance.now();
|
||||||
const context = await browser.newContext({
|
const createdContext = await browser.newContext({
|
||||||
locale: payload.metadata.language || "zh-CN",
|
locale: payload.metadata.language || "zh-CN",
|
||||||
serviceWorkers: "block"
|
serviceWorkers: "block"
|
||||||
});
|
});
|
||||||
|
context = createdContext;
|
||||||
const contextMs = performance.now() - contextStartedAt;
|
const contextMs = performance.now() - contextStartedAt;
|
||||||
let timeout: NodeJS.Timeout | undefined;
|
if (expired) {
|
||||||
|
await closeContextWithin(createdContext, this.timeoutMs);
|
||||||
|
throw new PdfRenderTimeoutError(this.timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
await createdContext.route("**/*", async (route) => {
|
||||||
await context.route("**/*", async (route) => {
|
|
||||||
if (
|
if (
|
||||||
isAllowedPdfRequestUrl(
|
isAllowedPdfRequestUrl(
|
||||||
route.request().url(),
|
route.request().url(),
|
||||||
@@ -355,17 +409,10 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
|||||||
await route.abort("blockedbyclient");
|
await route.abort("blockedbyclient");
|
||||||
});
|
});
|
||||||
|
|
||||||
const operation = this.renderWithContext(context, payload);
|
const generated = await this.renderWithContext(
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
createdContext,
|
||||||
timeout = setTimeout(() => {
|
payload
|
||||||
reject(new PdfRenderTimeoutError(this.timeoutMs));
|
);
|
||||||
}, this.timeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
const generated = await Promise.race([
|
|
||||||
operation,
|
|
||||||
timeoutPromise
|
|
||||||
]);
|
|
||||||
return {
|
return {
|
||||||
...generated,
|
...generated,
|
||||||
timings: {
|
timings: {
|
||||||
@@ -376,11 +423,14 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
|||||||
totalMs: performance.now() - queuedAt
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface ThemeRecord {
|
|||||||
export interface ThemeRegistryOptions {
|
export interface ThemeRegistryOptions {
|
||||||
bundledRoot: string;
|
bundledRoot: string;
|
||||||
localRoot: string;
|
localRoot: string;
|
||||||
|
onWarning?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetContentTypes: Record<string, string> = {
|
const assetContentTypes: Record<string, string> = {
|
||||||
@@ -92,7 +93,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 +141,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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,7 +278,7 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
|||||||
async function list() {
|
async function list() {
|
||||||
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];
|
||||||
|
|||||||
@@ -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,42 @@ 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("拒绝主题 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 +407,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,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -65,7 +68,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 +87,9 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
|
|||||||
context,
|
context,
|
||||||
page,
|
page,
|
||||||
pdfBuffer,
|
pdfBuffer,
|
||||||
|
releaseNewContext() {
|
||||||
|
releaseNewContext?.();
|
||||||
|
},
|
||||||
route
|
route
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -240,4 +253,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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -608,6 +608,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 });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
|
lengthToMillimeters,
|
||||||
paperFormatLabels,
|
paperFormatLabels,
|
||||||
supportedMermaidLayouts,
|
supportedMermaidLayouts,
|
||||||
supportedMermaidLooks,
|
supportedMermaidLooks,
|
||||||
@@ -76,10 +77,6 @@ const mermaidFontFamilies = [
|
|||||||
"Consolas, Microsoft YaHei, monospace"
|
"Consolas, Microsoft YaHei, monospace"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function millimeters(value: string) {
|
|
||||||
return Number.parseFloat(value.replace(/mm$/i, ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fitMarginPair(
|
function fitMarginPair(
|
||||||
first: number,
|
first: number,
|
||||||
second: number,
|
second: number,
|
||||||
@@ -103,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);
|
||||||
@@ -231,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
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -380,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)}
|
||||||
/>
|
/>
|
||||||
@@ -388,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)}
|
||||||
/>
|
/>
|
||||||
@@ -396,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)}
|
||||||
/>
|
/>
|
||||||
@@ -404,7 +409,9 @@ 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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ 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 { 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,
|
||||||
@@ -70,6 +74,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,
|
||||||
@@ -233,16 +285,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;
|
||||||
|
|
||||||
|
let mermaidFitMs = 0;
|
||||||
|
let mermaidConversionMs = 0;
|
||||||
|
if (content.querySelector(".mermaid svg")) {
|
||||||
|
const measurement = mountMeasurementContainer(
|
||||||
|
documentRef,
|
||||||
|
content,
|
||||||
|
payload,
|
||||||
|
options.target
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await documentRef.fonts.ready;
|
||||||
|
await waitForImages(measurement.host);
|
||||||
|
|
||||||
const mermaidFitStartedAt = performance.now();
|
const mermaidFitStartedAt = performance.now();
|
||||||
fitOversizedMermaidToPage(content, payload.exportConfig);
|
fitOversizedMermaidToPage(
|
||||||
const mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
measurement.host,
|
||||||
|
payload.exportConfig
|
||||||
|
);
|
||||||
|
mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
||||||
|
|
||||||
const mermaidConversionStartedAt = performance.now();
|
const mermaidConversionStartedAt = performance.now();
|
||||||
if (options.mermaidOutput === "svg-image") {
|
if (options.mermaidOutput === "svg-image") {
|
||||||
replaceMermaidSvgWithImages(content);
|
replaceMermaidSvgWithImages(measurement.host);
|
||||||
|
await waitForImages(measurement.host);
|
||||||
}
|
}
|
||||||
const mermaidConversionMs =
|
mermaidConversionMs =
|
||||||
performance.now() - mermaidConversionStartedAt;
|
performance.now() - mermaidConversionStartedAt;
|
||||||
|
} finally {
|
||||||
|
measurement.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldContinue()) {
|
if (!shouldContinue()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ import sanitizeHtml from "sanitize-html";
|
|||||||
|
|
||||||
export const RENDERER_VERSION = 1;
|
export const RENDERER_VERSION = 1;
|
||||||
|
|
||||||
|
export class MarkdownDocumentParseError extends Error {
|
||||||
|
readonly code = "INVALID_FRONT_MATTER";
|
||||||
|
|
||||||
|
constructor(message: string, options?: ErrorOptions) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = "MarkdownDocumentParseError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface MarkdownDocumentMetadata {
|
export interface MarkdownDocumentMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
@@ -198,9 +207,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$。
|
||||||
|
|||||||
Reference in New Issue
Block a user