Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad18149c10 | ||
|
|
b459def232 | ||
|
|
d93728bb8b |
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
优先于这里的全局设置;style、classDef 和 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
type ExportConfig
|
type ExportConfig,
|
||||||
|
type MarkdownDocumentMetadata,
|
||||||
|
type PagedDocumentPayload
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
|
import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
|
||||||
|
|
||||||
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
||||||
|
|
||||||
export interface PreviewMetadata {
|
export type PreviewMetadata = MarkdownDocumentMetadata;
|
||||||
title: string;
|
export type PagedPreviewPayload = PagedDocumentPayload;
|
||||||
author: string;
|
|
||||||
subject: string;
|
|
||||||
keywords: string[];
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagedPreviewPayload {
|
|
||||||
articleHtml: string;
|
|
||||||
fileName: string;
|
|
||||||
metadata: PreviewMetadata;
|
|
||||||
features: string[];
|
|
||||||
themeCss: string;
|
|
||||||
exportConfig: ExportConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagedPreviewRenderRequest {
|
export interface PagedPreviewRenderRequest {
|
||||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||||
@@ -45,7 +33,6 @@ export type PagedPreviewFrameMessage =
|
|||||||
type: "rendered";
|
type: "rendered";
|
||||||
requestId: number;
|
requestId: number;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
contentHeight: number;
|
|
||||||
mermaidErrors: string[];
|
mermaidErrors: string[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -1,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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export interface MarkdownRenderCallbacks {
|
||||||
|
onRenderStart?: () => void;
|
||||||
|
onStatusChange?: (status: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkdownRender(
|
||||||
|
markdown: string,
|
||||||
|
language: string,
|
||||||
|
callbacks: MarkdownRenderCallbacks = {}
|
||||||
|
) {
|
||||||
|
const [result, setResult] =
|
||||||
|
useState<RenderedMarkdownDocument | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const { onRenderStart, onStatusChange } = callbacks;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
onStatusChange?.("正在渲染…");
|
||||||
|
onRenderStart?.();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
void fetch("/api/render", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ markdown, language }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
const payload = (await response.json()) as
|
||||||
|
| RenderedMarkdownDocument
|
||||||
|
| { message?: string };
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
"message" in payload
|
||||||
|
? payload.message ?? "渲染失败"
|
||||||
|
: "渲染失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payload as RenderedMarkdownDocument;
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
setResult(payload);
|
||||||
|
onStatusChange?.("预览已更新");
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "渲染失败"
|
||||||
|
);
|
||||||
|
onStatusChange?.("预览失败");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [language, markdown, onRenderStart, onStatusChange]);
|
||||||
|
|
||||||
|
return { error, result };
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface ThemeSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
bundled: boolean;
|
||||||
|
source: "bundled" | "local";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemeResources(
|
||||||
|
themeId: string,
|
||||||
|
onThemesLoaded: (themes: ThemeSummary[]) => void
|
||||||
|
) {
|
||||||
|
const [themes, setThemes] = useState<ThemeSummary[]>([]);
|
||||||
|
const [themeCss, setThemeCss] = useState("");
|
||||||
|
const [catalogError, setCatalogError] = useState("");
|
||||||
|
const [cssError, setCssError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
void fetch("/api/themes", {
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("无法加载主题清单");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<{ themes: ThemeSummary[] }>;
|
||||||
|
})
|
||||||
|
.then(({ themes: availableThemes }) => {
|
||||||
|
setThemes(availableThemes);
|
||||||
|
setCatalogError("");
|
||||||
|
onThemesLoaded(availableThemes);
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setCatalogError(
|
||||||
|
reason instanceof Error ? reason.message : "无法加载主题清单"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [onThemesLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setThemeCss("");
|
||||||
|
setCssError("");
|
||||||
|
|
||||||
|
void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, {
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("无法加载主题");
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then(setThemeCss)
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setCssError(
|
||||||
|
reason instanceof Error ? reason.message : "无法加载主题"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [themeId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: catalogError || cssError,
|
||||||
|
themeCss,
|
||||||
|
themes
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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 忽略,不得提交。
|
||||||
|
|||||||
Generated
+21
-1
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ExportConfig } from "./export-config.js";
|
||||||
|
import type { ThemeFeature } from "./theme.js";
|
||||||
|
|
||||||
|
export interface MarkdownDocumentMetadata {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
subject: string;
|
||||||
|
keywords: string[];
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedMarkdownDocument {
|
||||||
|
rendererVersion: number;
|
||||||
|
articleHtml: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
metadata: MarkdownDocumentMetadata;
|
||||||
|
features: ThemeFeature[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedDocumentPayload {
|
||||||
|
articleHtml: string;
|
||||||
|
fileName: string;
|
||||||
|
metadata: MarkdownDocumentMetadata;
|
||||||
|
features: ThemeFeature[];
|
||||||
|
themeCss: string;
|
||||||
|
exportConfig: ExportConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePagedDocumentPayloadOptions {
|
||||||
|
document: RenderedMarkdownDocument;
|
||||||
|
fileName: string;
|
||||||
|
themeCss: string;
|
||||||
|
exportConfig: ExportConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPagedDocumentPayload({
|
||||||
|
document,
|
||||||
|
fileName,
|
||||||
|
themeCss,
|
||||||
|
exportConfig
|
||||||
|
}: CreatePagedDocumentPayloadOptions): PagedDocumentPayload {
|
||||||
|
return {
|
||||||
|
articleHtml: document.articleHtml,
|
||||||
|
fileName,
|
||||||
|
metadata: document.metadata,
|
||||||
|
features: document.features,
|
||||||
|
themeCss,
|
||||||
|
exportConfig
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedDocumentTimings {
|
||||||
|
setupMs: number;
|
||||||
|
mermaidMs: number;
|
||||||
|
mermaidFitMs: number;
|
||||||
|
mermaidConversionMs: number;
|
||||||
|
resourceWaitMs: number;
|
||||||
|
paginationMs: number;
|
||||||
|
finalizeMs: number;
|
||||||
|
totalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedDocumentRenderResult {
|
||||||
|
pageCount: number;
|
||||||
|
mermaidErrors: string[];
|
||||||
|
timings: PagedDocumentTimings;
|
||||||
|
}
|
||||||
@@ -1,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,2 +1,3 @@
|
|||||||
|
export * from "./document.js";
|
||||||
export * from "./export-config.js";
|
export * from "./export-config.js";
|
||||||
export * from "./theme.js";
|
export * from "./theme.js";
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createPagedDocumentPayload,
|
||||||
|
defaultExportConfig,
|
||||||
|
type RenderedMarkdownDocument
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
describe("分页文档载荷", () => {
|
||||||
|
it("从统一渲染结果提取预览与 PDF 共用字段", () => {
|
||||||
|
const document: RenderedMarkdownDocument = {
|
||||||
|
rendererVersion: 1,
|
||||||
|
articleHtml: '<article id="write"><h1>测试</h1></article>',
|
||||||
|
bodyHtml: "<h1>测试</h1>",
|
||||||
|
metadata: {
|
||||||
|
title: "测试",
|
||||||
|
author: "",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
features: ["table"],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createPagedDocumentPayload({
|
||||||
|
document,
|
||||||
|
fileName: "测试.md",
|
||||||
|
themeCss: "#write { color: #333; }",
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
articleHtml: document.articleHtml,
|
||||||
|
fileName: "测试.md",
|
||||||
|
metadata: document.metadata,
|
||||||
|
features: document.features,
|
||||||
|
themeCss: "#write { color: #333; }",
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 和危险链接形成可执行内容", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user