Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92a5bfd016 | ||
|
|
f5a46f79f1 | ||
|
|
1db21c80fc | ||
|
|
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
|
||||||
|
|||||||
+70
-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,
|
||||||
@@ -121,6 +149,8 @@ function createPdfServerTiming(
|
|||||||
`navigation;dur=${milliseconds(timings.navigationMs)}`,
|
`navigation;dur=${milliseconds(timings.navigationMs)}`,
|
||||||
`document-render;dur=${milliseconds(timings.documentRenderMs)}`,
|
`document-render;dur=${milliseconds(timings.documentRenderMs)}`,
|
||||||
`runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`,
|
`runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`,
|
||||||
|
`echarts;dur=${milliseconds(timings.echartsMs)}`,
|
||||||
|
`echarts-fit;dur=${milliseconds(timings.echartsFitMs)}`,
|
||||||
`mermaid;dur=${milliseconds(timings.mermaidMs)}`,
|
`mermaid;dur=${milliseconds(timings.mermaidMs)}`,
|
||||||
`mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`,
|
`mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`,
|
||||||
`mermaid-conversion;dur=${milliseconds(
|
`mermaid-conversion;dur=${milliseconds(
|
||||||
@@ -142,7 +172,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 +285,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 +343,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(
|
||||||
@@ -352,6 +399,10 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
.header("cache-control", "no-store")
|
.header("cache-control", "no-store")
|
||||||
.header("server-timing", serverTiming)
|
.header("server-timing", serverTiming)
|
||||||
.header("x-pdf-page-count", String(generated.pageCount))
|
.header("x-pdf-page-count", String(generated.pageCount))
|
||||||
|
.header(
|
||||||
|
"x-echarts-error-count",
|
||||||
|
String(generated.echartsErrors.length)
|
||||||
|
)
|
||||||
.header(
|
.header(
|
||||||
"x-mermaid-error-count",
|
"x-mermaid-error-count",
|
||||||
String(generated.mermaidErrors.length)
|
String(generated.mermaidErrors.length)
|
||||||
|
|||||||
+110
-78
@@ -1,25 +1,16 @@
|
|||||||
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;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
|
echartsErrors: string[];
|
||||||
mermaidErrors: string[];
|
mermaidErrors: string[];
|
||||||
timings: PdfGenerationTimings;
|
timings: PdfGenerationTimings;
|
||||||
}
|
}
|
||||||
@@ -31,6 +22,8 @@ export interface PdfGenerationTimings {
|
|||||||
navigationMs: number;
|
navigationMs: number;
|
||||||
documentRenderMs: number;
|
documentRenderMs: number;
|
||||||
runtimeSetupMs: number;
|
runtimeSetupMs: number;
|
||||||
|
echartsMs: number;
|
||||||
|
echartsFitMs: number;
|
||||||
mermaidMs: number;
|
mermaidMs: number;
|
||||||
mermaidFitMs: number;
|
mermaidFitMs: number;
|
||||||
mermaidConversionMs: number;
|
mermaidConversionMs: number;
|
||||||
@@ -62,22 +55,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<
|
||||||
@@ -85,6 +62,8 @@ interface PdfDocumentResult
|
|||||||
| "navigationMs"
|
| "navigationMs"
|
||||||
| "documentRenderMs"
|
| "documentRenderMs"
|
||||||
| "runtimeSetupMs"
|
| "runtimeSetupMs"
|
||||||
|
| "echartsMs"
|
||||||
|
| "echartsFitMs"
|
||||||
| "mermaidMs"
|
| "mermaidMs"
|
||||||
| "mermaidFitMs"
|
| "mermaidFitMs"
|
||||||
| "mermaidConversionMs"
|
| "mermaidConversionMs"
|
||||||
@@ -232,6 +211,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 +303,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 +349,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 +436,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) {
|
||||||
@@ -445,11 +474,14 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
|||||||
return {
|
return {
|
||||||
pdf,
|
pdf,
|
||||||
pageCount: renderResult.pageCount,
|
pageCount: renderResult.pageCount,
|
||||||
|
echartsErrors: renderResult.echartsErrors,
|
||||||
mermaidErrors: renderResult.mermaidErrors,
|
mermaidErrors: renderResult.mermaidErrors,
|
||||||
timings: {
|
timings: {
|
||||||
navigationMs,
|
navigationMs,
|
||||||
documentRenderMs,
|
documentRenderMs,
|
||||||
runtimeSetupMs: renderResult.timings.setupMs,
|
runtimeSetupMs: renderResult.timings.setupMs,
|
||||||
|
echartsMs: renderResult.timings.echartsMs,
|
||||||
|
echartsFitMs: renderResult.timings.echartsFitMs,
|
||||||
mermaidMs: renderResult.timings.mermaidMs,
|
mermaidMs: renderResult.timings.mermaidMs,
|
||||||
mermaidFitMs: renderResult.timings.mermaidFitMs,
|
mermaidFitMs: renderResult.timings.mermaidFitMs,
|
||||||
mermaidConversionMs:
|
mermaidConversionMs:
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -267,6 +369,7 @@ describe("PDF API", () => {
|
|||||||
const generate = vi.fn(async () => ({
|
const generate = vi.fn(async () => ({
|
||||||
pdf: Buffer.from("%PDF-test"),
|
pdf: Buffer.from("%PDF-test"),
|
||||||
pageCount: 3,
|
pageCount: 3,
|
||||||
|
echartsErrors: ["图表 2"],
|
||||||
mermaidErrors: ["图表 1"],
|
mermaidErrors: ["图表 1"],
|
||||||
timings: {
|
timings: {
|
||||||
queueMs: 0,
|
queueMs: 0,
|
||||||
@@ -275,6 +378,8 @@ describe("PDF API", () => {
|
|||||||
navigationMs: 3,
|
navigationMs: 3,
|
||||||
documentRenderMs: 4,
|
documentRenderMs: 4,
|
||||||
runtimeSetupMs: 0.1,
|
runtimeSetupMs: 0.1,
|
||||||
|
echartsMs: 0.15,
|
||||||
|
echartsFitMs: 0.18,
|
||||||
mermaidMs: 0.2,
|
mermaidMs: 0.2,
|
||||||
mermaidFitMs: 0.3,
|
mermaidFitMs: 0.3,
|
||||||
mermaidConversionMs: 0.35,
|
mermaidConversionMs: 0.35,
|
||||||
@@ -313,6 +418,7 @@ describe("PDF API", () => {
|
|||||||
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf"
|
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf"
|
||||||
);
|
);
|
||||||
expect(response.headers["x-pdf-page-count"]).toBe("3");
|
expect(response.headers["x-pdf-page-count"]).toBe("3");
|
||||||
|
expect(response.headers["x-echarts-error-count"]).toBe("1");
|
||||||
expect(response.headers["x-mermaid-error-count"]).toBe("1");
|
expect(response.headers["x-mermaid-error-count"]).toBe("1");
|
||||||
expect(response.headers["server-timing"]).toContain(
|
expect(response.headers["server-timing"]).toContain(
|
||||||
"document-render;dur=4.0"
|
"document-render;dur=4.0"
|
||||||
@@ -320,6 +426,9 @@ describe("PDF API", () => {
|
|||||||
expect(response.headers["server-timing"]).toContain(
|
expect(response.headers["server-timing"]).toContain(
|
||||||
"pdf-print;dur=5.0"
|
"pdf-print;dur=5.0"
|
||||||
);
|
);
|
||||||
|
expect(response.headers["server-timing"]).toContain(
|
||||||
|
"echarts;dur=0.1"
|
||||||
|
);
|
||||||
expect(response.headers["server-timing"]).toContain(
|
expect(response.headers["server-timing"]).toContain(
|
||||||
"mermaid;dur=0.2"
|
"mermaid;dur=0.2"
|
||||||
);
|
);
|
||||||
@@ -357,6 +466,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,10 +47,12 @@ 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,
|
echartsErrors: [],
|
||||||
mermaidErrors: [],
|
mermaidErrors: [],
|
||||||
timings: {
|
timings: {
|
||||||
setupMs: 1,
|
setupMs: 1,
|
||||||
|
echartsMs: 1.5,
|
||||||
|
echartsFitMs: 1.75,
|
||||||
mermaidMs: 2,
|
mermaidMs: 2,
|
||||||
mermaidFitMs: 3,
|
mermaidFitMs: 3,
|
||||||
mermaidConversionMs: 3.5,
|
mermaidConversionMs: 3.5,
|
||||||
@@ -65,7 +70,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 +89,9 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
|
|||||||
context,
|
context,
|
||||||
page,
|
page,
|
||||||
pdfBuffer,
|
pdfBuffer,
|
||||||
|
releaseNewContext() {
|
||||||
|
releaseNewContext?.();
|
||||||
|
},
|
||||||
route
|
route
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -179,6 +194,8 @@ describe("Playwright PDF 生成器", () => {
|
|||||||
navigationMs: expect.any(Number),
|
navigationMs: expect.any(Number),
|
||||||
documentRenderMs: expect.any(Number),
|
documentRenderMs: expect.any(Number),
|
||||||
runtimeSetupMs: 1,
|
runtimeSetupMs: 1,
|
||||||
|
echartsMs: 1.5,
|
||||||
|
echartsFitMs: 1.75,
|
||||||
mermaidMs: 2,
|
mermaidMs: 2,
|
||||||
mermaidFitMs: 3,
|
mermaidFitMs: 3,
|
||||||
mermaidConversionMs: 3.5,
|
mermaidConversionMs: 3.5,
|
||||||
@@ -240,4 +257,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,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/markdown-echarts": "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>
|
||||||
|
|||||||
+545
-204
@@ -10,10 +10,14 @@ 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 { PreviewPageControl } from "./PreviewPageControl";
|
||||||
|
import { PreviewZoomControl } from "./PreviewZoomControl";
|
||||||
import {
|
import {
|
||||||
loadExportConfig,
|
loadExportConfig,
|
||||||
resetExportConfig,
|
resetExportConfig,
|
||||||
@@ -26,7 +30,7 @@ import {
|
|||||||
} from "./paged-preview";
|
} from "./paged-preview";
|
||||||
import {
|
import {
|
||||||
getNearestPageNumber,
|
getNearestPageNumber,
|
||||||
PREVIEW_SCROLLBAR_WIDTH_PX
|
getPreviewPageScrollTop
|
||||||
} from "./preview-page";
|
} from "./preview-page";
|
||||||
import {
|
import {
|
||||||
createPdfExportCacheKey,
|
createPdfExportCacheKey,
|
||||||
@@ -37,6 +41,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 +58,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: 内网文档团队
|
||||||
@@ -83,7 +74,7 @@ keywords:
|
|||||||
|
|
||||||
- [x] Markdown 实时预览
|
- [x] Markdown 实时预览
|
||||||
- [x] 表格与任务列表
|
- [x] 表格与任务列表
|
||||||
- [x] 数学公式与 Mermaid
|
- [x] 数学公式、Mermaid 与 ECharts
|
||||||
- [ ] Chromium PDF 导出
|
- [ ] Chromium PDF 导出
|
||||||
|
|
||||||
| 功能 | 当前状态 |
|
| 功能 | 当前状态 |
|
||||||
@@ -101,29 +92,276 @@ flowchart LR
|
|||||||
C --> E["PDF 导出"]
|
C --> E["PDF 导出"]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
## ECharts 图表
|
||||||
|
|
||||||
|
### 基础柱状图
|
||||||
|
|
||||||
|
\`\`\`echarts
|
||||||
|
caption: 各模块文档数量
|
||||||
|
height: 58mm
|
||||||
|
option:
|
||||||
|
xAxis:
|
||||||
|
type: category
|
||||||
|
data: [研发, 产品, 设计, 运营]
|
||||||
|
yAxis:
|
||||||
|
type: value
|
||||||
|
series:
|
||||||
|
- type: bar
|
||||||
|
data: [28, 18, 15, 22]
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Tree 树图
|
||||||
|
|
||||||
|
\`\`\`echarts
|
||||||
|
caption: 产品能力树
|
||||||
|
height: 58mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: tree
|
||||||
|
orient: LR
|
||||||
|
label:
|
||||||
|
position: left
|
||||||
|
data:
|
||||||
|
- name: 文档平台
|
||||||
|
children:
|
||||||
|
- name: Markdown
|
||||||
|
children:
|
||||||
|
- name: Mermaid
|
||||||
|
- name: ECharts
|
||||||
|
- name: PDF
|
||||||
|
children:
|
||||||
|
- name: 快速预览
|
||||||
|
- name: 精确预览
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Treemap 矩形树图
|
||||||
|
|
||||||
|
\`\`\`echarts
|
||||||
|
caption: 模块工作量占比
|
||||||
|
height: 58mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: treemap
|
||||||
|
roam: false
|
||||||
|
nodeClick: false
|
||||||
|
breadcrumb:
|
||||||
|
show: false
|
||||||
|
label:
|
||||||
|
show: true
|
||||||
|
formatter: "{b}"
|
||||||
|
color: "#1f2937"
|
||||||
|
fontWeight: bold
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||||
|
borderRadius: 3
|
||||||
|
padding: [3, 6]
|
||||||
|
itemStyle:
|
||||||
|
borderColor: "#ffffff"
|
||||||
|
borderWidth: 2
|
||||||
|
gapWidth: 2
|
||||||
|
levels:
|
||||||
|
- itemStyle:
|
||||||
|
borderWidth: 0
|
||||||
|
gapWidth: 2
|
||||||
|
- upperLabel:
|
||||||
|
show: true
|
||||||
|
height: 28
|
||||||
|
color: "#1f2937"
|
||||||
|
fontWeight: bold
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||||
|
padding: [3, 6]
|
||||||
|
itemStyle:
|
||||||
|
borderColor: "#ffffff"
|
||||||
|
borderWidth: 2
|
||||||
|
gapWidth: 2
|
||||||
|
- label:
|
||||||
|
show: true
|
||||||
|
formatter: "{b}"
|
||||||
|
color: "#1f2937"
|
||||||
|
fontWeight: bold
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||||
|
borderRadius: 3
|
||||||
|
padding: [3, 6]
|
||||||
|
itemStyle:
|
||||||
|
borderColor: "#ffffff"
|
||||||
|
borderWidth: 2
|
||||||
|
gapWidth: 2
|
||||||
|
data:
|
||||||
|
- name: 渲染
|
||||||
|
children:
|
||||||
|
- { name: Markdown, value: 32 }
|
||||||
|
- { name: ECharts, value: 24 }
|
||||||
|
- { name: Mermaid, value: 18 }
|
||||||
|
- name: 导出
|
||||||
|
children:
|
||||||
|
- { name: PDF, value: 28 }
|
||||||
|
- { name: 主题, value: 12 }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Sunburst 旭日图
|
||||||
|
|
||||||
|
\`\`\`echarts
|
||||||
|
caption: 文档内容层级
|
||||||
|
height: 58mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: sunburst
|
||||||
|
radius: [15%, 90%]
|
||||||
|
data:
|
||||||
|
- name: 技术文档
|
||||||
|
children:
|
||||||
|
- { name: 架构, value: 18 }
|
||||||
|
- { name: 接口, value: 24 }
|
||||||
|
- name: 项目文档
|
||||||
|
children:
|
||||||
|
- { name: 周报, value: 16 }
|
||||||
|
- { name: 计划, value: 12 }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Graph 关系图
|
||||||
|
|
||||||
|
\`\`\`echarts
|
||||||
|
caption: 渲染链路关系
|
||||||
|
height: 58mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: graph
|
||||||
|
layout: circular
|
||||||
|
roam: false
|
||||||
|
label:
|
||||||
|
show: true
|
||||||
|
data:
|
||||||
|
- { name: Markdown, symbolSize: 42 }
|
||||||
|
- { name: HTML, symbolSize: 38 }
|
||||||
|
- { name: 预览, symbolSize: 34 }
|
||||||
|
- { name: PDF, symbolSize: 34 }
|
||||||
|
links:
|
||||||
|
- { source: Markdown, target: HTML }
|
||||||
|
- { source: HTML, target: 预览 }
|
||||||
|
- { source: HTML, target: PDF }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Sankey 桑基图
|
||||||
|
|
||||||
|
\`\`\`echarts
|
||||||
|
caption: 文档流转量
|
||||||
|
height: 58mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: sankey
|
||||||
|
data:
|
||||||
|
- { name: Markdown }
|
||||||
|
- { name: HTML }
|
||||||
|
- { name: 快速预览 }
|
||||||
|
- { name: 精确预览 }
|
||||||
|
- { name: PDF }
|
||||||
|
links:
|
||||||
|
- { source: Markdown, target: HTML, value: 100 }
|
||||||
|
- { source: HTML, target: 快速预览, value: 55 }
|
||||||
|
- { source: HTML, target: 精确预览, value: 25 }
|
||||||
|
- { source: HTML, target: PDF, value: 20 }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Chord 和弦图
|
||||||
|
|
||||||
|
\`\`\`echarts
|
||||||
|
caption: 团队协作关系
|
||||||
|
height: 58mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: chord
|
||||||
|
label:
|
||||||
|
show: true
|
||||||
|
data:
|
||||||
|
- { name: 研发 }
|
||||||
|
- { name: 产品 }
|
||||||
|
- { name: 设计 }
|
||||||
|
- { name: 运营 }
|
||||||
|
links:
|
||||||
|
- { source: 研发, target: 产品, value: 12 }
|
||||||
|
- { source: 产品, target: 设计, value: 8 }
|
||||||
|
- { source: 设计, target: 运营, value: 6 }
|
||||||
|
- { source: 运营, target: 研发, value: 4 }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Funnel 漏斗图
|
||||||
|
|
||||||
|
\`\`\`echarts
|
||||||
|
caption: 文档发布转化
|
||||||
|
height: 58mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: funnel
|
||||||
|
sort: descending
|
||||||
|
label:
|
||||||
|
formatter: "{b}: {c}"
|
||||||
|
data:
|
||||||
|
- { name: 草稿, value: 100 }
|
||||||
|
- { name: 评审, value: 76 }
|
||||||
|
- { name: 定稿, value: 58 }
|
||||||
|
- { name: 发布, value: 45 }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Gauge 仪表盘
|
||||||
|
|
||||||
|
\`\`\`echarts
|
||||||
|
caption: 发布完成率
|
||||||
|
height: 58mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: gauge
|
||||||
|
min: 0
|
||||||
|
max: 100
|
||||||
|
progress:
|
||||||
|
show: true
|
||||||
|
detail:
|
||||||
|
formatter: "{value}%"
|
||||||
|
data:
|
||||||
|
- { name: 完成率, value: 78 }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### PictorialBar 象形柱图
|
||||||
|
|
||||||
|
\`\`\`echarts
|
||||||
|
caption: 各渠道文档数量
|
||||||
|
height: 58mm
|
||||||
|
option:
|
||||||
|
xAxis:
|
||||||
|
type: category
|
||||||
|
data: [知识库, 周报, 手册, 方案]
|
||||||
|
yAxis:
|
||||||
|
type: value
|
||||||
|
series:
|
||||||
|
- type: pictorialBar
|
||||||
|
symbol: "path://M0,10 L5,0 L10,10 Z"
|
||||||
|
symbolRepeat: true
|
||||||
|
symbolSize: [14, 10]
|
||||||
|
data: [12, 20, 16, 24]
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
> 预览和 PDF 将共用同一份 HTML 与 CSS。
|
> 预览和 PDF 将共用同一份 HTML 与 CSS。
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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 [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
|
||||||
const [status, setStatus] = useState("正在准备预览…");
|
const [status, setStatus] = useState("正在准备预览…");
|
||||||
const [renderError, setRenderError] = useState("");
|
const [appError, setAppError] = useState("");
|
||||||
const [themeError, setThemeError] = useState("");
|
const [echartsError, setEChartsError] = 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 [quickPreviewContentHeight, setQuickPreviewContentHeight] =
|
||||||
|
useState<number>();
|
||||||
const [previewPagination, setPreviewPagination] = useState({
|
const [previewPagination, setPreviewPagination] = useState({
|
||||||
current: 0,
|
current: 0,
|
||||||
total: 0
|
total: 0
|
||||||
@@ -139,9 +377,49 @@ 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(() => {
|
||||||
|
setEChartsError("");
|
||||||
|
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 ||
|
||||||
|
echartsError ||
|
||||||
mermaidError ||
|
mermaidError ||
|
||||||
paginationError ||
|
paginationError ||
|
||||||
pdfError;
|
pdfError;
|
||||||
@@ -151,21 +429,36 @@ export function App() {
|
|||||||
exportConfig.paper.format,
|
exportConfig.paper.format,
|
||||||
exportConfig.paper.orientation
|
exportConfig.paper.orientation
|
||||||
);
|
);
|
||||||
|
const previewScale = previewZoom / 100;
|
||||||
|
const naturalPaperWidth = millimetersToCssPixels(paperDimensions.width);
|
||||||
|
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`,
|
||||||
|
...(quickPreviewContentHeight
|
||||||
|
? {
|
||||||
|
height: `${quickPreviewContentHeight * previewScale}px`,
|
||||||
|
minHeight: `${quickPreviewContentHeight * previewScale}px`
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
} satisfies CSSProperties;
|
||||||
|
const previewFrameStyle = {
|
||||||
|
width: `${100 / previewScale}%`,
|
||||||
|
height: quickPreviewContentHeight
|
||||||
|
? `${quickPreviewContentHeight}px`
|
||||||
|
: `${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 +479,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 +505,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;
|
||||||
@@ -337,7 +530,9 @@ export function App() {
|
|||||||
|
|
||||||
if (event.data.type === "rendering") {
|
if (event.data.type === "rendering") {
|
||||||
setPaginationError("");
|
setPaginationError("");
|
||||||
|
setEChartsError("");
|
||||||
setMermaidError("");
|
setMermaidError("");
|
||||||
|
setQuickPreviewContentHeight(undefined);
|
||||||
setPreviewPagination({ current: 0, total: 0 });
|
setPreviewPagination({ current: 0, total: 0 });
|
||||||
setStatus("正在分页…");
|
setStatus("正在分页…");
|
||||||
return;
|
return;
|
||||||
@@ -349,12 +544,18 @@ export function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setEChartsError(
|
||||||
|
event.data.echartsErrors.length > 0
|
||||||
|
? `ECharts:${event.data.echartsErrors.join(";")}`
|
||||||
|
: ""
|
||||||
|
);
|
||||||
setMermaidError(
|
setMermaidError(
|
||||||
event.data.mermaidErrors.length > 0
|
event.data.mermaidErrors.length > 0
|
||||||
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
||||||
: ""
|
: ""
|
||||||
);
|
);
|
||||||
setStatus(`预览已分页(${event.data.pageCount} 页)`);
|
setStatus(`预览已分页(${event.data.pageCount} 页)`);
|
||||||
|
setQuickPreviewContentHeight(event.data.contentHeight);
|
||||||
setPreviewPagination({
|
setPreviewPagination({
|
||||||
current: event.data.pageCount > 0 ? 1 : 0,
|
current: event.data.pageCount > 0 ? 1 : 0,
|
||||||
total: event.data.pageCount
|
total: event.data.pageCount
|
||||||
@@ -371,22 +572,15 @@ export function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frameWindow = previewFrameRef.current?.contentWindow;
|
function handleWindowScroll() {
|
||||||
if (!frameWindow || !previewFrameReady) {
|
if (getOuterPreviewScroller() === document.scrollingElement) {
|
||||||
return;
|
updatePreviewCurrentPage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePreviewScroll() {
|
window.addEventListener("scroll", handleWindowScroll, { passive: true });
|
||||||
synchronizeScroll("preview");
|
return () => window.removeEventListener("scroll", handleWindowScroll);
|
||||||
updatePreviewCurrentPage();
|
}, [previewMode, quickPreviewContentHeight]);
|
||||||
}
|
|
||||||
|
|
||||||
frameWindow.addEventListener("scroll", handlePreviewScroll, {
|
|
||||||
passive: true
|
|
||||||
});
|
|
||||||
return () =>
|
|
||||||
frameWindow.removeEventListener("scroll", handlePreviewScroll);
|
|
||||||
}, [previewFrameReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -412,12 +606,19 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}, [previewFrameReady, previewPayload]);
|
}, [previewFrameReady, previewPayload]);
|
||||||
|
|
||||||
function getPreviewScroller() {
|
function getOuterPreviewScroller() {
|
||||||
if (previewModeRef.current === "precise") {
|
const previewScroller = previewScrollRef.current;
|
||||||
return previewScrollRef.current;
|
if (!previewScroller) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return previewFrameRef.current?.contentDocument
|
|
||||||
?.scrollingElement as HTMLElement | null;
|
return previewScroller.scrollHeight > previewScroller.clientHeight + 1
|
||||||
|
? previewScroller
|
||||||
|
: (document.scrollingElement as HTMLElement | null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewScroller() {
|
||||||
|
return getOuterPreviewScroller();
|
||||||
}
|
}
|
||||||
|
|
||||||
function synchronizeScroll(origin: "editor" | "preview") {
|
function synchronizeScroll(origin: "editor" | "preview") {
|
||||||
@@ -458,15 +659,28 @@ export function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scrollerViewportTop =
|
||||||
|
scrollElement === document.scrollingElement
|
||||||
|
? 0
|
||||||
|
: scrollElement.getBoundingClientRect().top;
|
||||||
|
const frameViewportTop = frame.getBoundingClientRect().top;
|
||||||
const viewportCenter =
|
const viewportCenter =
|
||||||
scrollElement.scrollTop + frame.clientHeight / 2;
|
scrollElement.scrollTop + scrollElement.clientHeight / 2;
|
||||||
const pages = Array.from(
|
const pages = Array.from(
|
||||||
frameDocument.querySelectorAll<HTMLElement>(".pagedjs_page")
|
frameDocument.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||||
).map((page) => {
|
).map((page) => {
|
||||||
const rect = page.getBoundingClientRect();
|
const rect = page.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
top: rect.top + scrollElement.scrollTop,
|
top: getPreviewPageScrollTop(
|
||||||
bottom: rect.bottom + scrollElement.scrollTop
|
scrollElement.scrollTop,
|
||||||
|
frameViewportTop + rect.top * previewScale,
|
||||||
|
scrollerViewportTop
|
||||||
|
),
|
||||||
|
bottom: getPreviewPageScrollTop(
|
||||||
|
scrollElement.scrollTop,
|
||||||
|
frameViewportTop + rect.bottom * previewScale,
|
||||||
|
scrollerViewportTop
|
||||||
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const current = getNearestPageNumber(viewportCenter, pages);
|
const current = getNearestPageNumber(viewportCenter, pages);
|
||||||
@@ -483,20 +697,35 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updatePrecisePreviewCurrentPage() {
|
function updatePrecisePreviewCurrentPage() {
|
||||||
const scrollElement = previewScrollRef.current;
|
const scrollElement = getOuterPreviewScroller();
|
||||||
if (!scrollElement) {
|
if (!scrollElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const scrollerViewportTop =
|
||||||
|
scrollElement === document.scrollingElement
|
||||||
|
? 0
|
||||||
|
: scrollElement.getBoundingClientRect().top;
|
||||||
const viewportCenter =
|
const viewportCenter =
|
||||||
scrollElement.scrollTop + scrollElement.clientHeight / 2;
|
scrollElement.scrollTop + scrollElement.clientHeight / 2;
|
||||||
const pages = Array.from(
|
const pages = Array.from(
|
||||||
scrollElement.querySelectorAll<HTMLElement>(
|
scrollElement.querySelectorAll<HTMLElement>(
|
||||||
".precise-pdf-page"
|
".precise-pdf-page"
|
||||||
)
|
)
|
||||||
).map((page) => ({
|
).map((page) => {
|
||||||
top: page.offsetTop,
|
const rect = page.getBoundingClientRect();
|
||||||
bottom: page.offsetTop + page.offsetHeight
|
return {
|
||||||
}));
|
top: getPreviewPageScrollTop(
|
||||||
|
scrollElement.scrollTop,
|
||||||
|
rect.top,
|
||||||
|
scrollerViewportTop
|
||||||
|
),
|
||||||
|
bottom: getPreviewPageScrollTop(
|
||||||
|
scrollElement.scrollTop,
|
||||||
|
rect.bottom,
|
||||||
|
scrollerViewportTop
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
const current = getNearestPageNumber(viewportCenter, pages);
|
const current = getNearestPageNumber(viewportCenter, pages);
|
||||||
|
|
||||||
setPreviewPagination((pagination) =>
|
setPreviewPagination((pagination) =>
|
||||||
@@ -510,6 +739,60 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePreviewPageChange(pageNumber: number) {
|
||||||
|
const scrollElement = getPreviewScroller();
|
||||||
|
if (!scrollElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetTop: number | undefined;
|
||||||
|
if (previewModeRef.current === "precise") {
|
||||||
|
const page = scrollElement.querySelectorAll<HTMLElement>(
|
||||||
|
".precise-pdf-page"
|
||||||
|
)[pageNumber - 1];
|
||||||
|
if (page) {
|
||||||
|
const scrollerViewportTop =
|
||||||
|
scrollElement === document.scrollingElement
|
||||||
|
? 0
|
||||||
|
: scrollElement.getBoundingClientRect().top;
|
||||||
|
targetTop = getPreviewPageScrollTop(
|
||||||
|
scrollElement.scrollTop,
|
||||||
|
page.getBoundingClientRect().top,
|
||||||
|
scrollerViewportTop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const page =
|
||||||
|
previewFrameRef.current?.contentDocument?.querySelectorAll<HTMLElement>(
|
||||||
|
".pagedjs_page"
|
||||||
|
)[pageNumber - 1];
|
||||||
|
if (page) {
|
||||||
|
const scrollerViewportTop =
|
||||||
|
scrollElement === document.scrollingElement
|
||||||
|
? 0
|
||||||
|
: scrollElement.getBoundingClientRect().top;
|
||||||
|
const frameViewportTop =
|
||||||
|
previewFrameRef.current?.getBoundingClientRect().top ?? 0;
|
||||||
|
targetTop = getPreviewPageScrollTop(
|
||||||
|
scrollElement.scrollTop,
|
||||||
|
frameViewportTop +
|
||||||
|
page.getBoundingClientRect().top * previewScale,
|
||||||
|
scrollerViewportTop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetTop === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewPagination((pagination) => ({
|
||||||
|
...pagination,
|
||||||
|
current: pageNumber
|
||||||
|
}));
|
||||||
|
scrollElement.scrollTo({ top: Math.max(0, targetTop) });
|
||||||
|
}
|
||||||
|
|
||||||
const handlePrecisePageCount = useCallback((pageCount: number) => {
|
const handlePrecisePageCount = useCallback((pageCount: number) => {
|
||||||
setPreviewPagination({
|
setPreviewPagination({
|
||||||
current: pageCount > 0 ? 1 : 0,
|
current: pageCount > 0 ? 1 : 0,
|
||||||
@@ -525,10 +808,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 = "";
|
||||||
}
|
}
|
||||||
@@ -567,8 +851,12 @@ export function App() {
|
|||||||
exported.mermaidErrorCount > 0
|
exported.mermaidErrorCount > 0
|
||||||
? `,${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
|
? `,${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
|
||||||
: "";
|
: "";
|
||||||
|
const echartsDescription =
|
||||||
|
exported.echartsErrorCount > 0
|
||||||
|
? `,${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
|
||||||
|
: "";
|
||||||
setStatus(
|
setStatus(
|
||||||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${mermaidDescription}`
|
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`
|
||||||
);
|
);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setPdfError(
|
setPdfError(
|
||||||
@@ -581,8 +869,12 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePreviewModeChange(mode: PreviewMode) {
|
function handlePreviewModeChange(mode: PreviewMode) {
|
||||||
|
if (mode === previewMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPreviewMode(mode);
|
setPreviewMode(mode);
|
||||||
setPreviewFrameReady(false);
|
setPreviewFrameReady(false);
|
||||||
|
setQuickPreviewContentHeight(undefined);
|
||||||
setPreviewPagination({ current: 0, total: 0 });
|
setPreviewPagination({ current: 0, total: 0 });
|
||||||
previewScrollRef.current?.scrollTo({ top: 0 });
|
previewScrollRef.current?.scrollTo({ top: 0 });
|
||||||
if (mode === "precise" && !cachedPdf) {
|
if (mode === "precise" && !cachedPdf) {
|
||||||
@@ -591,19 +883,29 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePreviewScroll() {
|
function handlePreviewScroll() {
|
||||||
if (previewModeRef.current !== "precise") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
synchronizeScroll("preview");
|
synchronizeScroll("preview");
|
||||||
updatePrecisePreviewCurrentPage();
|
updatePreviewCurrentPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="workspace">
|
<main className="workspace">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div>
|
<div className="topbar-primary">
|
||||||
<p className="eyebrow">内网文档工具</p>
|
<div className="topbar-brand">
|
||||||
<h1>Markdown PDF 导出器</h1>
|
<p className="eyebrow">内网文档工具</p>
|
||||||
|
<h1>Markdown PDF 导出器</h1>
|
||||||
|
</div>
|
||||||
|
<div className="document-summary">
|
||||||
|
<span className="panel-kicker">源文件</span>
|
||||||
|
<strong title={fileName}>{fileName}</strong>
|
||||||
|
<span
|
||||||
|
className={`render-status${error ? " is-error" : ""}`}
|
||||||
|
aria-live="polite"
|
||||||
|
title={error || status}
|
||||||
|
>
|
||||||
|
{error || status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<label className="file-button">
|
<label className="file-button">
|
||||||
@@ -627,25 +929,52 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="editor-layout">
|
<section
|
||||||
|
className={`editor-layout${
|
||||||
|
sourcePanelCollapsed ? " is-source-collapsed" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<aside className="editor-panel" aria-label="Markdown 编辑区">
|
<aside className="editor-panel" aria-label="Markdown 编辑区">
|
||||||
<div className="panel-heading">
|
<div
|
||||||
<div>
|
id="markdown-source-content"
|
||||||
<span className="panel-kicker">源文件</span>
|
className="source-panel-content"
|
||||||
<strong>{fileName}</strong>
|
hidden={sourcePanelCollapsed}
|
||||||
|
>
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="panel-kicker">源文本</span>
|
||||||
|
<strong>Markdown 原文</strong>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="source-panel-toggle"
|
||||||
|
aria-controls="markdown-source-content"
|
||||||
|
aria-expanded="true"
|
||||||
|
onClick={() => setSourcePanelCollapsed(true)}
|
||||||
|
>
|
||||||
|
收起
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className={`render-status${error ? " is-error" : ""}`}>
|
<textarea
|
||||||
{error || status}
|
ref={editorRef}
|
||||||
</span>
|
aria-label="Markdown 内容"
|
||||||
|
value={markdown}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(event) => setMarkdown(event.target.value)}
|
||||||
|
onScroll={() => synchronizeScroll("editor")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
{sourcePanelCollapsed ? (
|
||||||
ref={editorRef}
|
<button
|
||||||
aria-label="Markdown 内容"
|
type="button"
|
||||||
value={markdown}
|
className="source-panel-restore"
|
||||||
spellCheck={false}
|
aria-controls="markdown-source-content"
|
||||||
onChange={(event) => setMarkdown(event.target.value)}
|
aria-expanded="false"
|
||||||
onScroll={() => synchronizeScroll("editor")}
|
onClick={() => setSourcePanelCollapsed(false)}
|
||||||
/>
|
>
|
||||||
|
展开源文本
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="preview-panel" aria-label="文档预览">
|
<section className="preview-panel" aria-label="文档预览">
|
||||||
@@ -655,11 +984,6 @@ export function App() {
|
|||||||
<strong>{result?.metadata.title || "未命名文档"}</strong>
|
<strong>{result?.metadata.title || "未命名文档"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<span className="preview-heading-actions">
|
<span className="preview-heading-actions">
|
||||||
{previewPagination.total > 0 ? (
|
|
||||||
<span className="preview-page-indicator" aria-live="polite">
|
|
||||||
第 {previewPagination.current} / {previewPagination.total} 页
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span
|
<span
|
||||||
className="preview-mode-control"
|
className="preview-mode-control"
|
||||||
role="group"
|
role="group"
|
||||||
@@ -715,6 +1039,21 @@ export function App() {
|
|||||||
</label>
|
</label>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="preview-toolbar">
|
||||||
|
{previewPagination.total > 0 ? (
|
||||||
|
<PreviewPageControl
|
||||||
|
current={previewPagination.current}
|
||||||
|
total={previewPagination.total}
|
||||||
|
onNavigate={handlePreviewPageChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<PreviewZoomControl
|
||||||
|
value={previewZoom}
|
||||||
|
onChange={setPreviewZoom}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={previewScrollRef}
|
ref={previewScrollRef}
|
||||||
className="preview-scroll"
|
className="preview-scroll"
|
||||||
@@ -726,6 +1065,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 +1090,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,62 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { normalizePreviewPageNumber } from "./preview-page";
|
||||||
|
|
||||||
|
interface PreviewPageControlProps {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
onNavigate: (pageNumber: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreviewPageControl({
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
onNavigate
|
||||||
|
}: PreviewPageControlProps) {
|
||||||
|
const [draft, setDraft] = useState(String(current));
|
||||||
|
const editingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingRef.current) {
|
||||||
|
setDraft(String(current));
|
||||||
|
}
|
||||||
|
}, [current, total]);
|
||||||
|
|
||||||
|
function commitPageNumber() {
|
||||||
|
const pageNumber = normalizePreviewPageNumber(draft, current, total);
|
||||||
|
setDraft(String(pageNumber));
|
||||||
|
if (pageNumber !== current) {
|
||||||
|
onNavigate(pageNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="preview-page-control">
|
||||||
|
<span>第</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={total}
|
||||||
|
step={1}
|
||||||
|
value={draft}
|
||||||
|
aria-label="跳转页码"
|
||||||
|
onBlur={() => {
|
||||||
|
editingRef.current = false;
|
||||||
|
commitPageNumber();
|
||||||
|
}}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onFocus={(event) => {
|
||||||
|
editingRef.current = true;
|
||||||
|
event.currentTarget.select();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span aria-live="polite">/ {total} 页</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import {
|
||||||
|
getPaperDimensionsMm,
|
||||||
|
lengthToMillimeters,
|
||||||
|
millimetersToCssPixels,
|
||||||
|
type ExportConfig
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export interface DiagramDimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagramPageFit extends DiagramDimensions {
|
||||||
|
scaled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SvgDiagramFitOptions {
|
||||||
|
containerSelector: string;
|
||||||
|
svgSelector?: string;
|
||||||
|
resolvePageContent?: (
|
||||||
|
container: HTMLElement,
|
||||||
|
pageContent: DiagramDimensions
|
||||||
|
) => DiagramDimensions;
|
||||||
|
onFit?: (
|
||||||
|
container: HTMLElement,
|
||||||
|
svg: SVGSVGElement,
|
||||||
|
fit: DiagramPageFit
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_FIT_EPSILON_PX = 1;
|
||||||
|
|
||||||
|
export function parseSvgViewBox(
|
||||||
|
value: string | null
|
||||||
|
): DiagramDimensions | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = value
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((part) => Number(part));
|
||||||
|
const [, , width = Number.NaN, height = Number.NaN] = values;
|
||||||
|
|
||||||
|
if (
|
||||||
|
values.length !== 4 ||
|
||||||
|
!Number.isFinite(width) ||
|
||||||
|
!Number.isFinite(height) ||
|
||||||
|
width <= 0 ||
|
||||||
|
height <= 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSvgDimensions(
|
||||||
|
svg: SVGSVGElement
|
||||||
|
): DiagramDimensions | undefined {
|
||||||
|
const width = Number.parseFloat(svg.getAttribute("width") ?? "");
|
||||||
|
const height = Number.parseFloat(
|
||||||
|
svg.getAttribute("height") ?? ""
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(width) ||
|
||||||
|
!Number.isFinite(height) ||
|
||||||
|
width <= 0 ||
|
||||||
|
height <= 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPageContentDimensions(
|
||||||
|
config: ExportConfig
|
||||||
|
): DiagramDimensions {
|
||||||
|
const paper = getPaperDimensionsMm(
|
||||||
|
config.paper.format,
|
||||||
|
config.paper.orientation
|
||||||
|
);
|
||||||
|
const width =
|
||||||
|
paper.width -
|
||||||
|
lengthToMillimeters(config.paper.margins.left) -
|
||||||
|
lengthToMillimeters(config.paper.margins.right);
|
||||||
|
const height =
|
||||||
|
paper.height -
|
||||||
|
lengthToMillimeters(config.paper.margins.top) -
|
||||||
|
lengthToMillimeters(config.paper.margins.bottom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: millimetersToCssPixels(width),
|
||||||
|
height: millimetersToCssPixels(height)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateDiagramPageFit(
|
||||||
|
intrinsic: DiagramDimensions,
|
||||||
|
pageContent: DiagramDimensions
|
||||||
|
): DiagramPageFit {
|
||||||
|
const heightAfterWidthFit =
|
||||||
|
(pageContent.width * intrinsic.height) / intrinsic.width;
|
||||||
|
|
||||||
|
if (heightAfterWidthFit <= pageContent.height) {
|
||||||
|
return {
|
||||||
|
width: pageContent.width,
|
||||||
|
height: heightAfterWidthFit,
|
||||||
|
scaled: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const height = Math.max(
|
||||||
|
0,
|
||||||
|
pageContent.height - PAGE_FIT_EPSILON_PX
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
width: (height * intrinsic.width) / intrinsic.height,
|
||||||
|
height,
|
||||||
|
scaled: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fitSvgDiagramsToPage(
|
||||||
|
root: ParentNode,
|
||||||
|
config: ExportConfig,
|
||||||
|
options: SvgDiagramFitOptions
|
||||||
|
) {
|
||||||
|
const pageContent = getPageContentDimensions(config);
|
||||||
|
const containers = Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>(options.containerSelector)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const container of containers) {
|
||||||
|
const svg = container.querySelector<SVGSVGElement>(
|
||||||
|
options.svgSelector ?? "svg"
|
||||||
|
);
|
||||||
|
const intrinsic = svg
|
||||||
|
? parseSvgViewBox(svg.getAttribute("viewBox")) ??
|
||||||
|
parseSvgDimensions(svg)
|
||||||
|
: undefined;
|
||||||
|
if (!svg || !intrinsic) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availablePageContent =
|
||||||
|
options.resolvePageContent?.(container, pageContent) ??
|
||||||
|
pageContent;
|
||||||
|
const fit = calculateDiagramPageFit(
|
||||||
|
intrinsic,
|
||||||
|
availablePageContent
|
||||||
|
);
|
||||||
|
if (!fit.scaled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.dataset.pageHeightFitted = "true";
|
||||||
|
container.style.setProperty("margin-block", "0", "important");
|
||||||
|
if (!svg.getAttribute("viewBox")) {
|
||||||
|
svg.setAttribute(
|
||||||
|
"viewBox",
|
||||||
|
`0 0 ${intrinsic.width} ${intrinsic.height}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||||
|
svg.style.setProperty("display", "block");
|
||||||
|
svg.style.setProperty("width", `${fit.width}px`, "important");
|
||||||
|
svg.style.setProperty("height", `${fit.height}px`, "important");
|
||||||
|
svg.style.setProperty("max-width", "100%", "important");
|
||||||
|
svg.style.setProperty(
|
||||||
|
"max-height",
|
||||||
|
`${fit.height}px`,
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
options.onFit?.(container, svg, fit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ExportConfig } from "@md-to-pdf/core";
|
||||||
|
import { fitSvgDiagramsToPage } from "./diagram-page-fit";
|
||||||
|
|
||||||
|
export function fitOversizedEChartsToPage(
|
||||||
|
container: ParentNode,
|
||||||
|
config: ExportConfig
|
||||||
|
) {
|
||||||
|
fitSvgDiagramsToPage(container, config, {
|
||||||
|
containerSelector: ".md-echarts",
|
||||||
|
svgSelector: ".md-echarts-host svg",
|
||||||
|
resolvePageContent(figure, pageContent) {
|
||||||
|
const host = figure.querySelector<HTMLElement>(
|
||||||
|
".md-echarts-host"
|
||||||
|
);
|
||||||
|
const figureHeight = figure.getBoundingClientRect().height;
|
||||||
|
const hostHeight = host?.getBoundingClientRect().height ?? 0;
|
||||||
|
const nonChartHeight = Math.max(
|
||||||
|
0,
|
||||||
|
figureHeight - hostHeight
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
width: pageContent.width,
|
||||||
|
height: Math.max(0, pageContent.height - nonChartHeight)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onFit(figure, _svg, fit) {
|
||||||
|
const host = figure.querySelector<HTMLElement>(
|
||||||
|
".md-echarts-host"
|
||||||
|
);
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.style.setProperty(
|
||||||
|
"width",
|
||||||
|
`${fit.width}px`,
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
host.style.setProperty(
|
||||||
|
"height",
|
||||||
|
`${fit.height}px`,
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
host.style.setProperty("margin-inline", "auto", "important");
|
||||||
|
host.style.removeProperty("aspect-ratio");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,123 +1,26 @@
|
|||||||
|
import type { ExportConfig } from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
getPaperDimensionsMm,
|
calculateDiagramPageFit,
|
||||||
lengthToMillimeters,
|
fitSvgDiagramsToPage,
|
||||||
millimetersToCssPixels,
|
getPageContentDimensions,
|
||||||
type ExportConfig
|
parseSvgViewBox,
|
||||||
} from "@md-to-pdf/core";
|
type DiagramDimensions,
|
||||||
|
type DiagramPageFit
|
||||||
|
} from "./diagram-page-fit";
|
||||||
|
|
||||||
export interface MermaidDimensions {
|
export type MermaidDimensions = DiagramDimensions;
|
||||||
width: number;
|
export type MermaidPageFit = DiagramPageFit;
|
||||||
height: number;
|
export {
|
||||||
}
|
getPageContentDimensions,
|
||||||
|
parseSvgViewBox
|
||||||
export interface MermaidPageFit extends MermaidDimensions {
|
};
|
||||||
scaled: boolean;
|
export const calculateMermaidPageFit = calculateDiagramPageFit;
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_FIT_EPSILON_PX = 1;
|
|
||||||
|
|
||||||
export function parseSvgViewBox(
|
|
||||||
value: string | null
|
|
||||||
): MermaidDimensions | undefined {
|
|
||||||
if (!value) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = value
|
|
||||||
.trim()
|
|
||||||
.split(/[\s,]+/)
|
|
||||||
.map((part) => Number(part));
|
|
||||||
const [, , width = Number.NaN, height = Number.NaN] = values;
|
|
||||||
|
|
||||||
if (
|
|
||||||
values.length !== 4 ||
|
|
||||||
!Number.isFinite(width) ||
|
|
||||||
!Number.isFinite(height) ||
|
|
||||||
width <= 0 ||
|
|
||||||
height <= 0
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPageContentDimensions(
|
|
||||||
config: ExportConfig
|
|
||||||
): MermaidDimensions {
|
|
||||||
const paper = getPaperDimensionsMm(
|
|
||||||
config.paper.format,
|
|
||||||
config.paper.orientation
|
|
||||||
);
|
|
||||||
const width =
|
|
||||||
paper.width -
|
|
||||||
lengthToMillimeters(config.paper.margins.left) -
|
|
||||||
lengthToMillimeters(config.paper.margins.right);
|
|
||||||
const height =
|
|
||||||
paper.height -
|
|
||||||
lengthToMillimeters(config.paper.margins.top) -
|
|
||||||
lengthToMillimeters(config.paper.margins.bottom);
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: millimetersToCssPixels(width),
|
|
||||||
height: millimetersToCssPixels(height)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateMermaidPageFit(
|
|
||||||
intrinsic: MermaidDimensions,
|
|
||||||
pageContent: MermaidDimensions
|
|
||||||
): MermaidPageFit {
|
|
||||||
const heightAfterWidthFit =
|
|
||||||
(pageContent.width * intrinsic.height) / intrinsic.width;
|
|
||||||
|
|
||||||
if (heightAfterWidthFit <= pageContent.height) {
|
|
||||||
return {
|
|
||||||
width: pageContent.width,
|
|
||||||
height: heightAfterWidthFit,
|
|
||||||
scaled: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const height = Math.max(
|
|
||||||
0,
|
|
||||||
pageContent.height - PAGE_FIT_EPSILON_PX
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
width: (height * intrinsic.width) / intrinsic.height,
|
|
||||||
height,
|
|
||||||
scaled: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fitOversizedMermaidToPage(
|
export function fitOversizedMermaidToPage(
|
||||||
container: ParentNode,
|
container: ParentNode,
|
||||||
config: ExportConfig
|
config: ExportConfig
|
||||||
) {
|
) {
|
||||||
const pageContent = getPageContentDimensions(config);
|
fitSvgDiagramsToPage(container, config, {
|
||||||
const diagrams = Array.from(
|
containerSelector: ".mermaid"
|
||||||
container.querySelectorAll<HTMLElement>(".mermaid")
|
});
|
||||||
);
|
|
||||||
|
|
||||||
for (const diagram of diagrams) {
|
|
||||||
const svg = diagram.querySelector<SVGSVGElement>("svg");
|
|
||||||
const intrinsic = parseSvgViewBox(svg?.getAttribute("viewBox") ?? null);
|
|
||||||
if (!svg || !intrinsic) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fit = calculateMermaidPageFit(intrinsic, pageContent);
|
|
||||||
if (!fit.scaled) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
diagram.dataset.pageHeightFitted = "true";
|
|
||||||
diagram.style.setProperty("margin-block", "0", "important");
|
|
||||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
||||||
svg.style.setProperty("display", "block");
|
|
||||||
svg.style.setProperty("width", `${fit.width}px`, "important");
|
|
||||||
svg.style.setProperty("height", `${fit.height}px`, "important");
|
|
||||||
svg.style.setProperty("max-width", "100%", "important");
|
|
||||||
svg.style.setProperty("max-height", `${fit.height}px`, "important");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
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 echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
||||||
|
import type {
|
||||||
|
PagedDocumentRenderResult,
|
||||||
|
PagedDocumentTimings
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
import { Previewer } from "pagedjs";
|
import { Previewer } from "pagedjs";
|
||||||
|
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
||||||
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 +26,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 +67,55 @@ 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,
|
||||||
|
echartsCss,
|
||||||
|
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,
|
||||||
@@ -116,6 +162,11 @@ export class PagedDocumentRuntime {
|
|||||||
private mermaidPromise:
|
private mermaidPromise:
|
||||||
| Promise<(typeof import("mermaid"))["default"]>
|
| Promise<(typeof import("mermaid"))["default"]>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
private echartsPromise:
|
||||||
|
| Promise<
|
||||||
|
typeof import("@md-to-pdf/markdown-echarts/browser")
|
||||||
|
>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
constructor(private readonly root: HTMLElement) {}
|
constructor(private readonly root: HTMLElement) {}
|
||||||
|
|
||||||
@@ -131,9 +182,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;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -142,6 +196,41 @@ export class PagedDocumentRuntime {
|
|||||||
return this.mermaidPromise;
|
return this.mermaidPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadECharts() {
|
||||||
|
if (!this.echartsPromise) {
|
||||||
|
this.echartsPromise = import(
|
||||||
|
"@md-to-pdf/markdown-echarts/browser"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.echartsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderECharts(
|
||||||
|
container: ParentNode,
|
||||||
|
payload: PagedPreviewPayload
|
||||||
|
) {
|
||||||
|
if (!payload.features.includes("echarts")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { renderEChartsBlocks } = await this.loadECharts();
|
||||||
|
const outcomes = await renderEChartsBlocks(container, {
|
||||||
|
outputMode: "inline-svg",
|
||||||
|
concurrency: 2,
|
||||||
|
timeoutMs: 5_000
|
||||||
|
});
|
||||||
|
|
||||||
|
return outcomes.flatMap((outcome, index) =>
|
||||||
|
outcome.success
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
`图表 ${index + 1}:${
|
||||||
|
outcome.error?.message ?? "未知错误"
|
||||||
|
}`
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async renderMermaid(
|
private async renderMermaid(
|
||||||
container: DocumentFragment,
|
container: DocumentFragment,
|
||||||
payload: PagedPreviewPayload
|
payload: PagedPreviewPayload
|
||||||
@@ -160,6 +249,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 +319,58 @@ 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 echartsErrors: string[] = [];
|
||||||
fitOversizedMermaidToPage(content, payload.exportConfig);
|
let echartsMs = 0;
|
||||||
const mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
let echartsFitMs = 0;
|
||||||
|
let mermaidFitMs = 0;
|
||||||
|
let mermaidConversionMs = 0;
|
||||||
|
if (
|
||||||
|
payload.features.includes("echarts") ||
|
||||||
|
content.querySelector(".mermaid svg")
|
||||||
|
) {
|
||||||
|
const measurement = mountMeasurementContainer(
|
||||||
|
documentRef,
|
||||||
|
content,
|
||||||
|
payload,
|
||||||
|
options.target
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await documentRef.fonts.ready;
|
||||||
|
|
||||||
const mermaidConversionStartedAt = performance.now();
|
const echartsStartedAt = performance.now();
|
||||||
if (options.mermaidOutput === "svg-image") {
|
echartsErrors = await this.renderECharts(
|
||||||
replaceMermaidSvgWithImages(content);
|
measurement.host,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
echartsMs = performance.now() - echartsStartedAt;
|
||||||
|
await waitForImages(measurement.host);
|
||||||
|
|
||||||
|
const echartsFitStartedAt = performance.now();
|
||||||
|
fitOversizedEChartsToPage(
|
||||||
|
measurement.host,
|
||||||
|
payload.exportConfig
|
||||||
|
);
|
||||||
|
echartsFitMs =
|
||||||
|
performance.now() - echartsFitStartedAt;
|
||||||
|
|
||||||
|
const mermaidFitStartedAt = performance.now();
|
||||||
|
fitOversizedMermaidToPage(
|
||||||
|
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;
|
||||||
@@ -256,6 +390,7 @@ export class PagedDocumentRuntime {
|
|||||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
stylesheet(documentRef, documentBaseCss, "document-base"),
|
||||||
stylesheet(documentRef, highlightCss, "highlight"),
|
stylesheet(documentRef, highlightCss, "highlight"),
|
||||||
stylesheet(documentRef, katexCss, "katex"),
|
stylesheet(documentRef, katexCss, "katex"),
|
||||||
|
stylesheet(documentRef, echartsCss, "echarts"),
|
||||||
stylesheet(documentRef, payload.themeCss, "theme"),
|
stylesheet(documentRef, payload.themeCss, "theme"),
|
||||||
stylesheet(
|
stylesheet(
|
||||||
documentRef,
|
documentRef,
|
||||||
@@ -285,16 +420,12 @@ export class PagedDocumentRuntime {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
pageCount: flow.total,
|
pageCount: flow.total,
|
||||||
contentHeight: Math.ceil(
|
echartsErrors,
|
||||||
Math.max(
|
|
||||||
documentRef.documentElement.scrollHeight,
|
|
||||||
documentRef.body.scrollHeight,
|
|
||||||
this.root.scrollHeight
|
|
||||||
)
|
|
||||||
),
|
|
||||||
mermaidErrors,
|
mermaidErrors,
|
||||||
timings: {
|
timings: {
|
||||||
setupMs,
|
setupMs,
|
||||||
|
echartsMs,
|
||||||
|
echartsFitMs,
|
||||||
mermaidMs,
|
mermaidMs,
|
||||||
mermaidFitMs,
|
mermaidFitMs,
|
||||||
mermaidConversionMs,
|
mermaidConversionMs,
|
||||||
|
|||||||
@@ -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,35 @@ 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
|
contentHeight: Math.ceil(document.documentElement.scrollHeight),
|
||||||
});
|
...result
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (requestId === latestRequestId) {
|
||||||
|
completePreviewFrameRender(document.documentElement, requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__mdToPdfRender = async (
|
window.__mdToPdfRender = async (
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -46,6 +32,7 @@ export type PagedPreviewFrameMessage =
|
|||||||
requestId: number;
|
requestId: number;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
contentHeight: number;
|
contentHeight: number;
|
||||||
|
echartsErrors: string[];
|
||||||
mermaidErrors: string[];
|
mermaidErrors: string[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -105,6 +92,11 @@ svg {
|
|||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-echarts {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const documentGeometryCss = `
|
export const documentGeometryCss = `
|
||||||
@@ -306,7 +298,8 @@ ${buildFooterCss(config)}
|
|||||||
#write pre,
|
#write pre,
|
||||||
#write blockquote,
|
#write blockquote,
|
||||||
#write .katex-display,
|
#write .katex-display,
|
||||||
#write .mermaid {
|
#write .mermaid,
|
||||||
|
#write .md-echarts {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,16 +335,7 @@ ${
|
|||||||
html[data-render-target="preview"] {
|
html[data-render-target="preview"] {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: scroll;
|
overflow-y: hidden;
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-render-target="preview"]::-webkit-scrollbar {
|
|
||||||
width: ${PREVIEW_SCROLLBAR_WIDTH_PX}px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-render-target="preview"]::-webkit-scrollbar:horizontal {
|
|
||||||
height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-render-target="preview"] body {
|
html[data-render-target="preview"] body {
|
||||||
@@ -426,10 +410,26 @@ export function isPagedPreviewFrameMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candidate = value as Partial<PagedPreviewFrameMessage>;
|
const candidate = value as Partial<PagedPreviewFrameMessage>;
|
||||||
return (
|
if (
|
||||||
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
|
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
|
||||||
["ready", "rendering", "rendered", "error"].includes(
|
!["ready", "rendering", "rendered", "error"].includes(
|
||||||
candidate.type ?? ""
|
candidate.type ?? ""
|
||||||
)
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.type !== "rendered") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
Number.isInteger(candidate.requestId) &&
|
||||||
|
Number.isInteger(candidate.pageCount) &&
|
||||||
|
typeof candidate.contentHeight === "number" &&
|
||||||
|
Number.isFinite(candidate.contentHeight) &&
|
||||||
|
candidate.contentHeight > 0 &&
|
||||||
|
Array.isArray(candidate.echartsErrors) &&
|
||||||
|
Array.isArray(candidate.mermaidErrors)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface PdfExportResult {
|
|||||||
blob: Blob;
|
blob: Blob;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
pageCount: number | undefined;
|
pageCount: number | undefined;
|
||||||
|
echartsErrorCount: number;
|
||||||
mermaidErrorCount: number;
|
mermaidErrorCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +118,10 @@ export async function requestPdfExport(
|
|||||||
pageCount: parseNumericHeader(
|
pageCount: parseNumericHeader(
|
||||||
response.headers.get("x-pdf-page-count")
|
response.headers.get("x-pdf-page-count")
|
||||||
),
|
),
|
||||||
|
echartsErrorCount:
|
||||||
|
parseNumericHeader(
|
||||||
|
response.headers.get("x-echarts-error-count")
|
||||||
|
) ?? 0,
|
||||||
mermaidErrorCount:
|
mermaidErrorCount:
|
||||||
parseNumericHeader(
|
parseNumericHeader(
|
||||||
response.headers.get("x-mermaid-error-count")
|
response.headers.get("x-mermaid-error-count")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,7 +3,50 @@ export interface PageVerticalRange {
|
|||||||
bottom: number;
|
bottom: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PREVIEW_SCROLLBAR_WIDTH_PX = 14;
|
export function getPreviewPageScrollTop(
|
||||||
|
currentScrollTop: number,
|
||||||
|
pageViewportTop: number,
|
||||||
|
scrollerViewportTop: number
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(currentScrollTop) ||
|
||||||
|
!Number.isFinite(pageViewportTop) ||
|
||||||
|
!Number.isFinite(scrollerViewportTop)
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
currentScrollTop + pageViewportTop - scrollerViewportTop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePreviewPageNumber(
|
||||||
|
value: string,
|
||||||
|
current: number,
|
||||||
|
total: number
|
||||||
|
) {
|
||||||
|
if (!Number.isInteger(total) || total < 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = value.trim();
|
||||||
|
const fallback = Number.isInteger(current)
|
||||||
|
? Math.min(Math.max(current, 1), total)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
if (normalizedValue.length === 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(normalizedValue);
|
||||||
|
if (!Number.isInteger(parsed)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max(parsed, 1), total);
|
||||||
|
}
|
||||||
|
|
||||||
export function getNearestPageNumber(
|
export function getNearestPageNumber(
|
||||||
viewportCenter: number,
|
viewportCenter: number,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// 预览缩放是非关键偏好,浏览器禁用缓存时保持当前会话值即可。
|
||||||
|
}
|
||||||
|
}
|
||||||
+251
-3
@@ -63,6 +63,39 @@ button:disabled {
|
|||||||
background: rgb(247 248 246 / 92%);
|
background: rgb(247 248 246 / 92%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-primary {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-brand {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: min(42vw, 520px);
|
||||||
|
padding-left: 28px;
|
||||||
|
border-left: 1px solid #d4dad7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary > strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary .render-status {
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
color: #3b755d;
|
color: #3b755d;
|
||||||
@@ -82,6 +115,7 @@ h1 {
|
|||||||
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
@@ -104,6 +138,11 @@ h1 {
|
|||||||
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
transition: grid-template-columns 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-layout.is-source-collapsed {
|
||||||
|
grid-template-columns: 48px minmax(520px, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-panel,
|
.editor-panel,
|
||||||
@@ -119,6 +158,45 @@ h1 {
|
|||||||
background: #f6f7f5;
|
background: #f6f7f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-panel-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-panel-content[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-panel-toggle {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-color: #c9d4ce;
|
||||||
|
background: #f8fbf9;
|
||||||
|
color: #4f695e;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-panel-restore {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f0f3f1;
|
||||||
|
color: #4f695e;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-panel-restore:hover {
|
||||||
|
background: #e4ebe7;
|
||||||
|
color: #356c55;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-heading {
|
.panel-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -176,14 +254,109 @@ h1 {
|
|||||||
max-width: 68%;
|
max-width: 68%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-page-indicator {
|
.preview-toolbar {
|
||||||
|
display: flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
min-height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 20px;
|
||||||
|
border-bottom: 1px solid #d9dedb;
|
||||||
|
background: rgb(255 255 255 / 58%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-page-control {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
color: #5d7168;
|
color: #5d7168;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-page-control input {
|
||||||
|
width: 48px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid #c9d4ce;
|
||||||
|
border-radius: 7px;
|
||||||
|
outline: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: #356c55;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-page-control input:focus {
|
||||||
|
border-color: #74a88f;
|
||||||
|
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-mode-control {
|
.preview-mode-control {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -290,6 +463,28 @@ textarea:focus {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0 30px;
|
padding: 0 30px;
|
||||||
|
scrollbar-color: #8d9993 #dfe4e1;
|
||||||
|
scrollbar-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scroll::-webkit-scrollbar {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scroll::-webkit-scrollbar-track {
|
||||||
|
background: #dfe4e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scroll::-webkit-scrollbar-thumb {
|
||||||
|
min-height: 48px;
|
||||||
|
border: 3px solid #dfe4e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #8d9993;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6f7d76;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paper {
|
.paper {
|
||||||
@@ -319,7 +514,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 +522,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%);
|
||||||
@@ -737,6 +931,21 @@ textarea:focus {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-primary {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-left: 0;
|
||||||
|
border-top: 1px solid #d4dad7;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -745,6 +954,10 @@ textarea:focus {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-layout.is-source-collapsed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-panel,
|
.editor-panel,
|
||||||
.preview-panel {
|
.preview-panel {
|
||||||
min-height: 650px;
|
min-height: 650px;
|
||||||
@@ -759,6 +972,19 @@ textarea:focus {
|
|||||||
border-bottom: 1px solid #cbd2ce;
|
border-bottom: 1px solid #cbd2ce;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-layout.is-source-collapsed .editor-panel {
|
||||||
|
height: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-panel-restore {
|
||||||
|
height: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-scroll {
|
.preview-scroll {
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
@@ -775,11 +1001,33 @@ textarea:focus {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-control {
|
||||||
|
flex: 1 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-slider {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-control select {
|
.theme-control select {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.editor-layout {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.settings-drawer {
|
.settings-drawer {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit";
|
||||||
|
|
||||||
|
describe("ECharts 单页高度适配", () => {
|
||||||
|
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<figure class="md-echarts">
|
||||||
|
<div class="md-echarts-host" style="height: 2000px">
|
||||||
|
<svg width="1000" height="2000"></svg>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
`;
|
||||||
|
|
||||||
|
fitOversizedEChartsToPage(root, defaultExportConfig);
|
||||||
|
|
||||||
|
const figure = root.querySelector<HTMLElement>(".md-echarts");
|
||||||
|
const host = root.querySelector<HTMLElement>(
|
||||||
|
".md-echarts-host"
|
||||||
|
);
|
||||||
|
const svg = root.querySelector<SVGSVGElement>("svg");
|
||||||
|
expect(figure?.dataset.pageHeightFitted).toBe("true");
|
||||||
|
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
|
||||||
|
1000.5748,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
expect(host?.style.aspectRatio).toBe("");
|
||||||
|
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
|
||||||
|
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
|
||||||
|
1000.5748,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("普通高度图表不改变作者设置的宿主高度", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<figure class="md-echarts">
|
||||||
|
<div class="md-echarts-host" style="height: 300px">
|
||||||
|
<svg viewBox="0 0 1000 400"></svg>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
`;
|
||||||
|
|
||||||
|
fitOversizedEChartsToPage(root, defaultExportConfig);
|
||||||
|
|
||||||
|
const figure = root.querySelector<HTMLElement>(".md-echarts");
|
||||||
|
const host = root.querySelector<HTMLElement>(
|
||||||
|
".md-echarts-host"
|
||||||
|
);
|
||||||
|
expect(figure?.dataset.pageHeightFitted).toBeUndefined();
|
||||||
|
expect(host?.style.height).toBe("300px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ describe("分页预览协议", () => {
|
|||||||
expect(css).toContain("padding: 34px 0");
|
expect(css).toContain("padding: 34px 0");
|
||||||
expect(css).toContain('html[data-render-target="preview"]');
|
expect(css).toContain('html[data-render-target="preview"]');
|
||||||
expect(css).toContain("overflow-x: hidden");
|
expect(css).toContain("overflow-x: hidden");
|
||||||
expect(css).toContain("overflow-y: scroll");
|
expect(css).toContain("overflow-y: hidden");
|
||||||
|
expect(css).not.toContain("scrollbar-color");
|
||||||
expect(css).toContain('html[data-render-target="pdf"]');
|
expect(css).toContain('html[data-render-target="pdf"]');
|
||||||
expect(css).toContain("height: auto !important");
|
expect(css).toContain("height: auto !important");
|
||||||
expect(css).toContain("overflow: visible !important");
|
expect(css).toContain("overflow: visible !important");
|
||||||
@@ -178,10 +179,21 @@ describe("分页预览协议", () => {
|
|||||||
type: "rendered",
|
type: "rendered",
|
||||||
requestId: 3,
|
requestId: 3,
|
||||||
pageCount: 2,
|
pageCount: 2,
|
||||||
contentHeight: 2300,
|
contentHeight: 2400,
|
||||||
|
echartsErrors: [],
|
||||||
mermaidErrors: []
|
mermaidErrors: []
|
||||||
})
|
})
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPagedPreviewFrameMessage({
|
||||||
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
|
type: "rendered",
|
||||||
|
requestId: 3,
|
||||||
|
pageCount: 2,
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: []
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
isPagedPreviewFrameMessage({
|
isPagedPreviewFrameMessage({
|
||||||
scope: "other",
|
scope: "other",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe("PDF 导出客户端", () => {
|
|||||||
blob: new Blob(["%PDF-test"]),
|
blob: new Blob(["%PDF-test"]),
|
||||||
fileName: "测试.pdf",
|
fileName: "测试.pdf",
|
||||||
pageCount: 1,
|
pageCount: 1,
|
||||||
|
echartsErrorCount: 0,
|
||||||
mermaidErrorCount: 0
|
mermaidErrorCount: 0
|
||||||
};
|
};
|
||||||
const cache: CachedPdfExport = { key, result };
|
const cache: CachedPdfExport = { key, result };
|
||||||
@@ -65,6 +66,7 @@ describe("PDF 导出客户端", () => {
|
|||||||
blob: new Blob(["%PDF-cached"]),
|
blob: new Blob(["%PDF-cached"]),
|
||||||
fileName: "测试.pdf",
|
fileName: "测试.pdf",
|
||||||
pageCount: 1,
|
pageCount: 1,
|
||||||
|
echartsErrorCount: 0,
|
||||||
mermaidErrorCount: 0
|
mermaidErrorCount: 0
|
||||||
};
|
};
|
||||||
const cache: CachedPdfExport = {
|
const cache: CachedPdfExport = {
|
||||||
@@ -95,6 +97,7 @@ describe("PDF 导出客户端", () => {
|
|||||||
blob: new Blob(["%PDF-new"]),
|
blob: new Blob(["%PDF-new"]),
|
||||||
fileName: "测试.pdf",
|
fileName: "测试.pdf",
|
||||||
pageCount: 2,
|
pageCount: 2,
|
||||||
|
echartsErrorCount: 0,
|
||||||
mermaidErrorCount: 0
|
mermaidErrorCount: 0
|
||||||
};
|
};
|
||||||
const requester = vi.fn(async () => result);
|
const requester = vi.fn(async () => result);
|
||||||
@@ -128,6 +131,7 @@ describe("PDF 导出客户端", () => {
|
|||||||
"content-disposition":
|
"content-disposition":
|
||||||
"attachment; filename=\"document.pdf\"",
|
"attachment; filename=\"document.pdf\"",
|
||||||
"x-pdf-page-count": "4",
|
"x-pdf-page-count": "4",
|
||||||
|
"x-echarts-error-count": "2",
|
||||||
"x-mermaid-error-count": "1"
|
"x-mermaid-error-count": "1"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -149,6 +153,7 @@ describe("PDF 导出客户端", () => {
|
|||||||
);
|
);
|
||||||
expect(result.fileName).toBe("document.pdf");
|
expect(result.fileName).toBe("document.pdf");
|
||||||
expect(result.pageCount).toBe(4);
|
expect(result.pageCount).toBe(4);
|
||||||
|
expect(result.echartsErrorCount).toBe(2);
|
||||||
expect(result.mermaidErrorCount).toBe(1);
|
expect(result.mermaidErrorCount).toBe(1);
|
||||||
expect(await result.blob.text()).toBe("%PDF-test");
|
expect(await result.blob.text()).toBe("%PDF-test");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getNearestPageNumber } from "../src/preview-page";
|
import {
|
||||||
|
getNearestPageNumber,
|
||||||
|
getPreviewPageScrollTop,
|
||||||
|
normalizePreviewPageNumber
|
||||||
|
} from "../src/preview-page";
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{ top: 0, bottom: 100 },
|
{ top: 0, bottom: 100 },
|
||||||
@@ -22,3 +26,35 @@ describe("getNearestPageNumber", () => {
|
|||||||
expect(getNearestPageNumber(Number.NaN, pages)).toBe(0);
|
expect(getNearestPageNumber(Number.NaN, pages)).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("normalizePreviewPageNumber", () => {
|
||||||
|
it("保留有效的整数页码", () => {
|
||||||
|
expect(normalizePreviewPageNumber("12", 3, 65)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将越界页码限制在有效范围", () => {
|
||||||
|
expect(normalizePreviewPageNumber("0", 3, 65)).toBe(1);
|
||||||
|
expect(normalizePreviewPageNumber("99", 3, 65)).toBe(65);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无效输入恢复当前页", () => {
|
||||||
|
expect(normalizePreviewPageNumber("", 7, 65)).toBe(7);
|
||||||
|
expect(normalizePreviewPageNumber("3.5", 7, 65)).toBe(7);
|
||||||
|
expect(normalizePreviewPageNumber("abc", 7, 65)).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("没有可用页面时返回 0", () => {
|
||||||
|
expect(normalizePreviewPageNumber("1", 0, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPreviewPageScrollTop", () => {
|
||||||
|
it("按目标页相对滚动容器的位置计算滚动量", () => {
|
||||||
|
expect(getPreviewPageScrollTop(200, 900, 120)).toBe(980);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("避免返回负数或无效滚动量", () => {
|
||||||
|
expect(getPreviewPageScrollTop(20, 10, 100)).toBe(0);
|
||||||
|
expect(getPreviewPageScrollTop(Number.NaN, 10, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ COPY package.json package-lock.json ./
|
|||||||
COPY apps/server/package.json apps/server/package.json
|
COPY apps/server/package.json apps/server/package.json
|
||||||
COPY apps/web/package.json apps/web/package.json
|
COPY apps/web/package.json apps/web/package.json
|
||||||
COPY packages/core/package.json packages/core/package.json
|
COPY packages/core/package.json packages/core/package.json
|
||||||
|
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||||
COPY packages/renderer/package.json packages/renderer/package.json
|
COPY packages/renderer/package.json packages/renderer/package.json
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ COPY package.json package-lock.json ./
|
|||||||
COPY apps/server/package.json apps/server/package.json
|
COPY apps/server/package.json apps/server/package.json
|
||||||
COPY apps/web/package.json apps/web/package.json
|
COPY apps/web/package.json apps/web/package.json
|
||||||
COPY packages/core/package.json packages/core/package.json
|
COPY packages/core/package.json packages/core/package.json
|
||||||
|
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||||
COPY packages/renderer/package.json packages/renderer/package.json
|
COPY packages/renderer/package.json packages/renderer/package.json
|
||||||
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
|
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ COPY --chown=pwuser:pwuser package.json package-lock.json ./
|
|||||||
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
|
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
|
||||||
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
|
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
|
||||||
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
|
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
|
||||||
|
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||||
COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json
|
COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json
|
||||||
COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules
|
COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
@@ -56,6 +59,7 @@ RUN npx playwright install --with-deps chromium \
|
|||||||
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
|
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
|
||||||
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
|
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
|
||||||
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
|
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
|
||||||
|
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
|
||||||
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
|
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
|
||||||
COPY --chown=pwuser:pwuser themes ./themes
|
COPY --chown=pwuser:pwuser themes ./themes
|
||||||
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
image: md-to-pdf:local
|
image: "${MD_TO_PDF_IMAGE:-md-to-pdf:local}"
|
||||||
init: true
|
init: true
|
||||||
ports:
|
ports:
|
||||||
- "${MD_TO_PDF_PORT:-8080}:8080"
|
- "${MD_TO_PDF_PORT:-8080}:8080"
|
||||||
|
|||||||
+231
-12
@@ -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,10 @@ fa07472 feat: 实现网页实时预览
|
|||||||
ec48bce chore: 初始化项目骨架
|
ec48bce chore: 初始化项目骨架
|
||||||
```
|
```
|
||||||
|
|
||||||
最新阶段已完成 Chromium PDF 导出、精确预览、PDF.js 渲染缓冲和 Mermaid 分页性能优化。
|
最新阶段已完成 Markdown ECharts YAML 围栏、统一 SVG 渲染与分页,
|
||||||
|
并完善默认示例、源文本收起、顶栏文档状态和快速页码跳转。
|
||||||
|
当前正在推进 `v0.3.1` ECharts 第二阶段,扩展层级图、关系图和更多
|
||||||
|
业务图表类型。
|
||||||
|
|
||||||
## 2. 已完成
|
## 2. 已完成
|
||||||
|
|
||||||
@@ -46,7 +51,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 +59,7 @@ ec48bce chore: 初始化项目骨架
|
|||||||
- 页码位置和格式;
|
- 页码位置和格式;
|
||||||
- PDF 元数据;
|
- PDF 元数据;
|
||||||
- 打印背景、缩放和一级标题分页选项;
|
- 打印背景、缩放和一级标题分页选项;
|
||||||
|
- Mermaid 布局、主题、外观和字体;
|
||||||
- Zod 数据校验;
|
- Zod 数据校验;
|
||||||
- 默认 A4 技术文档预设。
|
- 默认 A4 技术文档预设。
|
||||||
|
|
||||||
@@ -127,7 +133,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 +142,7 @@ ec48bce chore: 初始化项目骨架
|
|||||||
- 页眉左中右内容和变量;
|
- 页眉左中右内容和变量;
|
||||||
- 五种页码样式、对齐和起始页码;
|
- 五种页码样式、对齐和起始页码;
|
||||||
- Mermaid 官方配置 frontmatter、默认主题与错误隔离;
|
- Mermaid 官方配置 frontmatter、默认主题与错误隔离;
|
||||||
|
- Mermaid 全局 Dagre/ELK 布局、官方主题、外观和字体设置;
|
||||||
- Markdown 表格对齐属性保留和自适应列宽;
|
- Markdown 表格对齐属性保留和自适应列宽;
|
||||||
- 纸张尺寸和页边距实时预览。
|
- 纸张尺寸和页边距实时预览。
|
||||||
|
|
||||||
@@ -190,9 +197,131 @@ 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` 字段。
|
||||||
|
|
||||||
|
### 3.8 Windows 显示缩放与快速预览分页差异
|
||||||
|
|
||||||
|
- 此前 65 页长文档在快速预览中生成 64 页、在精确预览和最终 PDF 中
|
||||||
|
生成 65 页;该差异并非由 Chromium 版本不同导致。
|
||||||
|
- 出现差异时,笔记本内屏使用 Windows 150% 系统显示缩放。切换到使用
|
||||||
|
100% 显示缩放的外接显示器后,快速预览与精确预览的分页结果基本一致。
|
||||||
|
- 当前定位是:Windows 显示缩放改变浏览器的 `devicePixelRatio`,并可能
|
||||||
|
影响字体栅格化和 CSS 布局中的子像素取整;单处差异很小,但会在长文档
|
||||||
|
分页过程中累计,最终造成页数差异。
|
||||||
|
- 精确预览直接展示服务端 Chromium 生成的 PDF,最终下载也复用同一结果,
|
||||||
|
因此精确预览和最终 PDF 继续作为权威分页结果;快速预览定位为编辑期间的
|
||||||
|
客户端近似分页,不承诺在所有显示缩放环境下与 PDF 页数完全一致。
|
||||||
|
- 后续优化快速预览时,应重点检查 `devicePixelRatio`、浏览器页面缩放、
|
||||||
|
字体度量和逐页高度取整;分页测量不得依赖物理像素,视觉缩放不得参与
|
||||||
|
文档排版和分页计算,并避免逐页取整造成累计误差。
|
||||||
|
- 后续分页回归应覆盖 Windows 100%、125% 和 150% 系统显示缩放,分别
|
||||||
|
记录快速预览、精确预览和最终 PDF 的页数及分页边界。
|
||||||
|
|
||||||
|
### 3.9 Markdown ECharts 图表
|
||||||
|
|
||||||
|
- 新增可独立复用的 `packages/markdown-echarts` 工作区,为 Markdown-it
|
||||||
|
提供安全的 `echarts` YAML 围栏。
|
||||||
|
- 围栏支持版本、固定高度或宽高比、主题、图注和 ECharts `option`;
|
||||||
|
高度默认 80mm,主题默认跟随文档。
|
||||||
|
- 当前支持 line、bar、scatter、pie、radar、heatmap、boxplot 和
|
||||||
|
candlestick 系列,并校验各系列的数据结构和必要坐标系。
|
||||||
|
- 每个系列必须使用非空 `series[].data`、`dataset.source` 或具有有效
|
||||||
|
上游数据的内置 transform;缺少数据等错误会显示包含配置路径的局部
|
||||||
|
占位,不中断整篇 Markdown。
|
||||||
|
- YAML 会转换为安全纯数据对象;禁止函数、自定义标签、锚点和别名、
|
||||||
|
外部 URL、HTML formatter、原型污染属性及 `custom` 系列。
|
||||||
|
- 浏览器按需加载 ECharts 模块,以 SVG 渲染,并可冻结为内联 SVG 或
|
||||||
|
SVG Data URL 图片;快速预览与 PDF 使用同一套运行时。
|
||||||
|
- 分页遵循 Mermaid 的不可拆分规则:当前页空间足够时原位放置,不足时
|
||||||
|
移到下一页,超高图表等比例缩放到单页内,不跨页切割。
|
||||||
|
- PDF 输出等待 ECharts、Mermaid、字体和图片全部完成后再分页,并分别
|
||||||
|
返回 ECharts 与 Mermaid 错误数量。
|
||||||
|
- 包内 README 已记录围栏语法、默认值、验证规则、错误行为、安全边界、
|
||||||
|
Markdown-it 接入和浏览器渲染方式。
|
||||||
|
|
||||||
|
### 3.10 工作区布局与页码导航
|
||||||
|
|
||||||
|
- 默认 Markdown 示例新增 ECharts 柱状图,打开网页即可验证 YAML 围栏、
|
||||||
|
SVG 渲染和分页。
|
||||||
|
- 文件名及渲染、分页、导出错误状态从源文本标题栏迁移到顶部应用标题
|
||||||
|
旁,长文件名支持省略和完整悬停提示。
|
||||||
|
- 源文本区域支持收起和展开;桌面端收起为 48px 左侧窄栏,预览区扩展
|
||||||
|
到剩余宽度并保持纸张居中,窄屏收起为 44px 顶部横条。
|
||||||
|
- 折叠内容使用真实 `display:none`,不会残留可聚焦文本框或进入无障碍
|
||||||
|
树;按钮提供 `aria-controls` 和 `aria-expanded`。
|
||||||
|
- 当前页从预览标题行移到缩放工具栏左侧,显示为
|
||||||
|
`第 [n] / 总页数 页`,避免重复信息。
|
||||||
|
- 页码输入支持 Enter 和失焦提交,自动限制到 1~总页数;空值和非法
|
||||||
|
小数恢复当前页。
|
||||||
|
- 快速预览直接定位 iframe 内 `.pagedjs_page`;精确预览定位
|
||||||
|
`.precise-pdf-page`。滚动预览时输入框同步当前页。
|
||||||
|
- 精确预览会动态识别桌面内部滚动容器或窄屏主窗口滚动容器,目标页顶端
|
||||||
|
与实际可视滚动区域对齐。
|
||||||
|
- 快速预览 iframe 只负责生成分页内容并回传自然高度,滚动统一交给外层
|
||||||
|
`.preview-scroll`;滚动条因此与精确预览一样贴在右侧预览容器边缘,
|
||||||
|
不再贴着居中的纸张右边缘。
|
||||||
|
- 快速预览的页码识别和跳转会合并外层滚动位置、iframe 位置、内部分页
|
||||||
|
位置与显示缩放比例,50%~400% 视觉缩放不会破坏页码同步。
|
||||||
|
|
||||||
|
### 3.11 ECharts 第二阶段图表
|
||||||
|
|
||||||
|
- ECharts 系列支持从首版 8 种扩展到 17 种,新增 `tree`、`treemap`、
|
||||||
|
`sunburst`、`graph`、`sankey`、`chord`、`funnel`、`gauge` 和
|
||||||
|
`pictorialBar`。
|
||||||
|
- 当前使用 ECharts 6.1.0,`chord` 直接采用 ECharts 6 官方和弦图及
|
||||||
|
`ChordChart` 按需模块,不引入第三方扩展。
|
||||||
|
- `tree`、`treemap` 和 `sunburst` 校验递归层级结构;矩形树图和旭日图
|
||||||
|
的叶节点必须提供有限数值。
|
||||||
|
- `graph`、`sankey` 和 `chord` 校验节点、关系边、端点引用与边权重;
|
||||||
|
Graph 未指定布局时,有完整坐标则使用 `none`,否则默认使用 `force`。
|
||||||
|
- `funnel`、`gauge` 校验有限数值数据;`pictorialBar` 复用柱图的数据和
|
||||||
|
坐标轴规则,并允许不加载外部资源的 `path://` 内嵌矢量路径。
|
||||||
|
- 安全策略新增层级节点、关系节点和关系边数量限制,继续禁止外部图片、
|
||||||
|
跳转链接、函数、HTML formatter 和原型污染属性。
|
||||||
|
- 默认 Markdown 现在包含基础柱状图及九种第二阶段图表,每种均使用独立
|
||||||
|
YAML 围栏,可直接编辑和观察 SVG、分页及错误占位行为。
|
||||||
|
- Treemap 默认示例关闭节点下钻和面包屑,显式启用父级与叶节点标签,并
|
||||||
|
增加白色分隔边框,避免扁平数据区域缺少辨识信息或底部控件占用空间。
|
||||||
|
- Markdown/PDF 属于确定性的静态输出场景,浏览器引擎会在全局和系列级
|
||||||
|
强制关闭 ECharts 动画、动画延迟和持续时间,不允许 YAML 动画参数使
|
||||||
|
预览或 PDF 停留在动画中间帧。
|
||||||
|
|
||||||
## 4. 已执行验证
|
## 4. 已执行验证
|
||||||
|
|
||||||
2026-07-26 在当前完整工作区成功执行:
|
2026-07-27 在当前完整工作区成功执行:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
npm test
|
npm test
|
||||||
@@ -203,23 +332,86 @@ git diff --check
|
|||||||
|
|
||||||
结果:
|
结果:
|
||||||
|
|
||||||
- 共享配置测试:6 项通过;
|
- ECharts 围栏测试:36 项通过;
|
||||||
- 渲染器测试:7 项通过;
|
- 共享配置测试:9 项通过;
|
||||||
- 前端测试:47 项通过;
|
- 渲染器测试:10 项通过;
|
||||||
- 后端测试:17 项通过;
|
- 前端测试:63 项通过;
|
||||||
|
- 后端测试:29 项通过;
|
||||||
|
- 全项目共 147 项测试通过;
|
||||||
- 全项目类型检查通过;
|
- 全项目类型检查通过;
|
||||||
- 生产构建通过;
|
- 生产构建通过;
|
||||||
- `git diff --check` 通过。
|
- `git diff --check` 通过。
|
||||||
|
|
||||||
已使用浏览器插件完成视觉验证:
|
已使用浏览器插件完成视觉验证:
|
||||||
|
|
||||||
- 65 页附件的快速预览生成 64 页,67 个 Mermaid 全部使用静态 SVG;
|
- 默认示例中的 ECharts 柱状图生成 1 个内联 SVG,无错误占位;
|
||||||
|
- 顶栏文件名、分页状态与操作按钮在桌面和窄屏均无重叠;
|
||||||
|
- 桌面端源文本收起后宽度从 576px 降为 48px,预览区从 864px 扩展
|
||||||
|
到 1392px;窄屏折叠条为 44px;
|
||||||
|
- 快速预览输入第 2 页后目标页顶端对齐 iframe 视口,滚动回首页和第二页
|
||||||
|
时输入框分别同步为 1 和 2;
|
||||||
|
- 页码输入 0 和 99 时分别限制到 1 和总页数;
|
||||||
|
- 精确预览输入第 2 页后,目标页与桌面滚动容器顶部误差为 0;
|
||||||
|
- 快速预览改用外层滚动容器后,右侧容器边界为 1440px,纸张边界约为
|
||||||
|
1400px,滚动条稳定贴在容器最右侧而非纸张边缘;
|
||||||
|
- 快速预览输入第 1 页后目标页与外层可视区域顶部误差为 0,外层滚动到
|
||||||
|
第 2 页时页码输入框同步更新为 2;
|
||||||
|
- 快速预览在 50% 和 150% 下的可滚动高度分别随内容显示高度缩放,
|
||||||
|
iframe 自身保持无滚动;切换精确/快速模式后仍使用同一右侧滚动条;
|
||||||
|
- 桌面端收起源文本后,预览滚动容器从 48px 延伸至窗口右边缘,纸张仍
|
||||||
|
在扩展后的预览区域内居中;
|
||||||
|
- 窄屏精确预览自动使用主窗口滚动,并正确跳转到第 2 页;
|
||||||
|
- 页码控件在窄屏独占首行,缩放控件在下一行铺满,没有与预览模式或主题
|
||||||
|
选择器重叠;
|
||||||
|
- 当前默认 Markdown 和主题资源加载正常,快速预览生成 2 页;
|
||||||
|
- 编辑 Markdown 后标题、表格、Mermaid 和分页状态均完成防抖重绘;
|
||||||
|
- 从本地 GitHub 主题切换到内置 Typora 风格主题后 iframe 正常重建;
|
||||||
|
- 本轮浏览器回归期间控制台无警告或错误;
|
||||||
|
- 预览缩放在 50%、100%、150% 和 400% 下均按比例显示,快速与精确
|
||||||
|
预览的分页结果不受缩放影响;
|
||||||
|
- 包含 ELK、Base 主题、Hand-drawn 外观、自定义字体、
|
||||||
|
`themeVariables`、`style` 和 `classDef` 的 Mermaid 样例在快速预览
|
||||||
|
与精确预览中均成功渲染;
|
||||||
|
- 在 Windows 150% 系统显示缩放的笔记本内屏上,65 页附件的快速预览
|
||||||
|
生成 64 页,67 个 Mermaid 全部使用静态 SVG;
|
||||||
- 同一附件的精确预览和最终 PDF 均为 65 页;
|
- 同一附件的精确预览和最终 PDF 均为 65 页;
|
||||||
|
- 切换到 Windows 100% 系统显示缩放的外接显示器后,快速预览与精确预览
|
||||||
|
的分页结果基本一致,进一步排除了 Chromium 版本差异这一判断方向;
|
||||||
- 精确预览快速跳转至第 46、47、65 页,无黑色 Canvas、空白页或渲染错误;
|
- 精确预览快速跳转至第 46、47、65 页,无黑色 Canvas、空白页或渲染错误;
|
||||||
- PDF.js 同时只保留视口邻近页面的 Canvas,远端页面资源可以释放;
|
- PDF.js 同时只保留视口邻近页面的 Canvas,远端页面资源可以释放;
|
||||||
- Mermaid A/B PDF 页数、逐页文字和抽样视觉结果一致;
|
- Mermaid A/B PDF 页数、逐页文字和抽样视觉结果一致;
|
||||||
- 抽查第 2、6、27、53、65 页,Mermaid 尺寸、分页位置和末页内容正常。
|
- 抽查第 2、6、27、53、65 页,Mermaid 尺寸、分页位置和末页内容正常。
|
||||||
|
|
||||||
|
ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||||
|
|
||||||
|
- 默认示例生成 10 个 ECharts SVG,包含原柱状图和九种新增系列,错误
|
||||||
|
占位数为 0,浏览器控制台无警告或错误;
|
||||||
|
- 快速预览生成 6 页,第 2~6 页各放置两张完整图表,所有图表边界均位于
|
||||||
|
当前页内容区内,没有跨页切割;
|
||||||
|
- 精确预览同样生成 6 页,逐页跳转正常;Chord、Funnel、Gauge 和
|
||||||
|
PictorialBar 的 PDF Canvas 视觉检查通过;
|
||||||
|
- 使用默认导出配置调用 PDF API 生成 4 页 PDF,响应中的 ECharts 和
|
||||||
|
Mermaid 错误数均为 0;
|
||||||
|
- PDF 中九种新增图表的标题均可搜索;将全部页面渲染为 PNG 后,Tree、
|
||||||
|
Treemap、Sunburst、Graph、Sankey、Chord、Funnel、Gauge 和
|
||||||
|
PictorialBar 均显示完整,未发现裁切、重叠、黑块或空白图表。
|
||||||
|
- 修正 Treemap 示例后再次检查快速与精确预览:底部下钻面包屑已隐藏,
|
||||||
|
五个叶节点边界清晰;SVG 中父级和叶节点共 7 个标签均处于可见状态。
|
||||||
|
- 进一步确认此前 Treemap 标签虽存在于 SVG,但被冻结在入场动画初始帧,
|
||||||
|
`fill-opacity` 约为 `0.000014`,肉眼近似完全透明;全局关闭动画后,
|
||||||
|
快速预览和精确 PDF 中的 2 个父级及 5 个叶节点标签均清晰可见。
|
||||||
|
|
||||||
|
2026-07-27 已重新构建并启动本地发布候选镜像:
|
||||||
|
|
||||||
|
- 使用完整多阶段 `deploy/Dockerfile` 构建
|
||||||
|
`yixiong/md-to-pdf:v0.3.0`,镜像大小约 612 MB;
|
||||||
|
- Compose 强制重建后容器健康检查通过,服务继续监听
|
||||||
|
`http://localhost:8080`;
|
||||||
|
- 首页返回 200,`POST /api/render` 的 ECharts 冒烟请求返回
|
||||||
|
`code`、`echarts` 功能标识和 `md-echarts` 安全占位;
|
||||||
|
- 容器内 `@md-to-pdf/markdown-echarts` 工作区包可正常加载;
|
||||||
|
- 当前镜像已通过发布前手动验收,并作为 `v0.3.0` 发布镜像保留运行。
|
||||||
|
|
||||||
此前主题阶段已使用浏览器插件完成视觉验证:
|
此前主题阶段已使用浏览器插件完成视觉验证:
|
||||||
|
|
||||||
- 导入并打开 `tmp/数据中台项目周报_2026_W30.md`;
|
- 导入并打开 `tmp/数据中台项目周报_2026_W30.md`;
|
||||||
@@ -246,12 +438,30 @@ git diff --check
|
|||||||
- 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过;
|
- 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过;
|
||||||
- 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。
|
- 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。
|
||||||
|
|
||||||
为方便用户继续检查,AIO 容器当前运行在 `http://localhost:8080`。
|
`v0.3.0` 发布验收完成后,已停止并删除本项目 Compose 容器及网络以释放
|
||||||
|
本地资源;`yixiong/md-to-pdf:v0.3.0` 镜像继续保留。
|
||||||
|
|
||||||
|
2026-07-27 已构建并启动 `v0.3.1` 发布候选:
|
||||||
|
|
||||||
|
- 使用完整多阶段 Dockerfile 构建 `yixiong/md-to-pdf:v0.3.1`,镜像
|
||||||
|
大小约 612 MB;
|
||||||
|
- Compose 容器使用该镜像运行并通过健康检查,服务监听
|
||||||
|
`http://localhost:8080`;
|
||||||
|
- 容器首页返回 200,默认 Markdown 解析出 10 个 ECharts 围栏,
|
||||||
|
错误占位和渲染警告均为 0;
|
||||||
|
- 容器生成的默认配置 PDF 为 4 页、约 521 KiB,ECharts 和 Mermaid
|
||||||
|
渲染错误数均为 0;
|
||||||
|
- ECharts 已编译进按需加载的前端浏览器 Bundle,生产运行时不需要保留
|
||||||
|
独立的 `echarts` Node.js 包;
|
||||||
|
- 当前容器保留运行,等待 `v0.3.1` 发布前手动验收。
|
||||||
|
|
||||||
## 5. 当前注意事项
|
## 5. 当前注意事项
|
||||||
|
|
||||||
- `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,15 +469,24 @@ 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/PreviewPageControl.tsx` 和 `preview-page.ts` 负责当前页输入、
|
||||||
|
页码边界、滚动页识别和快速跳转。
|
||||||
|
- `apps/web/src/preview-zoom.ts` 和 `PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
|
||||||
|
- `packages/markdown-echarts` 负责 ECharts YAML 协议、验证、安全过滤、
|
||||||
|
Markdown-it 插件、浏览器 SVG 渲染和分页样式。
|
||||||
- `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 忽略,不得提交。
|
||||||
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。
|
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。
|
||||||
- Typora 官方资源是 All Rights Reserved,仅限当前用户本机使用,不进入 Git、容器或发布包。
|
- Typora 官方资源是 All Rights Reserved,仅限当前用户本机使用,不进入 Git、容器或发布包。
|
||||||
|
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
|
||||||
|
对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。
|
||||||
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。
|
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。
|
||||||
- AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证。
|
- AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证。
|
||||||
|
|
||||||
@@ -291,7 +510,7 @@ git diff --check
|
|||||||
## 7. 首版验收目标
|
## 7. 首版验收目标
|
||||||
|
|
||||||
- 可以选择或粘贴 Markdown;
|
- 可以选择或粘贴 Markdown;
|
||||||
- 网页正确预览常用 Markdown、公式和 Mermaid;
|
- 网页正确预览常用 Markdown、公式、Mermaid 和 ECharts;
|
||||||
- 可以下载真实 PDF;
|
- 可以下载真实 PDF;
|
||||||
- PDF 文本可搜索;
|
- PDF 文本可搜索;
|
||||||
- 预览与 PDF 基本一致;
|
- 预览与 PDF 基本一致;
|
||||||
|
|||||||
Generated
+178
-1
@@ -39,9 +39,11 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/markdown-echarts": "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",
|
||||||
@@ -1023,6 +1025,10 @@
|
|||||||
"resolved": "packages/core",
|
"resolved": "packages/core",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@md-to-pdf/markdown-echarts": {
|
||||||
|
"resolved": "packages/markdown-echarts",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@md-to-pdf/renderer": {
|
"node_modules/@md-to-pdf/renderer": {
|
||||||
"resolved": "packages/renderer",
|
"resolved": "packages/renderer",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -1145,6 +1151,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",
|
||||||
@@ -2173,6 +2192,23 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/whatwg-mimetype": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@upsetjs/venn.js": {
|
"node_modules/@upsetjs/venn.js": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
|
||||||
@@ -2478,6 +2514,19 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-image-size": {
|
||||||
|
"version": "0.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
|
||||||
|
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001806",
|
"version": "1.0.30001806",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||||
@@ -3326,6 +3375,24 @@
|
|||||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/echarts": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/echarts/node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.396",
|
"version": "1.5.396",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz",
|
||||||
@@ -3333,6 +3400,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",
|
||||||
@@ -3773,6 +3846,25 @@
|
|||||||
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
|
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/happy-dom": {
|
||||||
|
"version": "20.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz",
|
||||||
|
"integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": ">=20.0.0",
|
||||||
|
"@types/whatwg-mimetype": "^3.0.2",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"buffer-image-size": "^0.6.4",
|
||||||
|
"entities": "^7.0.1",
|
||||||
|
"whatwg-mimetype": "^3.0.0",
|
||||||
|
"ws": "^8.21.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -4153,6 +4245,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
|
||||||
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "^7.1.2",
|
"@braintree/sanitize-url": "^7.1.2",
|
||||||
"@iconify/utils": "^3.0.2",
|
"@iconify/utils": "^3.0.2",
|
||||||
@@ -5353,6 +5446,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -5388,6 +5491,28 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
|
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
@@ -5405,6 +5530,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
@@ -5443,6 +5583,23 @@
|
|||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zrender": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zrender/node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@md-to-pdf/core",
|
"name": "@md-to-pdf/core",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -5453,11 +5610,31 @@
|
|||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/markdown-echarts": {
|
||||||
|
"name": "@md-to-pdf/markdown-echarts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.8.1",
|
||||||
|
"zod": "^4.1.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"echarts": "6.1.0",
|
||||||
|
"happy-dom": "^20.11.1",
|
||||||
|
"markdown-it": "^14.3.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"markdown-it": "^14.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/renderer": {
|
"packages/renderer": {
|
||||||
"name": "@md-to-pdf/renderer",
|
"name": "@md-to-pdf/renderer",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||||
"@mdit/plugin-anchor": "^1.1.1",
|
"@mdit/plugin-anchor": "^1.1.1",
|
||||||
"@mdit/plugin-footnote": "^1.0.1",
|
"@mdit/plugin-footnote": "^1.0.1",
|
||||||
"@mdit/plugin-katex": "^1.0.1",
|
"@mdit/plugin-katex": "^1.0.1",
|
||||||
|
|||||||
+5
-4
@@ -8,15 +8,16 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server",
|
"build": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server",
|
||||||
"dev": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && concurrently -k -n core,renderer,server,web \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:server\" \"npm:dev:web\"",
|
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && concurrently -k -n markdown-echarts,core,renderer,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:server\" \"npm:dev:web\"",
|
||||||
|
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
|
||||||
"dev:core": "npm run dev -w @md-to-pdf/core",
|
"dev:core": "npm run dev -w @md-to-pdf/core",
|
||||||
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
|
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
|
||||||
"dev:server": "npm run dev -w @md-to-pdf/server",
|
"dev:server": "npm run dev -w @md-to-pdf/server",
|
||||||
"dev:web": "npm run dev -w @md-to-pdf/web",
|
"dev:web": "npm run dev -w @md-to-pdf/web",
|
||||||
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
||||||
"test": "npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server",
|
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server",
|
||||||
"typecheck": "npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server"
|
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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;
|
||||||
|
echartsMs: number;
|
||||||
|
echartsFitMs: number;
|
||||||
|
mermaidMs: number;
|
||||||
|
mermaidFitMs: number;
|
||||||
|
mermaidConversionMs: number;
|
||||||
|
resourceWaitMs: number;
|
||||||
|
paginationMs: number;
|
||||||
|
finalizeMs: number;
|
||||||
|
totalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedDocumentRenderResult {
|
||||||
|
pageCount: number;
|
||||||
|
echartsErrors: string[];
|
||||||
|
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";
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export const themeFeatureSchema = z.enum([
|
|||||||
"task-list",
|
"task-list",
|
||||||
"footnote",
|
"footnote",
|
||||||
"katex",
|
"katex",
|
||||||
"mermaid"
|
"mermaid",
|
||||||
|
"echarts"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const themeManifestSchema = z.object({
|
export const themeManifestSchema = z.object({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# @md-to-pdf/markdown-echarts
|
||||||
|
|
||||||
|
为 Markdown 增加安全、可验证、可复用的 ECharts YAML 围栏,
|
||||||
|
并在浏览器中渲染为 SVG。
|
||||||
|
|
||||||
|
该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的
|
||||||
|
HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。
|
||||||
|
|
||||||
|
## 基本语法
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```echarts
|
||||||
|
height: 80mm
|
||||||
|
caption: 年度收入
|
||||||
|
option:
|
||||||
|
xAxis:
|
||||||
|
type: category
|
||||||
|
data: [2023, 2024]
|
||||||
|
yAxis:
|
||||||
|
type: value
|
||||||
|
series:
|
||||||
|
- type: bar
|
||||||
|
data: [120, 180]
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
围栏内容必须使用 YAML,并包含一个 ECharts `option` 对象。YAML
|
||||||
|
会先转换为安全的纯数据对象,再交给 ECharts;不能使用函数、
|
||||||
|
YAML 锚点、别名或自定义标签。
|
||||||
|
|
||||||
|
## 围栏字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `version` | `1` | `1` | 围栏协议版本 |
|
||||||
|
| `height` | CSS 长度 | `80mm` | 支持 `mm`、`cm`、`in`、`px` |
|
||||||
|
| `aspectRatio` | 正数 | 无 | 与 `height` 二选一;指定后不补默认高度 |
|
||||||
|
| `theme` | `document`、`light`、`dark` | `document` | ECharts 主题 |
|
||||||
|
| `caption` | 字符串 | 无 | 图表标题说明 |
|
||||||
|
| `option` | 对象 | 必填 | 经过安全和语义验证的 ECharts 配置 |
|
||||||
|
|
||||||
|
## 数据来源
|
||||||
|
|
||||||
|
每个系列必须从以下任一位置获得有效数据:
|
||||||
|
|
||||||
|
- 非空的 `series[].data`;
|
||||||
|
- 非空的 `dataset.source`;
|
||||||
|
- 引用具有有效上游数据源的内置 `filter` 或 `sort` transform。
|
||||||
|
|
||||||
|
只有表头的二维数组、所有列均为空的对象以及空数组不属于有效
|
||||||
|
数据。使用 `dataset` 时保留 ECharts 的默认维度映射,也可以通过
|
||||||
|
`series.encode` 显式声明映射。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
option:
|
||||||
|
dataset:
|
||||||
|
source:
|
||||||
|
- [year, sales]
|
||||||
|
- [2025, 120]
|
||||||
|
- [2026, 180]
|
||||||
|
series:
|
||||||
|
- type: line
|
||||||
|
encode:
|
||||||
|
x: year
|
||||||
|
y: sales
|
||||||
|
```
|
||||||
|
|
||||||
|
## 系列验证规则
|
||||||
|
|
||||||
|
当前支持以下系列:
|
||||||
|
|
||||||
|
| 系列 | 主要规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| `line` | 需要有效数据;支持直角坐标系和极坐标系 |
|
||||||
|
| `bar` | 需要有效数据;支持直角坐标系和极坐标系 |
|
||||||
|
| `scatter` | 直接数据至少包含两个坐标值 |
|
||||||
|
| `pie` | 直接数据项的 `value` 必须是有限数值 |
|
||||||
|
| `radar` | 必须提供非空 `radar.indicator`;数据维数不得少于指标数 |
|
||||||
|
| `heatmap` | 直角坐标数据为 `[x, y, value]`;日历数据为 `[date, value]` |
|
||||||
|
| `boxplot` | 直接数据为 `[min, Q1, median, Q3, max]` |
|
||||||
|
| `candlestick` | 直接数据为 `[open, close, lowest, highest]` |
|
||||||
|
| `tree` | 必须提供非空层级 `data`;递归校验 `children` |
|
||||||
|
| `treemap` | 必须提供非空层级 `data`;叶节点必须具有有限数值 |
|
||||||
|
| `sunburst` | 必须提供非空层级 `data`;叶节点必须具有有限数值 |
|
||||||
|
| `graph` | 必须提供 `data` 或 `nodes`;校验边引用;缺少坐标时默认 `force` 布局 |
|
||||||
|
| `sankey` | 必须提供节点和关系边;边权重必须是非负有限数值 |
|
||||||
|
| `chord` | 使用 ECharts 6 和弦图;必须提供节点、关系边和非负权重 |
|
||||||
|
| `funnel` | 数据项必须是有限数值,或使用有效 `dataset` |
|
||||||
|
| `gauge` | 数据项必须是有限数值,或使用有效 `dataset` |
|
||||||
|
| `pictorialBar` | 需要有效数据和直角坐标系;允许安全的 `path://` 内嵌矢量符号 |
|
||||||
|
|
||||||
|
直角坐标系列缺少 `xAxis` 或 `yAxis` 时会补充空对象,让 ECharts
|
||||||
|
使用自身的轴默认值。极坐标、雷达和日历坐标系表达了明确语义,
|
||||||
|
因此不会猜测配置,缺少对应组件时会报告错误。
|
||||||
|
|
||||||
|
使用 `dataset` 时,验证器只验证数据源是否存在,不重复实现
|
||||||
|
ECharts 的维度推断和 `encode` 映射规则。
|
||||||
|
|
||||||
|
## 第二阶段系列示例
|
||||||
|
|
||||||
|
### 层级图
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
height: 60mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: treemap
|
||||||
|
nodeClick: false
|
||||||
|
breadcrumb:
|
||||||
|
show: false
|
||||||
|
label:
|
||||||
|
show: true
|
||||||
|
formatter: "{b}"
|
||||||
|
color: "#1f2937"
|
||||||
|
fontWeight: bold
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||||
|
borderRadius: 3
|
||||||
|
padding: [3, 6]
|
||||||
|
itemStyle:
|
||||||
|
borderColor: "#ffffff"
|
||||||
|
borderWidth: 2
|
||||||
|
gapWidth: 2
|
||||||
|
levels:
|
||||||
|
- itemStyle:
|
||||||
|
borderWidth: 0
|
||||||
|
gapWidth: 2
|
||||||
|
- upperLabel:
|
||||||
|
show: true
|
||||||
|
height: 28
|
||||||
|
color: "#1f2937"
|
||||||
|
fontWeight: bold
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||||
|
padding: [3, 6]
|
||||||
|
itemStyle:
|
||||||
|
borderColor: "#ffffff"
|
||||||
|
borderWidth: 2
|
||||||
|
gapWidth: 2
|
||||||
|
- label:
|
||||||
|
show: true
|
||||||
|
formatter: "{b}"
|
||||||
|
color: "#1f2937"
|
||||||
|
fontWeight: bold
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||||
|
borderRadius: 3
|
||||||
|
padding: [3, 6]
|
||||||
|
itemStyle:
|
||||||
|
borderColor: "#ffffff"
|
||||||
|
borderWidth: 2
|
||||||
|
gapWidth: 2
|
||||||
|
data:
|
||||||
|
- name: 渲染
|
||||||
|
children:
|
||||||
|
- { name: Markdown, value: 32 }
|
||||||
|
- { name: ECharts, value: 24 }
|
||||||
|
- name: 导出
|
||||||
|
children:
|
||||||
|
- { name: PDF, value: 28 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`tree`、`treemap` 和 `sunburst` 共用递归 `children` 结构。
|
||||||
|
`treemap` 与 `sunburst` 的叶节点需要数值;父节点可以省略数值,
|
||||||
|
由 ECharts 根据子节点汇总。
|
||||||
|
|
||||||
|
### 关系图
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
height: 60mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: sankey
|
||||||
|
data:
|
||||||
|
- { name: Markdown }
|
||||||
|
- { name: HTML }
|
||||||
|
- { name: PDF }
|
||||||
|
links:
|
||||||
|
- { source: Markdown, target: HTML, value: 100 }
|
||||||
|
- { source: HTML, target: PDF, value: 40 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`graph` 的关系边可以省略;`sankey` 和 ECharts 6 `chord` 必须
|
||||||
|
具有非空 `links` 或 `edges`。`source`、`target` 必须引用节点
|
||||||
|
名称、ID 或数组索引。
|
||||||
|
|
||||||
|
### 业务图表
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
height: 60mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: gauge
|
||||||
|
min: 0
|
||||||
|
max: 100
|
||||||
|
progress:
|
||||||
|
show: true
|
||||||
|
detail:
|
||||||
|
formatter: "{value}%"
|
||||||
|
data:
|
||||||
|
- { name: 完成率, value: 78 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`funnel` 和 `gauge` 使用有限数值数据;`pictorialBar` 复用柱图
|
||||||
|
坐标系和数据规则,并允许不加载外部资源的 `path://` SVG 路径。
|
||||||
|
|
||||||
|
## 错误行为
|
||||||
|
|
||||||
|
配置错误不会中断整篇 Markdown。对应围栏会变成局部错误占位:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="md-echarts-error" role="alert">
|
||||||
|
ECharts 图表配置无效:option.series.0.data 必须是非空数组……
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
错误消息包含配置路径,便于用户或 AI Agent 定位并修正 YAML。
|
||||||
|
宿主还可以通过 `onError` 收集结构化错误。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
markdown.use(markdownItECharts, {
|
||||||
|
onError(error, context) {
|
||||||
|
console.warn(error.code, error.message, context.tokenIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Markdown-it 接入
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
|
import {
|
||||||
|
markdownItECharts
|
||||||
|
} from "@md-to-pdf/markdown-echarts";
|
||||||
|
|
||||||
|
const markdown = new MarkdownIt().use(markdownItECharts);
|
||||||
|
const html = markdown.render(source);
|
||||||
|
```
|
||||||
|
|
||||||
|
如果宿主还会对 HTML 做安全过滤,需要保留包生成的 `figure`、
|
||||||
|
`figcaption`、隐藏配置节点及 `data-echarts-*` 属性。
|
||||||
|
|
||||||
|
## 浏览器渲染
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
renderEChartsBlocks
|
||||||
|
} from "@md-to-pdf/markdown-echarts/browser";
|
||||||
|
import "@md-to-pdf/markdown-echarts/styles.css";
|
||||||
|
|
||||||
|
const outcomes = await renderEChartsBlocks(document, {
|
||||||
|
outputMode: "inline-svg",
|
||||||
|
concurrency: 2,
|
||||||
|
timeoutMs: 5000
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
输出模式:
|
||||||
|
|
||||||
|
- `interactive`:保留 ECharts 实例和响应式尺寸监听;
|
||||||
|
- `inline-svg`:冻结为内联 SVG,适合打印和 PDF;
|
||||||
|
- `svg-image`:冻结为 SVG Data URL 图片。
|
||||||
|
|
||||||
|
PDF 输出前应等待所有图表、字体和图片完成,再开始分页或调用
|
||||||
|
Chromium `page.pdf()`。
|
||||||
|
|
||||||
|
浏览器引擎会强制关闭全局及各系列动画,Markdown 中的动画参数不会改变
|
||||||
|
静态 SVG、快速预览、精确预览或 PDF,避免输出停留在动画中间帧。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
默认策略会拒绝:
|
||||||
|
|
||||||
|
- `custom` 系列及 `renderItem`;
|
||||||
|
- JavaScript、HTTP、文件和 Data URL 外部资源;
|
||||||
|
- HTML formatter;
|
||||||
|
- 原型污染属性;
|
||||||
|
- 未允许的 toolbox 功能;
|
||||||
|
- 非内置数据转换;
|
||||||
|
- 超过节点数、深度、字符串、数组、系列、层级节点或关系边数量
|
||||||
|
上限的配置。
|
||||||
|
|
||||||
|
这些限制适用于不受信任的 Markdown。宿主如需扩大能力,应在明确
|
||||||
|
风险后扩展验证器,而不是直接执行 YAML 中的 JavaScript。
|
||||||
|
|
||||||
|
## 扩展系列验证
|
||||||
|
|
||||||
|
各系列规则注册在 `echartsSeriesValidators`。新增图形时需要同步:
|
||||||
|
|
||||||
|
1. 将系列类型加入协议支持列表;
|
||||||
|
2. 在浏览器引擎中注册对应 ECharts Chart;
|
||||||
|
3. 注册该系列的语义验证器;
|
||||||
|
4. 增加有效、缺少数据、错误数据结构和真实 SVG 渲染测试。
|
||||||
|
|
||||||
|
参考:
|
||||||
|
|
||||||
|
- [ECharts 配置参考](https://echarts.apache.org/en/option.html)
|
||||||
|
- [ECharts Dataset](https://echarts.apache.org/handbook/en/concepts/dataset/)
|
||||||
|
- [ECharts Data Transform](https://echarts.apache.org/handbook/en/concepts/data-transform/)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@md-to-pdf/markdown-echarts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./browser": {
|
||||||
|
"types": "./dist/browser.d.ts",
|
||||||
|
"import": "./dist/browser.js"
|
||||||
|
},
|
||||||
|
"./styles.css": {
|
||||||
|
"default": "./styles/echarts.css"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"styles"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.8.1",
|
||||||
|
"zod": "^4.1.13"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"markdown-it": "^14.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"echarts": "6.1.0",
|
||||||
|
"happy-dom": "^20.11.1",
|
||||||
|
"markdown-it": "^14.3.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./browser/engine.js";
|
||||||
|
export * from "./browser/freeze-svg.js";
|
||||||
|
export * from "./browser/render-blocks.js";
|
||||||
|
export * from "./browser/render-chart.js";
|
||||||
|
export * from "./browser/resource-policy.js";
|
||||||
|
export * from "./browser/types.js";
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import * as echarts from "echarts/core";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
BoxplotChart,
|
||||||
|
CandlestickChart,
|
||||||
|
ChordChart,
|
||||||
|
FunnelChart,
|
||||||
|
GaugeChart,
|
||||||
|
GraphChart,
|
||||||
|
HeatmapChart,
|
||||||
|
LineChart,
|
||||||
|
PieChart,
|
||||||
|
PictorialBarChart,
|
||||||
|
RadarChart,
|
||||||
|
SankeyChart,
|
||||||
|
ScatterChart,
|
||||||
|
SunburstChart,
|
||||||
|
TreeChart,
|
||||||
|
TreemapChart
|
||||||
|
} from "echarts/charts";
|
||||||
|
import {
|
||||||
|
AriaComponent,
|
||||||
|
AxisPointerComponent,
|
||||||
|
CalendarComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
DatasetComponent,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
MarkAreaComponent,
|
||||||
|
MarkLineComponent,
|
||||||
|
MarkPointComponent,
|
||||||
|
PolarComponent,
|
||||||
|
RadarComponent,
|
||||||
|
TitleComponent,
|
||||||
|
ToolboxComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
TransformComponent,
|
||||||
|
VisualMapComponent
|
||||||
|
} from "echarts/components";
|
||||||
|
import { LabelLayout } from "echarts/features";
|
||||||
|
import { SVGRenderer } from "echarts/renderers";
|
||||||
|
import type {
|
||||||
|
EChartsEngine,
|
||||||
|
EChartsInstanceLike
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
PieChart,
|
||||||
|
ScatterChart,
|
||||||
|
RadarChart,
|
||||||
|
HeatmapChart,
|
||||||
|
BoxplotChart,
|
||||||
|
CandlestickChart,
|
||||||
|
TreeChart,
|
||||||
|
TreemapChart,
|
||||||
|
SunburstChart,
|
||||||
|
GraphChart,
|
||||||
|
SankeyChart,
|
||||||
|
ChordChart,
|
||||||
|
FunnelChart,
|
||||||
|
GaugeChart,
|
||||||
|
PictorialBarChart,
|
||||||
|
GridComponent,
|
||||||
|
PolarComponent,
|
||||||
|
RadarComponent,
|
||||||
|
CalendarComponent,
|
||||||
|
TitleComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
AxisPointerComponent,
|
||||||
|
ToolboxComponent,
|
||||||
|
DatasetComponent,
|
||||||
|
TransformComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
VisualMapComponent,
|
||||||
|
MarkPointComponent,
|
||||||
|
MarkLineComponent,
|
||||||
|
MarkAreaComponent,
|
||||||
|
AriaComponent,
|
||||||
|
LabelLayout,
|
||||||
|
SVGRenderer
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const defaultEChartsEngine: EChartsEngine = {
|
||||||
|
init(container, theme, options) {
|
||||||
|
return echarts.init(
|
||||||
|
container,
|
||||||
|
theme,
|
||||||
|
options
|
||||||
|
) as unknown as EChartsInstanceLike;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { echartsDomContract } from "../dom-contract.js";
|
||||||
|
import { cloneSafeEChartsSvg } from "./resource-policy.js";
|
||||||
|
import type { EChartsOutputMode } from "./types.js";
|
||||||
|
|
||||||
|
function svgDataUrl(serialized: string) {
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function freezeEChartsSvg(
|
||||||
|
host: HTMLElement,
|
||||||
|
source: SVGSVGElement,
|
||||||
|
outputMode: Exclude<EChartsOutputMode, "interactive">,
|
||||||
|
label: string
|
||||||
|
) {
|
||||||
|
const svg = cloneSafeEChartsSvg(source);
|
||||||
|
svg.removeAttribute("style");
|
||||||
|
svg.setAttribute("role", "img");
|
||||||
|
svg.setAttribute("aria-label", label);
|
||||||
|
svg.style.width = "100%";
|
||||||
|
svg.style.height = "100%";
|
||||||
|
svg.style.display = "block";
|
||||||
|
|
||||||
|
if (outputMode === "inline-svg") {
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = host.ownerDocument.createElement("img");
|
||||||
|
image.className = `${echartsDomContract.figureClass}-svg-image`;
|
||||||
|
image.alt = label;
|
||||||
|
image.src = svgDataUrl(new XMLSerializer().serializeToString(svg));
|
||||||
|
image.style.width = "100%";
|
||||||
|
image.style.height = "100%";
|
||||||
|
image.style.objectFit = "contain";
|
||||||
|
image.style.display = "block";
|
||||||
|
return image;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { echartsDomContract } from "../dom-contract.js";
|
||||||
|
import {
|
||||||
|
disposeEChartsBlock,
|
||||||
|
renderEChartsBlock
|
||||||
|
} from "./render-chart.js";
|
||||||
|
import type {
|
||||||
|
EChartsBlockRenderOutcome,
|
||||||
|
RenderEChartsBlocksOptions
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export async function renderEChartsBlocks(
|
||||||
|
root: ParentNode,
|
||||||
|
options: RenderEChartsBlocksOptions = {}
|
||||||
|
) {
|
||||||
|
const elements = Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>(
|
||||||
|
`.${echartsDomContract.figureClass}[${echartsDomContract.pendingAttribute}]`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const outcomes = new Array<EChartsBlockRenderOutcome>(
|
||||||
|
elements.length
|
||||||
|
);
|
||||||
|
const concurrency = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(options.concurrency ?? 2, elements.length || 1)
|
||||||
|
);
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (nextIndex < elements.length) {
|
||||||
|
const index = nextIndex;
|
||||||
|
nextIndex += 1;
|
||||||
|
const element = elements[index];
|
||||||
|
if (!element) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const outcome = await renderEChartsBlock(element, options);
|
||||||
|
outcomes[index] = outcome;
|
||||||
|
if (!outcome.success && outcome.error) {
|
||||||
|
options.onError?.(outcome.error, element, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: concurrency }, () => worker())
|
||||||
|
);
|
||||||
|
return outcomes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeEChartsBlocks(root: ParentNode) {
|
||||||
|
const elements = root.querySelectorAll<HTMLElement>(
|
||||||
|
`.${echartsDomContract.figureClass}`
|
||||||
|
);
|
||||||
|
for (const element of elements) {
|
||||||
|
disposeEChartsBlock(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import { echartsDomContract } from "../dom-contract.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_ECHARTS_FENCE_HEIGHT,
|
||||||
|
EChartsFenceError,
|
||||||
|
echartsFenceSpecSchema,
|
||||||
|
type NormalizedEChartsFenceSpec
|
||||||
|
} from "../protocol.js";
|
||||||
|
import {
|
||||||
|
normalizeAndValidateEChartsOption
|
||||||
|
} from "../security-policy.js";
|
||||||
|
import {
|
||||||
|
validateAndApplyEChartsOptionDefaults
|
||||||
|
} from "../semantic-validator.js";
|
||||||
|
import { defaultEChartsEngine } from "./engine.js";
|
||||||
|
import { freezeEChartsSvg } from "./freeze-svg.js";
|
||||||
|
import type {
|
||||||
|
EChartsBlockRenderOutcome,
|
||||||
|
EChartsInstanceLike,
|
||||||
|
EChartsOutputMode,
|
||||||
|
RenderEChartsBlockOptions
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
interface ActiveChart {
|
||||||
|
instance: EChartsInstanceLike;
|
||||||
|
observer?: ResizeObserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCharts = new WeakMap<HTMLElement, ActiveChart>();
|
||||||
|
|
||||||
|
function abortError() {
|
||||||
|
return new DOMException("ECharts 渲染已取消", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal: AbortSignal | undefined) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw abortError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDefinition(value: unknown): NormalizedEChartsFenceSpec {
|
||||||
|
const protocolResult = echartsFenceSpecSchema.safeParse(value);
|
||||||
|
if (!protocolResult.success) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_PROTOCOL",
|
||||||
|
"ECharts 占位节点中的协议无效"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const normalized: NormalizedEChartsFenceSpec = {
|
||||||
|
version: protocolResult.data.version,
|
||||||
|
theme: protocolResult.data.theme,
|
||||||
|
option: validateAndApplyEChartsOptionDefaults(
|
||||||
|
normalizeAndValidateEChartsOption(
|
||||||
|
protocolResult.data.option
|
||||||
|
)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if (protocolResult.data.height !== undefined) {
|
||||||
|
normalized.height = protocolResult.data.height;
|
||||||
|
} else if (protocolResult.data.aspectRatio === undefined) {
|
||||||
|
normalized.height = DEFAULT_ECHARTS_FENCE_HEIGHT;
|
||||||
|
}
|
||||||
|
if (protocolResult.data.aspectRatio !== undefined) {
|
||||||
|
normalized.aspectRatio = protocolResult.data.aspectRatio;
|
||||||
|
}
|
||||||
|
if (protocolResult.data.caption !== undefined) {
|
||||||
|
normalized.caption = protocolResult.data.caption;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDefinition(element: HTMLElement) {
|
||||||
|
const definition = element.querySelector<HTMLElement>(
|
||||||
|
`.${echartsDomContract.definitionClass}`
|
||||||
|
);
|
||||||
|
if (!definition) {
|
||||||
|
throw new Error("缺少 ECharts 配置节点");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return normalizeDefinition(
|
||||||
|
JSON.parse(definition.textContent ?? "")
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("ECharts 配置节点无法解析");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChartSize(
|
||||||
|
host: HTMLElement,
|
||||||
|
definition: NormalizedEChartsFenceSpec
|
||||||
|
) {
|
||||||
|
host.style.width = "100%";
|
||||||
|
if (definition.height !== undefined) {
|
||||||
|
host.style.height = definition.height;
|
||||||
|
host.style.removeProperty("aspect-ratio");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.style.removeProperty("height");
|
||||||
|
host.style.aspectRatio = String(definition.aspectRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultTheme(
|
||||||
|
theme: NormalizedEChartsFenceSpec["theme"]
|
||||||
|
) {
|
||||||
|
return theme === "dark" ? "dark" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextFrame(element: HTMLElement) {
|
||||||
|
const requestFrame =
|
||||||
|
element.ownerDocument.defaultView?.requestAnimationFrame;
|
||||||
|
if (!requestFrame) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
requestFrame(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRender(
|
||||||
|
instance: EChartsInstanceLike,
|
||||||
|
host: HTMLElement,
|
||||||
|
timeoutMs: number,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
setOption: () => void
|
||||||
|
) {
|
||||||
|
let finished = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let abortHandler: (() => void) | undefined;
|
||||||
|
let finishedHandler: (() => void) | undefined;
|
||||||
|
|
||||||
|
const completion = new Promise<void>((resolve, reject) => {
|
||||||
|
const finish = () => {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finished = true;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const fail = (error: Error) => {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finished = true;
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
finishedHandler = finish;
|
||||||
|
instance.on("finished", finishedHandler);
|
||||||
|
timer = setTimeout(
|
||||||
|
() => fail(new Error(`ECharts 渲染超过 ${timeoutMs}ms`)),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
abortHandler = () => fail(abortError());
|
||||||
|
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
setOption();
|
||||||
|
} catch (error) {
|
||||||
|
fail(
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error("ECharts setOption 失败")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
completion,
|
||||||
|
(async () => {
|
||||||
|
await nextFrame(host);
|
||||||
|
await nextFrame(host);
|
||||||
|
if (!host.querySelector("svg")) {
|
||||||
|
await completion;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (abortHandler) {
|
||||||
|
signal?.removeEventListener("abort", abortHandler);
|
||||||
|
}
|
||||||
|
if (finishedHandler) {
|
||||||
|
instance.off("finished", finishedHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeInteractiveChart(
|
||||||
|
element: HTMLElement,
|
||||||
|
host: HTMLElement,
|
||||||
|
instance: EChartsInstanceLike
|
||||||
|
) {
|
||||||
|
let observer: ResizeObserver | undefined;
|
||||||
|
const ResizeObserverConstructor =
|
||||||
|
element.ownerDocument.defaultView?.ResizeObserver;
|
||||||
|
if (ResizeObserverConstructor) {
|
||||||
|
observer = new ResizeObserverConstructor(() => instance.resize());
|
||||||
|
observer.observe(host);
|
||||||
|
}
|
||||||
|
const active: ActiveChart = { instance };
|
||||||
|
if (observer !== undefined) {
|
||||||
|
active.observer = observer;
|
||||||
|
}
|
||||||
|
activeCharts.set(element, active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRenderError(element: HTMLElement, error: Error) {
|
||||||
|
element.removeAttribute(echartsDomContract.pendingAttribute);
|
||||||
|
element.classList.add(echartsDomContract.errorClass);
|
||||||
|
const host = element.querySelector<HTMLElement>(
|
||||||
|
`.${echartsDomContract.hostClass}`
|
||||||
|
);
|
||||||
|
if (host) {
|
||||||
|
host.setAttribute("role", "alert");
|
||||||
|
host.textContent = `ECharts 图表渲染失败:${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeEChartsBlock(element: HTMLElement) {
|
||||||
|
const active = activeCharts.get(element);
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
active.observer?.disconnect();
|
||||||
|
active.instance.dispose();
|
||||||
|
activeCharts.delete(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderEChartsBlock(
|
||||||
|
element: HTMLElement,
|
||||||
|
options: RenderEChartsBlockOptions = {}
|
||||||
|
): Promise<EChartsBlockRenderOutcome> {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const outputMode: EChartsOutputMode =
|
||||||
|
options.outputMode ?? "inline-svg";
|
||||||
|
let instance: EChartsInstanceLike | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
disposeEChartsBlock(element);
|
||||||
|
|
||||||
|
const definition = readDefinition(element);
|
||||||
|
const host = element.querySelector<HTMLElement>(
|
||||||
|
`.${echartsDomContract.hostClass}`
|
||||||
|
);
|
||||||
|
if (!host) {
|
||||||
|
throw new Error("缺少 ECharts 渲染容器");
|
||||||
|
}
|
||||||
|
|
||||||
|
applyChartSize(host, definition);
|
||||||
|
const fonts = element.ownerDocument.fonts;
|
||||||
|
if (fonts) {
|
||||||
|
await fonts.ready;
|
||||||
|
}
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
|
const engine = options.engine ?? defaultEChartsEngine;
|
||||||
|
const theme =
|
||||||
|
options.resolveTheme?.(definition.theme, element) ??
|
||||||
|
resolveDefaultTheme(definition.theme);
|
||||||
|
instance = engine.init(host, theme, {
|
||||||
|
renderer: "svg",
|
||||||
|
useDirtyRect: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const series = Array.isArray(definition.option.series)
|
||||||
|
? definition.option.series.map((item) =>
|
||||||
|
item && typeof item === "object" && !Array.isArray(item)
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
animation: false,
|
||||||
|
animationDelay: 0,
|
||||||
|
animationDelayUpdate: 0,
|
||||||
|
animationDuration: 0,
|
||||||
|
animationDurationUpdate: 0
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
: definition.option.series;
|
||||||
|
const option = {
|
||||||
|
...definition.option,
|
||||||
|
animation: false,
|
||||||
|
animationDelay: 0,
|
||||||
|
animationDelayUpdate: 0,
|
||||||
|
animationDuration: 0,
|
||||||
|
animationDurationUpdate: 0,
|
||||||
|
series
|
||||||
|
};
|
||||||
|
await waitForRender(
|
||||||
|
instance,
|
||||||
|
host,
|
||||||
|
options.timeoutMs ?? 5_000,
|
||||||
|
options.signal,
|
||||||
|
() => instance?.setOption(option, { notMerge: true })
|
||||||
|
);
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
|
if (outputMode === "interactive") {
|
||||||
|
storeInteractiveChart(element, host, instance);
|
||||||
|
element.removeAttribute(echartsDomContract.pendingAttribute);
|
||||||
|
element.dataset.echartsRendered = "interactive";
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
outputMode,
|
||||||
|
success: true,
|
||||||
|
durationMs: performance.now() - startedAt,
|
||||||
|
instance
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = host.querySelector<SVGSVGElement>("svg");
|
||||||
|
if (!svg) {
|
||||||
|
throw new Error("ECharts 未生成 SVG");
|
||||||
|
}
|
||||||
|
const frozenOutput = freezeEChartsSvg(
|
||||||
|
host,
|
||||||
|
svg,
|
||||||
|
outputMode,
|
||||||
|
definition.caption ?? "ECharts 图表"
|
||||||
|
);
|
||||||
|
instance.dispose();
|
||||||
|
instance = undefined;
|
||||||
|
host.replaceChildren(frozenOutput);
|
||||||
|
element.removeAttribute(echartsDomContract.pendingAttribute);
|
||||||
|
element.dataset.echartsRendered = outputMode;
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
outputMode,
|
||||||
|
success: true,
|
||||||
|
durationMs: performance.now() - startedAt
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
instance?.dispose();
|
||||||
|
const normalizedError =
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error("未知 ECharts 渲染错误");
|
||||||
|
showRenderError(element, normalizedError);
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
outputMode,
|
||||||
|
success: false,
|
||||||
|
durationMs: performance.now() - startedAt,
|
||||||
|
error: normalizedError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const forbiddenSvgElements = [
|
||||||
|
"script",
|
||||||
|
"foreignObject",
|
||||||
|
"iframe",
|
||||||
|
"object",
|
||||||
|
"embed"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const urlFunctionPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/giu;
|
||||||
|
|
||||||
|
function assertSafeUrlValue(value: string, context: string) {
|
||||||
|
for (const match of value.matchAll(urlFunctionPattern)) {
|
||||||
|
const target = match[2]?.trim() ?? "";
|
||||||
|
if (!target.startsWith("#")) {
|
||||||
|
throw new Error(`${context} 包含外部 SVG 资源`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeHref(value: string, context: string) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized && !normalized.startsWith("#")) {
|
||||||
|
throw new Error(`${context} 包含外部 SVG 链接`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneSafeEChartsSvg(
|
||||||
|
source: SVGSVGElement
|
||||||
|
): SVGSVGElement {
|
||||||
|
const clone = source.cloneNode(true) as SVGSVGElement;
|
||||||
|
if (clone.querySelector(forbiddenSvgElements.join(","))) {
|
||||||
|
throw new Error("ECharts SVG 包含禁止元素");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of [clone, ...clone.querySelectorAll("*")]) {
|
||||||
|
for (const attribute of Array.from(element.attributes)) {
|
||||||
|
const name = attribute.name.toLowerCase();
|
||||||
|
if (name.startsWith("on")) {
|
||||||
|
element.removeAttribute(attribute.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name === "href" || name === "xlink:href") {
|
||||||
|
assertSafeHref(attribute.value, attribute.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assertSafeUrlValue(attribute.value, attribute.name);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
element.tagName.toLowerCase() === "style" &&
|
||||||
|
/@import|url\(\s*(?!['"]?#)/iu.test(element.textContent ?? "")
|
||||||
|
) {
|
||||||
|
throw new Error("ECharts SVG 样式包含外部资源");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type {
|
||||||
|
NormalizedEChartsFenceSpec
|
||||||
|
} from "../protocol.js";
|
||||||
|
|
||||||
|
export type EChartsOutputMode =
|
||||||
|
| "interactive"
|
||||||
|
| "inline-svg"
|
||||||
|
| "svg-image";
|
||||||
|
|
||||||
|
export interface EChartsInstanceLike {
|
||||||
|
setOption(option: unknown, options?: { notMerge?: boolean }): void;
|
||||||
|
on(eventName: string, handler: () => void): void;
|
||||||
|
off(eventName: string, handler: () => void): void;
|
||||||
|
resize(): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EChartsEngine {
|
||||||
|
init(
|
||||||
|
container: HTMLElement,
|
||||||
|
theme: string | object | null | undefined,
|
||||||
|
options: {
|
||||||
|
renderer: "svg";
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
devicePixelRatio?: number;
|
||||||
|
useDirtyRect?: boolean;
|
||||||
|
}
|
||||||
|
): EChartsInstanceLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderEChartsBlockOptions {
|
||||||
|
engine?: EChartsEngine;
|
||||||
|
outputMode?: EChartsOutputMode;
|
||||||
|
timeoutMs?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
resolveTheme?: (
|
||||||
|
theme: NormalizedEChartsFenceSpec["theme"],
|
||||||
|
element: HTMLElement
|
||||||
|
) => string | object | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderEChartsBlocksOptions
|
||||||
|
extends RenderEChartsBlockOptions {
|
||||||
|
concurrency?: number;
|
||||||
|
onError?: (
|
||||||
|
error: Error,
|
||||||
|
element: HTMLElement,
|
||||||
|
index: number
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EChartsBlockRenderOutcome {
|
||||||
|
element: HTMLElement;
|
||||||
|
outputMode: EChartsOutputMode;
|
||||||
|
success: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
error?: Error;
|
||||||
|
instance?: EChartsInstanceLike;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const echartsDomContract = {
|
||||||
|
figureClass: "md-echarts",
|
||||||
|
hostClass: "md-echarts-host",
|
||||||
|
definitionClass: "md-echarts-definition",
|
||||||
|
errorClass: "md-echarts-error",
|
||||||
|
pendingAttribute: "data-echarts-pending",
|
||||||
|
protocolAttribute: "data-echarts-protocol"
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./dom-contract.js";
|
||||||
|
export * from "./markdown-it-plugin.js";
|
||||||
|
export * from "./parse-yaml.js";
|
||||||
|
export * from "./protocol.js";
|
||||||
|
export * from "./security-policy.js";
|
||||||
|
export * from "./semantic-validator.js";
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type MarkdownIt from "markdown-it";
|
||||||
|
import type { Options as MarkdownItOptions } from "markdown-it";
|
||||||
|
import type Renderer from "markdown-it/lib/renderer.mjs";
|
||||||
|
import type Token from "markdown-it/lib/token.mjs";
|
||||||
|
import { echartsDomContract } from "./dom-contract.js";
|
||||||
|
import {
|
||||||
|
ECHARTS_FENCE_VERSION,
|
||||||
|
EChartsFenceError,
|
||||||
|
type NormalizedEChartsFenceSpec
|
||||||
|
} from "./protocol.js";
|
||||||
|
import {
|
||||||
|
parseEChartsFence,
|
||||||
|
type ParseEChartsFenceOptions
|
||||||
|
} from "./parse-yaml.js";
|
||||||
|
|
||||||
|
export interface MarkdownItEChartsErrorContext {
|
||||||
|
source: string;
|
||||||
|
tokenIndex: number;
|
||||||
|
environment: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownItEChartsOptions {
|
||||||
|
parse?: ParseEChartsFenceOptions;
|
||||||
|
onError?: (
|
||||||
|
error: EChartsFenceError,
|
||||||
|
context: MarkdownItEChartsErrorContext
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FenceRenderer = (
|
||||||
|
tokens: Token[],
|
||||||
|
index: number,
|
||||||
|
options: MarkdownItOptions,
|
||||||
|
environment: unknown,
|
||||||
|
renderer: Renderer
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
function renderError(markdown: MarkdownIt, error: EChartsFenceError) {
|
||||||
|
const message = markdown.utils.escapeHtml(error.message);
|
||||||
|
return `<div class="${echartsDomContract.errorClass}" role="alert">ECharts 图表配置无效:${message}</div>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDefinition(
|
||||||
|
markdown: MarkdownIt,
|
||||||
|
definition: NormalizedEChartsFenceSpec
|
||||||
|
) {
|
||||||
|
const serialized = markdown.utils.escapeHtml(
|
||||||
|
JSON.stringify(definition)
|
||||||
|
);
|
||||||
|
const caption =
|
||||||
|
definition.caption === undefined
|
||||||
|
? ""
|
||||||
|
: `<figcaption>${markdown.utils.escapeHtml(definition.caption)}</figcaption>`;
|
||||||
|
const label = markdown.utils.escapeHtml(
|
||||||
|
definition.caption ?? "ECharts 图表"
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<figure class="${echartsDomContract.figureClass}"`,
|
||||||
|
` ${echartsDomContract.pendingAttribute}="true"`,
|
||||||
|
` ${echartsDomContract.protocolAttribute}="${ECHARTS_FENCE_VERSION}">`,
|
||||||
|
`<div class="${echartsDomContract.hostClass}" role="img" aria-label="${label}"></div>`,
|
||||||
|
`<pre class="${echartsDomContract.definitionClass}" hidden="hidden">${serialized}</pre>`,
|
||||||
|
caption,
|
||||||
|
"</figure>\n"
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDefaultFence(
|
||||||
|
defaultFenceRenderer: FenceRenderer | undefined,
|
||||||
|
tokens: Token[],
|
||||||
|
index: number,
|
||||||
|
options: MarkdownItOptions,
|
||||||
|
environment: unknown,
|
||||||
|
renderer: Renderer
|
||||||
|
) {
|
||||||
|
if (defaultFenceRenderer) {
|
||||||
|
return defaultFenceRenderer(
|
||||||
|
tokens,
|
||||||
|
index,
|
||||||
|
options,
|
||||||
|
environment,
|
||||||
|
renderer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderer.renderToken(tokens, index, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownItECharts(
|
||||||
|
markdown: MarkdownIt,
|
||||||
|
pluginOptions: MarkdownItEChartsOptions = {}
|
||||||
|
) {
|
||||||
|
const defaultFenceRenderer = markdown.renderer.rules.fence;
|
||||||
|
|
||||||
|
markdown.renderer.rules.fence = (
|
||||||
|
tokens,
|
||||||
|
index,
|
||||||
|
options,
|
||||||
|
environment,
|
||||||
|
renderer
|
||||||
|
) => {
|
||||||
|
const token = tokens[index];
|
||||||
|
const language = token?.info
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/u)[0]
|
||||||
|
?.toLowerCase();
|
||||||
|
if (!token || language !== "echarts") {
|
||||||
|
return renderDefaultFence(
|
||||||
|
defaultFenceRenderer,
|
||||||
|
tokens,
|
||||||
|
index,
|
||||||
|
options,
|
||||||
|
environment,
|
||||||
|
renderer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return renderDefinition(
|
||||||
|
markdown,
|
||||||
|
parseEChartsFence(token.content, pluginOptions.parse)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const normalizedError =
|
||||||
|
error instanceof EChartsFenceError
|
||||||
|
? error
|
||||||
|
: new EChartsFenceError(
|
||||||
|
"INVALID_YAML",
|
||||||
|
error instanceof Error ? error.message : "未知错误",
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
pluginOptions.onError?.(normalizedError, {
|
||||||
|
source: token.content,
|
||||||
|
tokenIndex: index,
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
return renderError(markdown, normalizedError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
isAlias,
|
||||||
|
isMap,
|
||||||
|
isScalar,
|
||||||
|
isSeq,
|
||||||
|
parseDocument
|
||||||
|
} from "yaml";
|
||||||
|
import type { Pair, ParsedNode } from "yaml";
|
||||||
|
import {
|
||||||
|
DEFAULT_ECHARTS_FENCE_HEIGHT,
|
||||||
|
EChartsFenceError,
|
||||||
|
echartsFenceSpecSchema,
|
||||||
|
type NormalizedEChartsFenceSpec
|
||||||
|
} from "./protocol.js";
|
||||||
|
import {
|
||||||
|
defaultEChartsSecurityLimits,
|
||||||
|
normalizeAndValidateEChartsOption,
|
||||||
|
type EChartsSecurityLimits
|
||||||
|
} from "./security-policy.js";
|
||||||
|
import {
|
||||||
|
validateAndApplyEChartsOptionDefaults
|
||||||
|
} from "./semantic-validator.js";
|
||||||
|
|
||||||
|
export interface ParseEChartsFenceOptions {
|
||||||
|
maxSourceBytes?: number;
|
||||||
|
securityLimits?: EChartsSecurityLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultMaxEChartsFenceSourceBytes = 512 * 1024;
|
||||||
|
|
||||||
|
function inspectYamlNode(node: ParsedNode | null | undefined): void {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAlias(node)) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"YAML_ALIAS_NOT_ALLOWED",
|
||||||
|
"ECharts YAML 不允许使用别名"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node.anchor !== undefined) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"YAML_ANCHOR_NOT_ALLOWED",
|
||||||
|
"ECharts YAML 不允许使用锚点"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node.tag !== undefined) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"YAML_TAG_NOT_ALLOWED",
|
||||||
|
"ECharts YAML 不允许使用显式标签"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isMap(node)) {
|
||||||
|
for (const pair of node.items as Pair<ParsedNode, ParsedNode>[]) {
|
||||||
|
inspectYamlNode(pair.key);
|
||||||
|
inspectYamlNode(pair.value);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSeq(node)) {
|
||||||
|
for (const item of node.items) {
|
||||||
|
inspectYamlNode(item);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isScalar(node)) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_YAML",
|
||||||
|
"ECharts YAML 包含无法识别的节点"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatZodError(error: {
|
||||||
|
issues: { path: PropertyKey[]; message: string }[];
|
||||||
|
}) {
|
||||||
|
return error.issues
|
||||||
|
.map((issue) => {
|
||||||
|
const path = issue.path.join(".");
|
||||||
|
return path ? `${path}:${issue.message}` : issue.message;
|
||||||
|
})
|
||||||
|
.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEChartsFence(
|
||||||
|
source: string,
|
||||||
|
options: ParseEChartsFenceOptions = {}
|
||||||
|
): NormalizedEChartsFenceSpec {
|
||||||
|
const maxSourceBytes =
|
||||||
|
options.maxSourceBytes ?? defaultMaxEChartsFenceSourceBytes;
|
||||||
|
if (new TextEncoder().encode(source).byteLength > maxSourceBytes) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"SOURCE_TOO_LARGE",
|
||||||
|
`ECharts YAML 不能超过 ${maxSourceBytes} 字节`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = parseDocument(source, {
|
||||||
|
schema: "core",
|
||||||
|
strict: true,
|
||||||
|
uniqueKeys: true,
|
||||||
|
prettyErrors: true
|
||||||
|
});
|
||||||
|
if (document.errors.length > 0) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_YAML",
|
||||||
|
document.errors.map((error) => error.message).join(";")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectYamlNode(document.contents);
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = document.toJS({
|
||||||
|
maxAliasCount: 0,
|
||||||
|
mapAsMap: false
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_YAML",
|
||||||
|
error instanceof Error ? error.message : "ECharts YAML 解析失败",
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolResult = echartsFenceSpecSchema.safeParse(parsed);
|
||||||
|
if (!protocolResult.success) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_PROTOCOL",
|
||||||
|
formatZodError(protocolResult.error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const securityLimits =
|
||||||
|
options.securityLimits ?? defaultEChartsSecurityLimits;
|
||||||
|
const option = validateAndApplyEChartsOptionDefaults(
|
||||||
|
normalizeAndValidateEChartsOption(
|
||||||
|
protocolResult.data.option,
|
||||||
|
securityLimits
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const normalized: NormalizedEChartsFenceSpec = {
|
||||||
|
version: protocolResult.data.version,
|
||||||
|
theme: protocolResult.data.theme,
|
||||||
|
option
|
||||||
|
};
|
||||||
|
if (protocolResult.data.height !== undefined) {
|
||||||
|
normalized.height = protocolResult.data.height;
|
||||||
|
} else if (protocolResult.data.aspectRatio === undefined) {
|
||||||
|
normalized.height = DEFAULT_ECHARTS_FENCE_HEIGHT;
|
||||||
|
}
|
||||||
|
if (protocolResult.data.aspectRatio !== undefined) {
|
||||||
|
normalized.aspectRatio = protocolResult.data.aspectRatio;
|
||||||
|
}
|
||||||
|
if (protocolResult.data.caption !== undefined) {
|
||||||
|
normalized.caption = protocolResult.data.caption;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ECHARTS_FENCE_VERSION = 1;
|
||||||
|
export const DEFAULT_ECHARTS_FENCE_HEIGHT = "80mm";
|
||||||
|
|
||||||
|
export const supportedEChartsSeriesTypes = [
|
||||||
|
"line",
|
||||||
|
"bar",
|
||||||
|
"pie",
|
||||||
|
"scatter",
|
||||||
|
"radar",
|
||||||
|
"heatmap",
|
||||||
|
"boxplot",
|
||||||
|
"candlestick",
|
||||||
|
"tree",
|
||||||
|
"treemap",
|
||||||
|
"sunburst",
|
||||||
|
"graph",
|
||||||
|
"sankey",
|
||||||
|
"chord",
|
||||||
|
"funnel",
|
||||||
|
"gauge",
|
||||||
|
"pictorialBar"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SupportedEChartsSeriesType =
|
||||||
|
(typeof supportedEChartsSeriesTypes)[number];
|
||||||
|
|
||||||
|
export const echartsFenceThemeSchema = z.enum([
|
||||||
|
"document",
|
||||||
|
"light",
|
||||||
|
"dark"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const positiveLengthPattern =
|
||||||
|
/^(?:0*[1-9]\d*(?:\.\d+)?|0*\.\d*[1-9]\d*)(?:mm|cm|in|px)$/u;
|
||||||
|
|
||||||
|
export const echartsFenceSpecSchema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(ECHARTS_FENCE_VERSION).default(
|
||||||
|
ECHARTS_FENCE_VERSION
|
||||||
|
),
|
||||||
|
height: z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
positiveLengthPattern,
|
||||||
|
"height 必须是大于零的 mm、cm、in 或 px 长度"
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
aspectRatio: z.number().positive().max(10).optional(),
|
||||||
|
theme: echartsFenceThemeSchema.default("document"),
|
||||||
|
caption: z.string().trim().min(1).max(500).optional(),
|
||||||
|
option: z.record(z.string(), z.unknown())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type EChartsFenceSpec = z.infer<typeof echartsFenceSpecSchema>;
|
||||||
|
|
||||||
|
export type JsonPrimitive = string | number | boolean | null;
|
||||||
|
export type JsonValue =
|
||||||
|
| JsonPrimitive
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export interface NormalizedEChartsFenceSpec {
|
||||||
|
version: typeof ECHARTS_FENCE_VERSION;
|
||||||
|
height?: string;
|
||||||
|
aspectRatio?: number;
|
||||||
|
theme: z.infer<typeof echartsFenceThemeSchema>;
|
||||||
|
caption?: string;
|
||||||
|
option: Record<string, JsonValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const echartsFenceErrorCodes = [
|
||||||
|
"SOURCE_TOO_LARGE",
|
||||||
|
"INVALID_YAML",
|
||||||
|
"YAML_ALIAS_NOT_ALLOWED",
|
||||||
|
"YAML_ANCHOR_NOT_ALLOWED",
|
||||||
|
"YAML_TAG_NOT_ALLOWED",
|
||||||
|
"INVALID_PROTOCOL",
|
||||||
|
"INVALID_OPTION",
|
||||||
|
"UNSAFE_OPTION",
|
||||||
|
"LIMIT_EXCEEDED"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type EChartsFenceErrorCode =
|
||||||
|
(typeof echartsFenceErrorCodes)[number];
|
||||||
|
|
||||||
|
export class EChartsFenceError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: EChartsFenceErrorCode,
|
||||||
|
message: string,
|
||||||
|
options?: ErrorOptions
|
||||||
|
) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = "EChartsFenceError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import {
|
||||||
|
EChartsFenceError,
|
||||||
|
supportedEChartsSeriesTypes,
|
||||||
|
type JsonValue
|
||||||
|
} from "./protocol.js";
|
||||||
|
|
||||||
|
export interface EChartsSecurityLimits {
|
||||||
|
maxDepth: number;
|
||||||
|
maxNodes: number;
|
||||||
|
maxObjectKeys: number;
|
||||||
|
maxArrayLength: number;
|
||||||
|
maxStringLength: number;
|
||||||
|
maxSeries: number;
|
||||||
|
maxHierarchyNodes: number;
|
||||||
|
maxGraphNodes: number;
|
||||||
|
maxGraphEdges: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultEChartsSecurityLimits: EChartsSecurityLimits = {
|
||||||
|
maxDepth: 32,
|
||||||
|
maxNodes: 50_000,
|
||||||
|
maxObjectKeys: 1_000,
|
||||||
|
maxArrayLength: 20_000,
|
||||||
|
maxStringLength: 100_000,
|
||||||
|
maxSeries: 32,
|
||||||
|
maxHierarchyNodes: 5_000,
|
||||||
|
maxGraphNodes: 5_000,
|
||||||
|
maxGraphEdges: 10_000
|
||||||
|
};
|
||||||
|
|
||||||
|
const forbiddenPropertyNames = new Set([
|
||||||
|
"__proto__",
|
||||||
|
"prototype",
|
||||||
|
"constructor",
|
||||||
|
"renderItem",
|
||||||
|
"optionToContent",
|
||||||
|
"contentToOption",
|
||||||
|
"onclick",
|
||||||
|
"onClick",
|
||||||
|
"link",
|
||||||
|
"sublink"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allowedTopLevelOptionKeys = new Set([
|
||||||
|
"aria",
|
||||||
|
"animation",
|
||||||
|
"animationDelay",
|
||||||
|
"animationDelayUpdate",
|
||||||
|
"animationDuration",
|
||||||
|
"animationDurationUpdate",
|
||||||
|
"animationEasing",
|
||||||
|
"animationEasingUpdate",
|
||||||
|
"angleAxis",
|
||||||
|
"axisPointer",
|
||||||
|
"backgroundColor",
|
||||||
|
"calendar",
|
||||||
|
"color",
|
||||||
|
"darkMode",
|
||||||
|
"dataZoom",
|
||||||
|
"dataset",
|
||||||
|
"grid",
|
||||||
|
"legend",
|
||||||
|
"polar",
|
||||||
|
"radar",
|
||||||
|
"radiusAxis",
|
||||||
|
"series",
|
||||||
|
"textStyle",
|
||||||
|
"title",
|
||||||
|
"toolbox",
|
||||||
|
"tooltip",
|
||||||
|
"visualMap",
|
||||||
|
"xAxis",
|
||||||
|
"yAxis"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allowedToolboxFeatures = new Set([
|
||||||
|
"brush",
|
||||||
|
"dataZoom",
|
||||||
|
"magicType",
|
||||||
|
"restore",
|
||||||
|
"saveAsImage"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const unsafeStringPattern =
|
||||||
|
/^(?:javascript|file|https?):|^\/\/|^data:|url\s*\(|image:\/\//iu;
|
||||||
|
|
||||||
|
interface TraversalState {
|
||||||
|
nodes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionPath(path: readonly (string | number)[]) {
|
||||||
|
return path.length === 0 ? "option" : `option.${path.join(".")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeKey(key: string, path: readonly (string | number)[]) {
|
||||||
|
if (forbiddenPropertyNames.has(key)) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"UNSAFE_OPTION",
|
||||||
|
`${optionPath(path)} 包含禁止属性 ${key}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValue(
|
||||||
|
value: unknown,
|
||||||
|
path: readonly (string | number)[],
|
||||||
|
depth: number,
|
||||||
|
state: TraversalState,
|
||||||
|
limits: EChartsSecurityLimits
|
||||||
|
): JsonValue {
|
||||||
|
state.nodes += 1;
|
||||||
|
if (state.nodes > limits.maxNodes) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"LIMIT_EXCEEDED",
|
||||||
|
`ECharts option 节点数量不能超过 ${limits.maxNodes}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth > limits.maxDepth) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"LIMIT_EXCEEDED",
|
||||||
|
`ECharts option 嵌套深度不能超过 ${limits.maxDepth}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null || typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_OPTION",
|
||||||
|
`${optionPath(path)} 必须是有限数值`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (value.length > limits.maxStringLength) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"LIMIT_EXCEEDED",
|
||||||
|
`${optionPath(path)} 字符串长度不能超过 ${limits.maxStringLength}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (unsafeStringPattern.test(value.trim())) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"UNSAFE_OPTION",
|
||||||
|
`${optionPath(path)} 包含被禁止的外部资源或危险 URL`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
path.at(-1) === "formatter" &&
|
||||||
|
(value.includes("<") || value.includes(">"))
|
||||||
|
) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"UNSAFE_OPTION",
|
||||||
|
`${optionPath(path)} 只允许不含 HTML 的模板字符串`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > limits.maxArrayLength) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"LIMIT_EXCEEDED",
|
||||||
|
`${optionPath(path)} 数组长度不能超过 ${limits.maxArrayLength}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value.map((item, index) =>
|
||||||
|
normalizeValue(item, [...path, index], depth + 1, state, limits)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== "object") {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_OPTION",
|
||||||
|
`${optionPath(path)} 只能包含 JSON 可序列化值`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prototype = Object.getPrototypeOf(value);
|
||||||
|
if (prototype !== Object.prototype && prototype !== null) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_OPTION",
|
||||||
|
`${optionPath(path)} 必须是普通对象`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
if (entries.length > limits.maxObjectKeys) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"LIMIT_EXCEEDED",
|
||||||
|
`${optionPath(path)} 属性数量不能超过 ${limits.maxObjectKeys}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: Record<string, JsonValue> = {};
|
||||||
|
for (const [key, child] of entries) {
|
||||||
|
assertSafeKey(key, [...path, key]);
|
||||||
|
normalized[key] = normalizeValue(
|
||||||
|
child,
|
||||||
|
[...path, key],
|
||||||
|
depth + 1,
|
||||||
|
state,
|
||||||
|
limits
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSupportedTopLevelKeys(option: Record<string, JsonValue>) {
|
||||||
|
for (const key of Object.keys(option)) {
|
||||||
|
if (!allowedTopLevelOptionKeys.has(key)) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_OPTION",
|
||||||
|
`option.${key} 暂不在首版支持范围内`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSupportedSeries(
|
||||||
|
option: Record<string, JsonValue>,
|
||||||
|
limits: EChartsSecurityLimits
|
||||||
|
) {
|
||||||
|
const series = option.series;
|
||||||
|
if (!Array.isArray(series) || series.length === 0) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_OPTION",
|
||||||
|
"option.series 必须是非空数组"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (series.length > limits.maxSeries) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"LIMIT_EXCEEDED",
|
||||||
|
`option.series 数量不能超过 ${limits.maxSeries}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, item] of series.entries()) {
|
||||||
|
if (
|
||||||
|
item === null ||
|
||||||
|
Array.isArray(item) ||
|
||||||
|
typeof item !== "object"
|
||||||
|
) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_OPTION",
|
||||||
|
`option.series.${index} 必须是对象`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const type = item.type;
|
||||||
|
if (
|
||||||
|
typeof type !== "string" ||
|
||||||
|
!supportedEChartsSeriesTypes.some(
|
||||||
|
(supportedType) => supportedType === type
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_OPTION",
|
||||||
|
`option.series.${index}.type 暂不支持 ${String(type)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertSeriesStructureLimits(item, index, type, limits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countHierarchyNodes(
|
||||||
|
value: JsonValue | undefined,
|
||||||
|
limit: number,
|
||||||
|
path: string
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
const pending = [...value];
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const node = pending.pop();
|
||||||
|
count += 1;
|
||||||
|
if (count > limit) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"LIMIT_EXCEEDED",
|
||||||
|
`${path} 层级节点数量不能超过 ${limit}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
node !== null &&
|
||||||
|
!Array.isArray(node) &&
|
||||||
|
typeof node === "object" &&
|
||||||
|
Array.isArray(node.children)
|
||||||
|
) {
|
||||||
|
pending.push(...node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayLength(value: JsonValue | undefined) {
|
||||||
|
return Array.isArray(value) ? value.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSeriesStructureLimits(
|
||||||
|
series: Record<string, JsonValue>,
|
||||||
|
seriesIndex: number,
|
||||||
|
type: string,
|
||||||
|
limits: EChartsSecurityLimits
|
||||||
|
) {
|
||||||
|
const path = `option.series.${seriesIndex}`;
|
||||||
|
if (
|
||||||
|
type === "tree" ||
|
||||||
|
type === "treemap" ||
|
||||||
|
type === "sunburst"
|
||||||
|
) {
|
||||||
|
countHierarchyNodes(
|
||||||
|
series.data,
|
||||||
|
limits.maxHierarchyNodes,
|
||||||
|
`${path}.data`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
type !== "graph" &&
|
||||||
|
type !== "sankey" &&
|
||||||
|
type !== "chord"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeCount =
|
||||||
|
arrayLength(series.data) + arrayLength(series.nodes);
|
||||||
|
if (nodeCount > limits.maxGraphNodes) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"LIMIT_EXCEEDED",
|
||||||
|
`${path} 节点数量不能超过 ${limits.maxGraphNodes}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const edgeCount =
|
||||||
|
arrayLength(series.links) + arrayLength(series.edges);
|
||||||
|
if (edgeCount > limits.maxGraphEdges) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"LIMIT_EXCEEDED",
|
||||||
|
`${path} 边数量不能超过 ${limits.maxGraphEdges}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeToolbox(option: Record<string, JsonValue>) {
|
||||||
|
if (option.toolbox === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toolboxes = Array.isArray(option.toolbox)
|
||||||
|
? option.toolbox
|
||||||
|
: [option.toolbox];
|
||||||
|
|
||||||
|
for (const toolbox of toolboxes) {
|
||||||
|
if (
|
||||||
|
toolbox === null ||
|
||||||
|
Array.isArray(toolbox) ||
|
||||||
|
typeof toolbox !== "object"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const feature = toolbox.feature;
|
||||||
|
if (
|
||||||
|
feature === undefined ||
|
||||||
|
feature === null ||
|
||||||
|
Array.isArray(feature) ||
|
||||||
|
typeof feature !== "object"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(feature)) {
|
||||||
|
if (!allowedToolboxFeatures.has(key)) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"UNSAFE_OPTION",
|
||||||
|
`option.toolbox.feature.${key} 不允许使用`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeTransforms(value: JsonValue, path: string[] = []) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item) => assertSafeTransforms(item, path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value === null || typeof value !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
const childPath = [...path, key];
|
||||||
|
if (key === "transform") {
|
||||||
|
const transforms = Array.isArray(child) ? child : [child];
|
||||||
|
for (const transform of transforms) {
|
||||||
|
if (
|
||||||
|
transform === null ||
|
||||||
|
Array.isArray(transform) ||
|
||||||
|
typeof transform !== "object" ||
|
||||||
|
(transform.type !== "filter" && transform.type !== "sort")
|
||||||
|
) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"UNSAFE_OPTION",
|
||||||
|
`${optionPath(childPath)} 只允许内置 filter 或 sort`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertSafeTransforms(child, childPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAndValidateEChartsOption(
|
||||||
|
value: unknown,
|
||||||
|
limits: EChartsSecurityLimits = defaultEChartsSecurityLimits
|
||||||
|
): Record<string, JsonValue> {
|
||||||
|
const normalized = normalizeValue(
|
||||||
|
value,
|
||||||
|
[],
|
||||||
|
0,
|
||||||
|
{ nodes: 0 },
|
||||||
|
limits
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
normalized === null ||
|
||||||
|
Array.isArray(normalized) ||
|
||||||
|
typeof normalized !== "object"
|
||||||
|
) {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_OPTION",
|
||||||
|
"option 必须是普通对象"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSupportedTopLevelKeys(normalized);
|
||||||
|
assertSupportedSeries(normalized, limits);
|
||||||
|
assertSafeToolbox(normalized);
|
||||||
|
assertSafeTransforms(normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
import {
|
||||||
|
EChartsFenceError,
|
||||||
|
type JsonValue,
|
||||||
|
type SupportedEChartsSeriesType
|
||||||
|
} from "./protocol.js";
|
||||||
|
|
||||||
|
type JsonObject = Record<string, JsonValue>;
|
||||||
|
|
||||||
|
export interface EChartsSeriesValidationContext {
|
||||||
|
option: JsonObject;
|
||||||
|
series: JsonObject;
|
||||||
|
seriesIndex: number;
|
||||||
|
hasDataset: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EChartsSeriesValidator = (
|
||||||
|
context: EChartsSeriesValidationContext
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
function fail(path: string, message: string): never {
|
||||||
|
throw new EChartsFenceError(
|
||||||
|
"INVALID_OPTION",
|
||||||
|
`${path} ${message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: JsonValue | undefined): value is JsonObject {
|
||||||
|
return (
|
||||||
|
value !== null &&
|
||||||
|
value !== undefined &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
typeof value === "object"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObjects(value: JsonValue | undefined): JsonObject[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter(isObject);
|
||||||
|
}
|
||||||
|
return isObject(value) ? [value] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmptyData(value: JsonValue | undefined) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return isObject(value) && Object.keys(value).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function datasetHasRows(dataset: JsonObject) {
|
||||||
|
const source = dataset.source;
|
||||||
|
if (Array.isArray(source)) {
|
||||||
|
if (source.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const firstRow = source[0];
|
||||||
|
const hasHeader =
|
||||||
|
dataset.sourceHeader === true ||
|
||||||
|
(Array.isArray(firstRow) &&
|
||||||
|
firstRow.length > 0 &&
|
||||||
|
firstRow.every((value) => typeof value === "string"));
|
||||||
|
return hasHeader ? source.length > 1 : true;
|
||||||
|
}
|
||||||
|
if (isObject(source)) {
|
||||||
|
return Object.values(source).some(
|
||||||
|
(column) => Array.isArray(column) && column.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function datasets(option: JsonObject) {
|
||||||
|
return asObjects(option.dataset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function datasetHasSource(
|
||||||
|
option: JsonObject,
|
||||||
|
datasetIndex: number,
|
||||||
|
visited = new Set<number>()
|
||||||
|
): boolean {
|
||||||
|
const allDatasets = datasets(option);
|
||||||
|
const dataset = allDatasets[datasetIndex];
|
||||||
|
if (!dataset || visited.has(datasetIndex)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
visited.add(datasetIndex);
|
||||||
|
|
||||||
|
if (datasetHasRows(dataset)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (dataset.transform === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = dataset.fromDatasetIndex;
|
||||||
|
if (
|
||||||
|
typeof upstream === "number" &&
|
||||||
|
Number.isInteger(upstream) &&
|
||||||
|
upstream >= 0
|
||||||
|
) {
|
||||||
|
return datasetHasSource(option, upstream, visited);
|
||||||
|
}
|
||||||
|
return allDatasets.some(
|
||||||
|
(candidate, index) =>
|
||||||
|
index !== datasetIndex && datasetHasRows(candidate)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seriesHasDataset(option: JsonObject, series: JsonObject) {
|
||||||
|
const datasetIndex =
|
||||||
|
typeof series.datasetIndex === "number" &&
|
||||||
|
Number.isInteger(series.datasetIndex)
|
||||||
|
? series.datasetIndex
|
||||||
|
: 0;
|
||||||
|
return datasetHasSource(option, datasetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireData(context: EChartsSeriesValidationContext) {
|
||||||
|
if (
|
||||||
|
!isNonEmptyData(context.series.data) &&
|
||||||
|
!context.hasDataset
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.data`,
|
||||||
|
"必须是非空数组,或通过 dataset.source 提供数据"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCartesianAxes(option: JsonObject) {
|
||||||
|
option.xAxis ??= {};
|
||||||
|
option.yAxis ??= {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireComponent(
|
||||||
|
option: JsonObject,
|
||||||
|
name: string,
|
||||||
|
seriesIndex: number
|
||||||
|
) {
|
||||||
|
if (option[name] === undefined) {
|
||||||
|
fail(
|
||||||
|
`option.${name}`,
|
||||||
|
`是 option.series.${seriesIndex} 所用坐标系的必填组件`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coordinateSystem(series: JsonObject) {
|
||||||
|
return typeof series.coordinateSystem === "string"
|
||||||
|
? series.coordinateSystem
|
||||||
|
: "cartesian2d";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCartesianOrPolar(
|
||||||
|
context: EChartsSeriesValidationContext
|
||||||
|
) {
|
||||||
|
const coordinate = coordinateSystem(context.series);
|
||||||
|
if (coordinate === "cartesian2d") {
|
||||||
|
ensureCartesianAxes(context.option);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coordinate === "polar") {
|
||||||
|
requireComponent(
|
||||||
|
context.option,
|
||||||
|
"polar",
|
||||||
|
context.seriesIndex
|
||||||
|
);
|
||||||
|
requireComponent(
|
||||||
|
context.option,
|
||||||
|
"angleAxis",
|
||||||
|
context.seriesIndex
|
||||||
|
);
|
||||||
|
requireComponent(
|
||||||
|
context.option,
|
||||||
|
"radiusAxis",
|
||||||
|
context.seriesIndex
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||||
|
`暂不支持 ${coordinate}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataValues(series: JsonObject): JsonValue[] {
|
||||||
|
return Array.isArray(series.data) ? series.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemValue(item: JsonValue): JsonValue {
|
||||||
|
return isObject(item) && item.value !== undefined
|
||||||
|
? item.value
|
||||||
|
: item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireTupleData(
|
||||||
|
context: EChartsSeriesValidationContext,
|
||||||
|
minimumLength: number,
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
if (context.hasDataset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [dataIndex, item] of dataValues(
|
||||||
|
context.series
|
||||||
|
).entries()) {
|
||||||
|
const value = itemValue(item);
|
||||||
|
if (!Array.isArray(value) || value.length < minimumLength) {
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.data.${dataIndex}`,
|
||||||
|
`必须是${description}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLineOrBar(
|
||||||
|
context: EChartsSeriesValidationContext
|
||||||
|
) {
|
||||||
|
requireData(context);
|
||||||
|
validateCartesianOrPolar(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateScatter(
|
||||||
|
context: EChartsSeriesValidationContext
|
||||||
|
) {
|
||||||
|
requireData(context);
|
||||||
|
const coordinate = coordinateSystem(context.series);
|
||||||
|
if (coordinate === "calendar") {
|
||||||
|
requireComponent(
|
||||||
|
context.option,
|
||||||
|
"calendar",
|
||||||
|
context.seriesIndex
|
||||||
|
);
|
||||||
|
requireTupleData(context, 2, "至少包含日期和值的数组");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validateCartesianOrPolar(context);
|
||||||
|
requireTupleData(context, 2, "至少包含两个坐标值的数组");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePie(context: EChartsSeriesValidationContext) {
|
||||||
|
requireData(context);
|
||||||
|
if (context.hasDataset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [dataIndex, item] of dataValues(
|
||||||
|
context.series
|
||||||
|
).entries()) {
|
||||||
|
const value = itemValue(item);
|
||||||
|
if (
|
||||||
|
typeof value !== "number" ||
|
||||||
|
!Number.isFinite(value)
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.data.${dataIndex}.value`,
|
||||||
|
"必须是有限数值"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function radarIndicators(option: JsonObject) {
|
||||||
|
return asObjects(option.radar).flatMap((radar) =>
|
||||||
|
Array.isArray(radar.indicator)
|
||||||
|
? radar.indicator.filter(isObject)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRadar(context: EChartsSeriesValidationContext) {
|
||||||
|
requireData(context);
|
||||||
|
const indicators = radarIndicators(context.option);
|
||||||
|
if (indicators.length === 0) {
|
||||||
|
fail(
|
||||||
|
"option.radar.indicator",
|
||||||
|
"是雷达图的必填非空数组"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (context.hasDataset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireTupleData(
|
||||||
|
context,
|
||||||
|
indicators.length,
|
||||||
|
`至少包含 ${indicators.length} 个指标值的数组`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHeatmap(context: EChartsSeriesValidationContext) {
|
||||||
|
requireData(context);
|
||||||
|
const coordinate = coordinateSystem(context.series);
|
||||||
|
if (coordinate === "calendar") {
|
||||||
|
requireComponent(
|
||||||
|
context.option,
|
||||||
|
"calendar",
|
||||||
|
context.seriesIndex
|
||||||
|
);
|
||||||
|
requireTupleData(context, 2, "包含日期和值的数组");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coordinate !== "cartesian2d") {
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||||
|
`暂不支持 ${coordinate}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ensureCartesianAxes(context.option);
|
||||||
|
requireTupleData(context, 3, "包含 x、y、值的三元数组");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBoxplot(
|
||||||
|
context: EChartsSeriesValidationContext
|
||||||
|
) {
|
||||||
|
requireData(context);
|
||||||
|
ensureCartesianAxes(context.option);
|
||||||
|
requireTupleData(
|
||||||
|
context,
|
||||||
|
5,
|
||||||
|
"包含最小值、Q1、中位数、Q3、最大值的五元数组"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCandlestick(
|
||||||
|
context: EChartsSeriesValidationContext
|
||||||
|
) {
|
||||||
|
requireData(context);
|
||||||
|
ensureCartesianAxes(context.option);
|
||||||
|
requireTupleData(
|
||||||
|
context,
|
||||||
|
4,
|
||||||
|
"包含开盘、收盘、最低、最高值的四元数组"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireDirectArray(
|
||||||
|
context: EChartsSeriesValidationContext,
|
||||||
|
key: "data" | "nodes" | "links" | "edges",
|
||||||
|
description: string
|
||||||
|
): JsonValue[] {
|
||||||
|
const value = context.series[key];
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.${key}`,
|
||||||
|
`必须是${description}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHierarchyNodes(
|
||||||
|
context: EChartsSeriesValidationContext,
|
||||||
|
requireLeafValue: boolean
|
||||||
|
) {
|
||||||
|
const roots = requireDirectArray(
|
||||||
|
context,
|
||||||
|
"data",
|
||||||
|
"非空层级节点数组"
|
||||||
|
);
|
||||||
|
const pending = roots.map((node, index) => ({
|
||||||
|
node,
|
||||||
|
path: `option.series.${context.seriesIndex}.data.${index}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const current = pending.pop();
|
||||||
|
if (!current) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isObject(current.node)) {
|
||||||
|
fail(current.path, "必须是层级节点对象");
|
||||||
|
}
|
||||||
|
const children = current.node.children;
|
||||||
|
if (children !== undefined && !Array.isArray(children)) {
|
||||||
|
fail(`${current.path}.children`, "必须是数组");
|
||||||
|
}
|
||||||
|
const childNodes = Array.isArray(children) ? children : [];
|
||||||
|
childNodes.forEach((node, index) =>
|
||||||
|
pending.push({
|
||||||
|
node,
|
||||||
|
path: `${current.path}.children.${index}`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requireLeafValue || childNodes.length > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = current.node.value;
|
||||||
|
const valid =
|
||||||
|
typeof value === "number"
|
||||||
|
? Number.isFinite(value)
|
||||||
|
: Array.isArray(value) &&
|
||||||
|
value.length > 0 &&
|
||||||
|
value.every(
|
||||||
|
(item) =>
|
||||||
|
typeof item === "number" && Number.isFinite(item)
|
||||||
|
);
|
||||||
|
if (!valid) {
|
||||||
|
fail(
|
||||||
|
`${current.path}.value`,
|
||||||
|
"必须是有限数值或非空有限数值数组"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTree(context: EChartsSeriesValidationContext) {
|
||||||
|
validateHierarchyNodes(context, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTreemapOrSunburst(
|
||||||
|
context: EChartsSeriesValidationContext
|
||||||
|
) {
|
||||||
|
validateHierarchyNodes(context, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationNodes(
|
||||||
|
context: EChartsSeriesValidationContext
|
||||||
|
): { values: JsonValue[]; key: "data" | "nodes" } {
|
||||||
|
if (
|
||||||
|
Array.isArray(context.series.data) &&
|
||||||
|
context.series.data.length > 0
|
||||||
|
) {
|
||||||
|
return { values: context.series.data, key: "data" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
values: requireDirectArray(
|
||||||
|
context,
|
||||||
|
"nodes",
|
||||||
|
"非空节点数组"
|
||||||
|
),
|
||||||
|
key: "nodes"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationEdges(
|
||||||
|
context: EChartsSeriesValidationContext,
|
||||||
|
required: boolean
|
||||||
|
): { values: JsonValue[]; key: "links" | "edges" } {
|
||||||
|
if (
|
||||||
|
Array.isArray(context.series.links) &&
|
||||||
|
context.series.links.length > 0
|
||||||
|
) {
|
||||||
|
return { values: context.series.links, key: "links" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Array.isArray(context.series.edges) &&
|
||||||
|
context.series.edges.length > 0
|
||||||
|
) {
|
||||||
|
return { values: context.series.edges, key: "edges" };
|
||||||
|
}
|
||||||
|
if (required) {
|
||||||
|
return {
|
||||||
|
values: requireDirectArray(
|
||||||
|
context,
|
||||||
|
"links",
|
||||||
|
"非空关系边数组"
|
||||||
|
),
|
||||||
|
key: "links"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { values: [], key: "links" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeIdentifiers(node: JsonValue, index: number) {
|
||||||
|
const identifiers = new Set<string>([`#${index}`]);
|
||||||
|
if (typeof node === "string" || typeof node === "number") {
|
||||||
|
identifiers.add(String(node));
|
||||||
|
return identifiers;
|
||||||
|
}
|
||||||
|
if (!isObject(node)) {
|
||||||
|
return identifiers;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof node.id === "string" ||
|
||||||
|
typeof node.id === "number"
|
||||||
|
) {
|
||||||
|
identifiers.add(String(node.id));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof node.name === "string" ||
|
||||||
|
typeof node.name === "number"
|
||||||
|
) {
|
||||||
|
identifiers.add(String(node.name));
|
||||||
|
}
|
||||||
|
return identifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeEndpointExists(
|
||||||
|
value: JsonValue | undefined,
|
||||||
|
identifiers: Set<string>,
|
||||||
|
nodeCount: number
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
typeof value !== "string" &&
|
||||||
|
typeof value !== "number"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value === "number" &&
|
||||||
|
Number.isInteger(value) &&
|
||||||
|
value >= 0 &&
|
||||||
|
value < nodeCount
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return identifiers.has(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRelationSeries(
|
||||||
|
context: EChartsSeriesValidationContext,
|
||||||
|
options: {
|
||||||
|
requireEdges: boolean;
|
||||||
|
requireEdgeValue: boolean;
|
||||||
|
defaultGraphLayout?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const nodes = relationNodes(context);
|
||||||
|
const identifiers = new Set<string>();
|
||||||
|
nodes.values.forEach((node, index) => {
|
||||||
|
if (
|
||||||
|
node === null ||
|
||||||
|
Array.isArray(node) ||
|
||||||
|
(typeof node !== "object" &&
|
||||||
|
typeof node !== "string" &&
|
||||||
|
typeof node !== "number")
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.${nodes.key}.${index}`,
|
||||||
|
"必须是节点对象、名称或数值"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nodeIdentifiers(node, index).forEach((identifier) =>
|
||||||
|
identifiers.add(identifier)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.defaultGraphLayout &&
|
||||||
|
context.series.layout === undefined
|
||||||
|
) {
|
||||||
|
const hasCoordinates = nodes.values.every(
|
||||||
|
(node) =>
|
||||||
|
isObject(node) &&
|
||||||
|
typeof node.x === "number" &&
|
||||||
|
Number.isFinite(node.x) &&
|
||||||
|
typeof node.y === "number" &&
|
||||||
|
Number.isFinite(node.y)
|
||||||
|
);
|
||||||
|
context.series.layout = hasCoordinates ? "none" : "force";
|
||||||
|
}
|
||||||
|
|
||||||
|
const edges = relationEdges(context, options.requireEdges);
|
||||||
|
edges.values.forEach((edge, edgeIndex) => {
|
||||||
|
const path = `option.series.${context.seriesIndex}.${edges.key}.${edgeIndex}`;
|
||||||
|
if (!isObject(edge)) {
|
||||||
|
fail(path, "必须是关系边对象");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!edgeEndpointExists(
|
||||||
|
edge.source,
|
||||||
|
identifiers,
|
||||||
|
nodes.values.length
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail(`${path}.source`, "必须引用已存在的节点");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!edgeEndpointExists(
|
||||||
|
edge.target,
|
||||||
|
identifiers,
|
||||||
|
nodes.values.length
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail(`${path}.target`, "必须引用已存在的节点");
|
||||||
|
}
|
||||||
|
if (options.requireEdgeValue || edge.value !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof edge.value !== "number" ||
|
||||||
|
!Number.isFinite(edge.value) ||
|
||||||
|
edge.value < 0
|
||||||
|
) {
|
||||||
|
fail(`${path}.value`, "必须是非负有限数值");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGraph(context: EChartsSeriesValidationContext) {
|
||||||
|
const coordinate = context.series.coordinateSystem;
|
||||||
|
if (coordinate !== undefined && coordinate !== "none") {
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||||
|
`暂不支持 ${String(coordinate)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateRelationSeries(context, {
|
||||||
|
requireEdges: false,
|
||||||
|
requireEdgeValue: false,
|
||||||
|
defaultGraphLayout: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSankey(context: EChartsSeriesValidationContext) {
|
||||||
|
const coordinate = context.series.coordinateSystem;
|
||||||
|
if (coordinate !== undefined && coordinate !== "view") {
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||||
|
`暂不支持 ${String(coordinate)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateRelationSeries(context, {
|
||||||
|
requireEdges: true,
|
||||||
|
requireEdgeValue: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateChord(context: EChartsSeriesValidationContext) {
|
||||||
|
const coordinate = context.series.coordinateSystem;
|
||||||
|
if (coordinate !== undefined && coordinate !== "none") {
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||||
|
`暂不支持 ${String(coordinate)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateRelationSeries(context, {
|
||||||
|
requireEdges: true,
|
||||||
|
requireEdgeValue: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFiniteValueData(
|
||||||
|
context: EChartsSeriesValidationContext
|
||||||
|
) {
|
||||||
|
requireData(context);
|
||||||
|
if (context.hasDataset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [dataIndex, item] of dataValues(
|
||||||
|
context.series
|
||||||
|
).entries()) {
|
||||||
|
const value = itemValue(item);
|
||||||
|
if (
|
||||||
|
typeof value !== "number" ||
|
||||||
|
!Number.isFinite(value)
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
`option.series.${context.seriesIndex}.data.${dataIndex}.value`,
|
||||||
|
"必须是有限数值"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const echartsSeriesValidators: ReadonlyMap<
|
||||||
|
SupportedEChartsSeriesType,
|
||||||
|
EChartsSeriesValidator
|
||||||
|
> = new Map([
|
||||||
|
["line", validateLineOrBar],
|
||||||
|
["bar", validateLineOrBar],
|
||||||
|
["pie", validatePie],
|
||||||
|
["scatter", validateScatter],
|
||||||
|
["radar", validateRadar],
|
||||||
|
["heatmap", validateHeatmap],
|
||||||
|
["boxplot", validateBoxplot],
|
||||||
|
["candlestick", validateCandlestick],
|
||||||
|
["tree", validateTree],
|
||||||
|
["treemap", validateTreemapOrSunburst],
|
||||||
|
["sunburst", validateTreemapOrSunburst],
|
||||||
|
["graph", validateGraph],
|
||||||
|
["sankey", validateSankey],
|
||||||
|
["chord", validateChord],
|
||||||
|
["funnel", validateFiniteValueData],
|
||||||
|
["gauge", validateFiniteValueData],
|
||||||
|
["pictorialBar", validateLineOrBar]
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function validateAndApplyEChartsOptionDefaults(
|
||||||
|
option: JsonObject
|
||||||
|
) {
|
||||||
|
const series = option.series;
|
||||||
|
if (!Array.isArray(series)) {
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [seriesIndex, value] of series.entries()) {
|
||||||
|
if (!isObject(value) || typeof value.type !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = value.type as SupportedEChartsSeriesType;
|
||||||
|
const validator = echartsSeriesValidators.get(type);
|
||||||
|
validator?.({
|
||||||
|
option,
|
||||||
|
series: value,
|
||||||
|
seriesIndex,
|
||||||
|
hasDataset: seriesHasDataset(option, value)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return option;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
.md-echarts {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-echarts-host {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-echarts-definition[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-echarts figcaption {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.875em;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-echarts-error {
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-echarts-svg-image {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
markdownItECharts
|
||||||
|
} from "../src/index.js";
|
||||||
|
import {
|
||||||
|
disposeEChartsBlocks,
|
||||||
|
renderEChartsBlocks,
|
||||||
|
type EChartsEngine,
|
||||||
|
type EChartsInstanceLike
|
||||||
|
} from "../src/browser.js";
|
||||||
|
|
||||||
|
function createDocumentWithChart() {
|
||||||
|
const markdown = new MarkdownIt().use(markdownItECharts);
|
||||||
|
document.body.innerHTML = markdown.render(`
|
||||||
|
\`\`\`echarts
|
||||||
|
version: 1
|
||||||
|
height: 80mm
|
||||||
|
caption: 测试图表
|
||||||
|
option:
|
||||||
|
xAxis:
|
||||||
|
type: category
|
||||||
|
data: [A, B]
|
||||||
|
yAxis:
|
||||||
|
type: value
|
||||||
|
series:
|
||||||
|
- type: bar
|
||||||
|
data: [1, 2]
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeEngine(
|
||||||
|
configureSvg?: (svg: SVGSVGElement) => void
|
||||||
|
) {
|
||||||
|
const dispose = vi.fn();
|
||||||
|
const resize = vi.fn();
|
||||||
|
const setOption = vi.fn();
|
||||||
|
const init = vi.fn(
|
||||||
|
(container: HTMLElement): EChartsInstanceLike => {
|
||||||
|
dispose.mockImplementation(() => container.replaceChildren());
|
||||||
|
const handlers = new Map<string, () => void>();
|
||||||
|
return {
|
||||||
|
setOption(option, setOptionOptions) {
|
||||||
|
setOption(option, setOptionOptions);
|
||||||
|
const svg = document.createElementNS(
|
||||||
|
"http://www.w3.org/2000/svg",
|
||||||
|
"svg"
|
||||||
|
);
|
||||||
|
svg.setAttribute("viewBox", "0 0 400 300");
|
||||||
|
const text = document.createElementNS(
|
||||||
|
"http://www.w3.org/2000/svg",
|
||||||
|
"text"
|
||||||
|
);
|
||||||
|
text.textContent = "测试图表";
|
||||||
|
svg.append(text);
|
||||||
|
configureSvg?.(svg);
|
||||||
|
container.replaceChildren(svg);
|
||||||
|
handlers.get("finished")?.();
|
||||||
|
},
|
||||||
|
on(eventName, handler) {
|
||||||
|
handlers.set(eventName, handler);
|
||||||
|
},
|
||||||
|
off(eventName) {
|
||||||
|
handlers.delete(eventName);
|
||||||
|
},
|
||||||
|
resize,
|
||||||
|
dispose
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
engine: { init } satisfies EChartsEngine,
|
||||||
|
init,
|
||||||
|
dispose,
|
||||||
|
resize,
|
||||||
|
setOption
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("浏览器 ECharts 渲染", () => {
|
||||||
|
it("生成安全的内联 SVG 并释放实例", async () => {
|
||||||
|
createDocumentWithChart();
|
||||||
|
const { engine, init, dispose } = fakeEngine();
|
||||||
|
|
||||||
|
const outcomes = await renderEChartsBlocks(document, {
|
||||||
|
engine,
|
||||||
|
outputMode: "inline-svg"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcomes).toHaveLength(1);
|
||||||
|
expect(outcomes[0]?.success).toBe(true);
|
||||||
|
expect(init).toHaveBeenCalledWith(
|
||||||
|
expect.any(HTMLElement),
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({ renderer: "svg" })
|
||||||
|
);
|
||||||
|
expect(document.querySelector(".md-echarts-host svg")).not.toBeNull();
|
||||||
|
expect(dispose).toHaveBeenCalledOnce();
|
||||||
|
expect(
|
||||||
|
document.querySelector(".md-echarts")?.getAttribute(
|
||||||
|
"data-echarts-pending"
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("全局及系列级动画始终关闭", async () => {
|
||||||
|
createDocumentWithChart();
|
||||||
|
const { engine, setOption } = fakeEngine();
|
||||||
|
|
||||||
|
await renderEChartsBlocks(document, {
|
||||||
|
engine,
|
||||||
|
outputMode: "interactive"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setOption).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
animation: false,
|
||||||
|
animationDelay: 0,
|
||||||
|
animationDelayUpdate: 0,
|
||||||
|
animationDuration: 0,
|
||||||
|
animationDurationUpdate: 0,
|
||||||
|
series: [
|
||||||
|
expect.objectContaining({
|
||||||
|
animation: false,
|
||||||
|
animationDelay: 0,
|
||||||
|
animationDelayUpdate: 0,
|
||||||
|
animationDuration: 0,
|
||||||
|
animationDurationUpdate: 0
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
{ notMerge: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("生成 SVG Data URL 图片", async () => {
|
||||||
|
createDocumentWithChart();
|
||||||
|
const { engine } = fakeEngine();
|
||||||
|
|
||||||
|
const outcomes = await renderEChartsBlocks(document, {
|
||||||
|
engine,
|
||||||
|
outputMode: "svg-image"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcomes[0]?.success).toBe(true);
|
||||||
|
const image = document.querySelector<HTMLImageElement>(
|
||||||
|
".md-echarts-svg-image"
|
||||||
|
);
|
||||||
|
expect(image?.src).toMatch(/^data:image\/svg\+xml/u);
|
||||||
|
expect(image?.alt).toBe("测试图表");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("交互模式保留实例并支持统一销毁", async () => {
|
||||||
|
createDocumentWithChart();
|
||||||
|
const { engine, dispose } = fakeEngine();
|
||||||
|
|
||||||
|
const outcomes = await renderEChartsBlocks(document, {
|
||||||
|
engine,
|
||||||
|
outputMode: "interactive"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcomes[0]?.instance).toBeDefined();
|
||||||
|
expect(dispose).not.toHaveBeenCalled();
|
||||||
|
disposeEChartsBlocks(document);
|
||||||
|
expect(dispose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝渲染器产生的外部 SVG 资源", async () => {
|
||||||
|
createDocumentWithChart();
|
||||||
|
const onError = vi.fn();
|
||||||
|
const { engine } = fakeEngine((svg) => {
|
||||||
|
const image = document.createElementNS(
|
||||||
|
"http://www.w3.org/2000/svg",
|
||||||
|
"image"
|
||||||
|
);
|
||||||
|
image.setAttribute("href", "https://example.com/chart.png");
|
||||||
|
svg.append(image);
|
||||||
|
});
|
||||||
|
|
||||||
|
const outcomes = await renderEChartsBlocks(document, {
|
||||||
|
engine,
|
||||||
|
outputMode: "inline-svg",
|
||||||
|
onError
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcomes[0]?.success).toBe(false);
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
expect(document.querySelector(".md-echarts-error")).not.toBeNull();
|
||||||
|
expect(document.body.textContent).toContain("外部 SVG 链接");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import MarkdownIt from "markdown-it";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
markdownItECharts
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
describe("markdownItECharts", () => {
|
||||||
|
it("将 echarts 围栏转换为安全占位 DOM", () => {
|
||||||
|
const markdown = new MarkdownIt().use(markdownItECharts);
|
||||||
|
const html = markdown.render(`
|
||||||
|
\`\`\`echarts
|
||||||
|
version: 1
|
||||||
|
height: 80mm
|
||||||
|
caption: 年度收入
|
||||||
|
option:
|
||||||
|
xAxis:
|
||||||
|
type: category
|
||||||
|
data: [2023, 2024]
|
||||||
|
yAxis:
|
||||||
|
type: value
|
||||||
|
series:
|
||||||
|
- type: bar
|
||||||
|
data: [120, 180]
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(html).toContain('class="md-echarts"');
|
||||||
|
expect(html).toContain('data-echarts-pending="true"');
|
||||||
|
expect(html).toContain('class="md-echarts-host"');
|
||||||
|
expect(html).toContain(
|
||||||
|
'class="md-echarts-definition" hidden="hidden"'
|
||||||
|
);
|
||||||
|
expect(html).toContain("<figcaption>年度收入</figcaption>");
|
||||||
|
expect(html).toContain(""type":"bar"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("保留其他代码围栏的默认行为", () => {
|
||||||
|
const markdown = new MarkdownIt().use(markdownItECharts);
|
||||||
|
expect(markdown.render("```ts\nconst answer = 42;\n```")).toContain(
|
||||||
|
'<code class="language-ts">'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("错误配置产生安全错误节点并通知宿主", () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
const markdown = new MarkdownIt().use(markdownItECharts, {
|
||||||
|
onError
|
||||||
|
});
|
||||||
|
const environment = { requestId: "render-1" };
|
||||||
|
const html = markdown.render(`
|
||||||
|
\`\`\`echarts
|
||||||
|
version: 1
|
||||||
|
height: 80mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: custom
|
||||||
|
\`\`\`
|
||||||
|
`, environment);
|
||||||
|
|
||||||
|
expect(html).toContain('class="md-echarts-error"');
|
||||||
|
expect(html).not.toContain("<script");
|
||||||
|
expect(onError).toHaveBeenCalledWith(
|
||||||
|
expect.any(Error),
|
||||||
|
expect.objectContaining({ environment })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺少数据时输出包含配置路径的局部错误", () => {
|
||||||
|
const markdown = new MarkdownIt().use(markdownItECharts);
|
||||||
|
const html = markdown.render(`
|
||||||
|
\`\`\`echarts
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: bar
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(html).toContain('class="md-echarts-error"');
|
||||||
|
expect(html).toContain("option.series.0.data");
|
||||||
|
expect(html).not.toContain("data-echarts-pending");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
EChartsFenceError,
|
||||||
|
parseEChartsFence
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
const validSource = `
|
||||||
|
version: 1
|
||||||
|
height: 90mm
|
||||||
|
theme: document
|
||||||
|
caption: 年度趋势
|
||||||
|
option:
|
||||||
|
dataset:
|
||||||
|
source:
|
||||||
|
- [year, sales]
|
||||||
|
- [2023, 120]
|
||||||
|
- [2024, 180]
|
||||||
|
xAxis:
|
||||||
|
type: category
|
||||||
|
yAxis:
|
||||||
|
type: value
|
||||||
|
series:
|
||||||
|
- type: line
|
||||||
|
encode:
|
||||||
|
x: year
|
||||||
|
y: sales
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("parseEChartsFence", () => {
|
||||||
|
it("解析并规范化有效协议", () => {
|
||||||
|
expect(parseEChartsFence(validSource)).toEqual({
|
||||||
|
version: 1,
|
||||||
|
height: "90mm",
|
||||||
|
theme: "document",
|
||||||
|
caption: "年度趋势",
|
||||||
|
option: {
|
||||||
|
dataset: {
|
||||||
|
source: [
|
||||||
|
["year", "sales"],
|
||||||
|
[2023, 120],
|
||||||
|
[2024, 180]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category"
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value"
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
encode: {
|
||||||
|
x: "year",
|
||||||
|
y: "sales"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("补充协议版本、尺寸和直角坐标轴默认值", () => {
|
||||||
|
expect(
|
||||||
|
parseEChartsFence(`
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: line
|
||||||
|
data: [12, 18, 26]
|
||||||
|
`)
|
||||||
|
).toEqual({
|
||||||
|
version: 1,
|
||||||
|
height: "80mm",
|
||||||
|
theme: "document",
|
||||||
|
option: {
|
||||||
|
xAxis: {},
|
||||||
|
yAxis: {},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
data: [12, 18, 26]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝 YAML 锚点和别名", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseEChartsFence(`
|
||||||
|
version: 1
|
||||||
|
height: 80mm
|
||||||
|
option:
|
||||||
|
series: &series
|
||||||
|
- type: line
|
||||||
|
color: *series
|
||||||
|
`)
|
||||||
|
).toThrow(EChartsFenceError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝显式 YAML 标签", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseEChartsFence(`
|
||||||
|
version: 1
|
||||||
|
height: 80mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: !!str line
|
||||||
|
`)
|
||||||
|
).toThrow(/不允许使用显式标签/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
defaultEChartsSecurityLimits,
|
||||||
|
normalizeAndValidateEChartsOption
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
describe("normalizeAndValidateEChartsOption", () => {
|
||||||
|
it("接受首版系列和内置数据转换", () => {
|
||||||
|
const normalized = normalizeAndValidateEChartsOption({
|
||||||
|
dataset: {
|
||||||
|
source: [
|
||||||
|
["name", "value"],
|
||||||
|
["A", 1]
|
||||||
|
],
|
||||||
|
transform: {
|
||||||
|
type: "sort",
|
||||||
|
config: {
|
||||||
|
dimension: "value",
|
||||||
|
order: "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "bar",
|
||||||
|
encode: {
|
||||||
|
x: "name",
|
||||||
|
y: "value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized).toMatchObject({
|
||||||
|
series: [{ type: "bar" }]
|
||||||
|
});
|
||||||
|
expect(Object.getPrototypeOf(normalized)).toBe(
|
||||||
|
Object.prototype
|
||||||
|
);
|
||||||
|
expect(normalized.hasOwnProperty("series")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝不支持的系列", () => {
|
||||||
|
expect(() =>
|
||||||
|
normalizeAndValidateEChartsOption({
|
||||||
|
series: [{ type: "custom" }]
|
||||||
|
})
|
||||||
|
).toThrow(/暂不支持 custom/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("接受第二阶段系列和安全的内嵌矢量路径", () => {
|
||||||
|
const types = [
|
||||||
|
"tree",
|
||||||
|
"treemap",
|
||||||
|
"sunburst",
|
||||||
|
"graph",
|
||||||
|
"sankey",
|
||||||
|
"chord",
|
||||||
|
"funnel",
|
||||||
|
"gauge",
|
||||||
|
"pictorialBar"
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
normalizeAndValidateEChartsOption({
|
||||||
|
series: types.map((type) => ({
|
||||||
|
type,
|
||||||
|
...(type === "pictorialBar"
|
||||||
|
? { symbol: "path://M0,0 L10,0 L10,10 Z" }
|
||||||
|
: {})
|
||||||
|
}))
|
||||||
|
}).series
|
||||||
|
).toHaveLength(types.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("限制层级节点和关系边数量", () => {
|
||||||
|
const limits = {
|
||||||
|
...defaultEChartsSecurityLimits,
|
||||||
|
maxHierarchyNodes: 2,
|
||||||
|
maxGraphEdges: 1
|
||||||
|
};
|
||||||
|
expect(() =>
|
||||||
|
normalizeAndValidateEChartsOption(
|
||||||
|
{
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "tree",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
children: [{ value: 1 }, { value: 2 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limits
|
||||||
|
)
|
||||||
|
).toThrow(/层级节点数量不能超过 2/u);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
normalizeAndValidateEChartsOption(
|
||||||
|
{
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "graph",
|
||||||
|
data: [{ name: "A" }, { name: "B" }],
|
||||||
|
links: [
|
||||||
|
{ source: "A", target: "B" },
|
||||||
|
{ source: "B", target: "A" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limits
|
||||||
|
)
|
||||||
|
).toThrow(/边数量不能超过 1/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝外部资源和危险链接", () => {
|
||||||
|
expect(() =>
|
||||||
|
normalizeAndValidateEChartsOption({
|
||||||
|
title: {
|
||||||
|
text: "示例",
|
||||||
|
subtext: "https://example.com/chart"
|
||||||
|
},
|
||||||
|
series: [{ type: "line" }]
|
||||||
|
})
|
||||||
|
).toThrow(/危险 URL/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝 HTML formatter", () => {
|
||||||
|
expect(() =>
|
||||||
|
normalizeAndValidateEChartsOption({
|
||||||
|
tooltip: {
|
||||||
|
formatter: "<img src=x onerror=alert(1)>"
|
||||||
|
},
|
||||||
|
series: [{ type: "line" }]
|
||||||
|
})
|
||||||
|
).toThrow(/不含 HTML/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝原型污染属性", () => {
|
||||||
|
const option = JSON.parse(
|
||||||
|
'{"series":[{"type":"line"}],"__proto__":{"polluted":true}}'
|
||||||
|
);
|
||||||
|
expect(() => normalizeAndValidateEChartsOption(option)).toThrow(
|
||||||
|
/禁止属性/u
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
normalizeAndValidateEChartsOption,
|
||||||
|
validateAndApplyEChartsOptionDefaults
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
function validate(option: Record<string, unknown>) {
|
||||||
|
return validateAndApplyEChartsOptionDefaults(
|
||||||
|
normalizeAndValidateEChartsOption(option)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ECharts 系列语义验证", () => {
|
||||||
|
it("允许从 dataset.source 取数并补充坐标轴", () => {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
dataset: {
|
||||||
|
source: [
|
||||||
|
["name", "value"],
|
||||||
|
["A", 12]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
series: [{ type: "bar" }]
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
xAxis: {},
|
||||||
|
yAxis: {},
|
||||||
|
series: [{ type: "bar" }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺少所有数据来源时报告精确路径", () => {
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [{ type: "line" }]
|
||||||
|
})
|
||||||
|
).toThrow(
|
||||||
|
/option\.series\.0\.data 必须是非空数组/u
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只有表头或空列的 dataset 不算有效数据", () => {
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
dataset: {
|
||||||
|
source: [["name", "value"]]
|
||||||
|
},
|
||||||
|
series: [{ type: "bar" }]
|
||||||
|
})
|
||||||
|
).toThrow(/必须是非空数组/u);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
dataset: {
|
||||||
|
source: {
|
||||||
|
name: [],
|
||||||
|
value: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [{ type: "bar" }]
|
||||||
|
})
|
||||||
|
).toThrow(/必须是非空数组/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("散点图要求二维坐标", () => {
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [{ type: "scatter", data: [12] }]
|
||||||
|
})
|
||||||
|
).toThrow(
|
||||||
|
/option\.series\.0\.data\.0 必须是至少包含两个坐标值/u
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("饼图要求有限数值", () => {
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "pie",
|
||||||
|
data: [{ name: "A", value: "12" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(
|
||||||
|
/option\.series\.0\.data\.0\.value 必须是有限数值/u
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("雷达图要求指标并校验数据维数", () => {
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "radar",
|
||||||
|
data: [{ value: [12, 18] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/option\.radar\.indicator/u);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
radar: {
|
||||||
|
indicator: [
|
||||||
|
{ name: "质量" },
|
||||||
|
{ name: "速度" },
|
||||||
|
{ name: "成本" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "radar",
|
||||||
|
data: [{ value: [12, 18] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/至少包含 3 个指标值/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("热力图区分直角坐标系与日历坐标系", () => {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "heatmap",
|
||||||
|
data: [[0, 1, 20]]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toMatchObject({ xAxis: {}, yAxis: {} });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "heatmap",
|
||||||
|
coordinateSystem: "calendar",
|
||||||
|
data: [["2026-01-01", 20]]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/option\.calendar/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("箱线图要求五数数据", () => {
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "boxplot",
|
||||||
|
data: [[1, 2, 3, 4]]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/五元数组/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("K 线图要求 OHLC 四元数据", () => {
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "candlestick",
|
||||||
|
data: [[20, 18, 16]]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/四元数组/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("极坐标折线图要求完整极坐标组件", () => {
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
coordinateSystem: "polar",
|
||||||
|
data: [[10, 20]]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/option\.polar/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("支持树图并校验递归 children 结构", () => {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "tree",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
name: "根节点",
|
||||||
|
children: [{ name: "子节点" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
series: [{ type: "tree" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "tree",
|
||||||
|
data: [{ name: "根节点", children: "错误" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/data\.0\.children 必须是数组/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("矩形树图和旭日图要求叶节点数值", () => {
|
||||||
|
for (const type of ["treemap", "sunburst"]) {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
name: "分类",
|
||||||
|
children: [{ name: "项目", value: 12 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toMatchObject({ series: [{ type }] });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
data: [{ name: "无数值叶节点" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/data\.0\.value 必须是有限数值/u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("关系图默认选择可渲染布局并校验边引用", () => {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "graph",
|
||||||
|
data: [{ name: "A" }, { name: "B" }],
|
||||||
|
links: [{ source: "A", target: "B" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
series: [{ type: "graph", layout: "force" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "graph",
|
||||||
|
data: [
|
||||||
|
{ name: "A", x: 10, y: 20 },
|
||||||
|
{ name: "B", x: 30, y: 40 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
series: [{ type: "graph", layout: "none" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "graph",
|
||||||
|
nodes: [{ name: "A" }],
|
||||||
|
edges: [{ source: "A", target: "B" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/edges\.0\.target 必须引用已存在的节点/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("桑基图和和弦图要求有效节点、关系边和权重", () => {
|
||||||
|
for (const type of ["sankey", "chord"]) {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
data: [{ name: "A" }, { name: "B" }],
|
||||||
|
links: [
|
||||||
|
{ source: "A", target: "B", value: 12 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toMatchObject({ series: [{ type }] });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
data: [{ name: "A" }, { name: "B" }],
|
||||||
|
links: [{ source: "A", target: "B" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/links\.0\.value 必须是非负有限数值/u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("漏斗图和仪表盘要求有限数值", () => {
|
||||||
|
for (const type of ["funnel", "gauge"]) {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
data: [{ name: "完成率", value: 72 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toMatchObject({ series: [{ type }] });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
data: [{ name: "完成率", value: "72" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toThrow(/data\.0\.value 必须是有限数值/u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("象形柱图复用柱图数据和坐标轴默认值", () => {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "pictorialBar",
|
||||||
|
symbol: "path://M0,0 L10,0 L10,10 Z",
|
||||||
|
data: [12, 18]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
xAxis: {},
|
||||||
|
yAxis: {},
|
||||||
|
series: [{ type: "pictorialBar" }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"@mdit/plugin-katex": "^1.0.1",
|
"@mdit/plugin-katex": "^1.0.1",
|
||||||
"@mdit/plugin-tasklist": "^1.0.1",
|
"@mdit/plugin-tasklist": "^1.0.1",
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"markdown-it": "^14.3.0",
|
"markdown-it": "^14.3.0",
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ 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 {
|
||||||
|
markdownItECharts,
|
||||||
|
type MarkdownItEChartsErrorContext
|
||||||
|
} from "@md-to-pdf/markdown-echarts";
|
||||||
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 +21,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 = {
|
||||||
@@ -107,6 +114,17 @@ markdown.renderer.rules.fence = (
|
|||||||
return renderFallbackFence(tokens, index, renderer);
|
return renderFallbackFence(tokens, index, renderer);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
markdown.use(markdownItECharts, {
|
||||||
|
onError(error, context: MarkdownItEChartsErrorContext) {
|
||||||
|
const environment = context.environment as
|
||||||
|
| { warnings?: string[] }
|
||||||
|
| undefined;
|
||||||
|
environment?.warnings?.push(
|
||||||
|
`ECharts 图表配置无效:${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const safeHtmlOptions: sanitizeHtml.IOptions = {
|
const safeHtmlOptions: sanitizeHtml.IOptions = {
|
||||||
allowedTags: [
|
allowedTags: [
|
||||||
...sanitizeHtml.defaults.allowedTags,
|
...sanitizeHtml.defaults.allowedTags,
|
||||||
@@ -127,10 +145,16 @@ const safeHtmlOptions: sanitizeHtml.IOptions = {
|
|||||||
"*": ["class", "id", "title", "aria-*", "role"],
|
"*": ["class", "id", "title", "aria-*", "role"],
|
||||||
a: ["href", "name", "target", "rel"],
|
a: ["href", "name", "target", "rel"],
|
||||||
div: ["class", "data-mermaid-pending"],
|
div: ["class", "data-mermaid-pending"],
|
||||||
|
figure: [
|
||||||
|
"class",
|
||||||
|
"data-echarts-pending",
|
||||||
|
"data-echarts-protocol"
|
||||||
|
],
|
||||||
img: ["src", "alt", "title", "width", "height"],
|
img: ["src", "alt", "title", "width", "height"],
|
||||||
input: ["type", "checked", "disabled", "class"],
|
input: ["type", "checked", "disabled", "class"],
|
||||||
li: ["class", "value"],
|
li: ["class", "value"],
|
||||||
ol: ["class", "start"],
|
ol: ["class", "start"],
|
||||||
|
pre: ["class", "hidden"],
|
||||||
span: ["class", "style"],
|
span: ["class", "style"],
|
||||||
td: ["colspan", "rowspan", "style"],
|
td: ["colspan", "rowspan", "style"],
|
||||||
th: ["colspan", "rowspan", "scope", "style"]
|
th: ["colspan", "rowspan", "scope", "style"]
|
||||||
@@ -198,9 +222,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 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +283,8 @@ function detectFeatures(html: string): ThemeFeature[] {
|
|||||||
["task-list", /\bmd-task-list-item\b/u],
|
["task-list", /\bmd-task-list-item\b/u],
|
||||||
["footnote", /\bfootnote/u],
|
["footnote", /\bfootnote/u],
|
||||||
["katex", /\bkatex\b/u],
|
["katex", /\bkatex\b/u],
|
||||||
["mermaid", /\bmermaid\b/u]
|
["mermaid", /\bmermaid\b/u],
|
||||||
|
["echarts", /\bmd-echarts\b/u]
|
||||||
];
|
];
|
||||||
|
|
||||||
return featureChecks
|
return featureChecks
|
||||||
|
|||||||
@@ -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,93 @@ 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("将 ECharts YAML 围栏转换为安全占位并识别能力", () => {
|
||||||
|
const result = renderMarkdown(`
|
||||||
|
\`\`\`echarts
|
||||||
|
version: 1
|
||||||
|
height: 80mm
|
||||||
|
caption: 年度收入
|
||||||
|
option:
|
||||||
|
xAxis:
|
||||||
|
type: category
|
||||||
|
data: [2023, 2024]
|
||||||
|
yAxis:
|
||||||
|
type: value
|
||||||
|
series:
|
||||||
|
- type: bar
|
||||||
|
data: [120, 180]
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(result.bodyHtml).toContain('class="md-echarts"');
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'data-echarts-pending="true"'
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'data-echarts-protocol="1"'
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'class="md-echarts-definition" hidden="hidden"'
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
"<figcaption>年度收入</figcaption>"
|
||||||
|
);
|
||||||
|
expect(result.features).toContain("echarts");
|
||||||
|
expect(result.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将无效 ECharts 配置转为局部错误和文档告警", () => {
|
||||||
|
const result = renderMarkdown(`
|
||||||
|
\`\`\`echarts
|
||||||
|
version: 1
|
||||||
|
height: 80mm
|
||||||
|
option:
|
||||||
|
series:
|
||||||
|
- type: custom
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'class="md-echarts-error"'
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).not.toContain(
|
||||||
|
"data-echarts-pending"
|
||||||
|
);
|
||||||
|
expect(result.features).toContain("echarts");
|
||||||
|
expect(result.warnings).toHaveLength(1);
|
||||||
|
expect(result.warnings[0]).toContain(
|
||||||
|
"ECharts 图表配置无效"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("阻止原始 HTML 和危险链接形成可执行内容", () => {
|
it("阻止原始 HTML 和危险链接形成可执行内容", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user